Update to 0.9.3 #43
41
backend/migrate_add_session_report_date.py
Normal file
41
backend/migrate_add_session_report_date.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Migration: add report_date to monitoring_sessions.
|
||||
|
||||
Run once:
|
||||
python backend/migrate_add_session_report_date.py
|
||||
|
||||
Or inside the container:
|
||||
docker exec terra-view-terra-view-1 python3 backend/migrate_add_session_report_date.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.database import engine
|
||||
from sqlalchemy import text
|
||||
|
||||
def run():
|
||||
with engine.connect() as conn:
|
||||
# Check which columns already exist
|
||||
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||
existing = {row[1] for row in result}
|
||||
|
||||
added = []
|
||||
for col, definition in [
|
||||
("report_date", "DATE"),
|
||||
]:
|
||||
if col not in existing:
|
||||
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||
added.append(col)
|
||||
else:
|
||||
print(f" Column '{col}' already exists — skipping.")
|
||||
|
||||
conn.commit()
|
||||
|
||||
if added:
|
||||
print(f" Added columns: {', '.join(added)}")
|
||||
print("Migration complete.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -309,6 +309,11 @@ class MonitoringSession(Base):
|
||||
period_start_hour = Column(Integer, nullable=True)
|
||||
period_end_hour = Column(Integer, nullable=True)
|
||||
|
||||
# For day sessions: the specific calendar date to use for report filtering.
|
||||
# Overrides the automatic "last date with daytime rows" heuristic.
|
||||
# Null = use heuristic.
|
||||
report_date = Column(Date, nullable=True)
|
||||
|
||||
# Snapshot of device configuration at recording time
|
||||
session_metadata = Column(Text, nullable=True) # JSON
|
||||
|
||||
|
||||
@@ -1159,7 +1159,7 @@ async def get_project_sessions(
|
||||
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
|
||||
from datetime import date as _date
|
||||
local_start = utc_to_local(session.started_at)
|
||||
start_day = local_start.date()
|
||||
start_day = session.report_date if session.report_date else local_start.date()
|
||||
sh = session.period_start_hour
|
||||
eh = session.period_end_hour
|
||||
|
||||
@@ -1259,10 +1259,11 @@ async def get_sessions_calendar(
|
||||
# Build calendar grid (Mon–Sun weeks)
|
||||
first_day = _date(year, month, 1)
|
||||
last_day = _date(year, month, monthrange(year, month)[1])
|
||||
# Start on Monday before first_day
|
||||
grid_start = first_day - _td(days=first_day.weekday())
|
||||
# End on Sunday after last_day
|
||||
days_after = 6 - last_day.weekday()
|
||||
# Start on Sunday before first_day (isoweekday: Mon=1 … Sun=7; weekday: Mon=0 … Sun=6)
|
||||
days_before = (first_day.isoweekday() % 7) # Sun=0, Mon=1, …, Sat=6
|
||||
grid_start = first_day - _td(days=days_before)
|
||||
# End on Saturday after last_day
|
||||
days_after = 6 - (last_day.isoweekday() % 7)
|
||||
grid_end = last_day + _td(days=days_after)
|
||||
|
||||
weeks = []
|
||||
@@ -2061,6 +2062,17 @@ async def patch_session(
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail=f"{field} must be an integer 0–23 or null")
|
||||
|
||||
if "report_date" in data:
|
||||
val = data["report_date"]
|
||||
if val is None or val == "":
|
||||
session.report_date = None
|
||||
else:
|
||||
try:
|
||||
from datetime import date as _date
|
||||
session.report_date = _date.fromisoformat(str(val))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid report_date format. Use YYYY-MM-DD.")
|
||||
|
||||
db.commit()
|
||||
return JSONResponse({
|
||||
"status": "success",
|
||||
@@ -2068,6 +2080,7 @@ async def patch_session(
|
||||
"period_type": session.period_type,
|
||||
"period_start_hour": session.period_start_hour,
|
||||
"period_end_hour": session.period_end_hour,
|
||||
"report_date": session.report_date.isoformat() if session.report_date else None,
|
||||
})
|
||||
|
||||
|
||||
@@ -2103,7 +2116,7 @@ async def view_session_detail(
|
||||
effective_range = None
|
||||
if session.period_start_hour is not None and session.period_end_hour is not None and session.started_at:
|
||||
local_start = utc_to_local(session.started_at)
|
||||
start_day = local_start.date()
|
||||
start_day = session.report_date if session.report_date else local_start.date()
|
||||
sh = session.period_start_hour
|
||||
eh = session.period_end_hour
|
||||
def _fmt_h(h):
|
||||
@@ -2137,6 +2150,7 @@ async def view_session_detail(
|
||||
"files": files,
|
||||
"effective_range": effective_range,
|
||||
"session_meta": session_meta,
|
||||
"report_date": session.report_date.isoformat() if session.report_date else "",
|
||||
})
|
||||
|
||||
|
||||
@@ -2672,7 +2686,7 @@ async def generate_excel_report(
|
||||
_plot_border.ln.solidFill = "000000"
|
||||
_plot_border.ln.w = 12700
|
||||
chart.plot_area.spPr = _plot_border
|
||||
ws.add_chart(chart, "H4")
|
||||
ws.add_chart(chart, "I4")
|
||||
|
||||
# --- Stats table: note at I28-I29, headers at I31, data rows 32-34 ---
|
||||
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
|
||||
@@ -3160,7 +3174,7 @@ async def generate_report_from_preview(
|
||||
_plot_border.ln.solidFill = "000000"
|
||||
_plot_border.ln.w = 12700
|
||||
chart.plot_area.spPr = _plot_border
|
||||
ws.add_chart(chart, "H4")
|
||||
ws.add_chart(chart, "I4")
|
||||
|
||||
# --- Stats block starting at I28 ---
|
||||
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
||||
@@ -3508,7 +3522,7 @@ async def generate_combined_excel_report(
|
||||
_plot_border.ln.solidFill = "000000"
|
||||
_plot_border.ln.w = 12700
|
||||
chart.plot_area.spPr = _plot_border
|
||||
ws.add_chart(chart, "H4")
|
||||
ws.add_chart(chart, "I4")
|
||||
|
||||
# Stats table: note at I28-I29, headers at I31, data rows 32-34, border row 35
|
||||
note1 = ws.cell(row=28, column=9, value="Note: Averages are calculated by determining the arithmetic average ")
|
||||
@@ -3751,6 +3765,7 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
||||
"period_start_hour": session.period_start_hour,
|
||||
"period_end_hour": session.period_end_hour,
|
||||
"started_at": session.started_at,
|
||||
"report_date": session.report_date,
|
||||
"rows": [],
|
||||
}
|
||||
|
||||
@@ -3807,6 +3822,9 @@ def _build_location_data_from_sessions(project_id: str, db, selected_session_ids
|
||||
if is_day_session:
|
||||
# Day-style: start_h <= hour < end_h, restricted to the LAST calendar date
|
||||
in_window = lambda h: sh <= h < eh
|
||||
if entry.get("report_date"):
|
||||
target_date = entry["report_date"]
|
||||
else:
|
||||
daytime_dates = sorted({
|
||||
dt.date() for dt, row in parsed if dt and in_window(dt.hour)
|
||||
})
|
||||
@@ -4101,7 +4119,7 @@ async def generate_combined_from_preview(
|
||||
_plot_border.ln.solidFill = "000000"
|
||||
_plot_border.ln.w = 12700
|
||||
chart.plot_area.spPr = _plot_border
|
||||
ws.add_chart(chart, "H4")
|
||||
ws.add_chart(chart, "I4")
|
||||
|
||||
hdr_fill_tbl = PatternFill(start_color="F2F2F2", end_color="F2F2F2", fill_type="solid")
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
|
||||
{% for day_name in ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] %}
|
||||
{% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
|
||||
<div class="py-2 text-center text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wide
|
||||
{% if loop.index >= 6 %}text-amber-500 dark:text-amber-400{% endif %}">
|
||||
{% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% endif %}">
|
||||
{{ day_name }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective"></dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Report Date</dt>
|
||||
<dd class="font-medium text-gray-900 dark:text-white" id="info-report-date">
|
||||
{{ report_date or '— (auto)' }}
|
||||
</dd>
|
||||
</div>
|
||||
{% if session.started_at %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
|
||||
@@ -118,19 +124,29 @@
|
||||
|
||||
<!-- Edit Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Edit Session</h2>
|
||||
<form id="edit-form" class="space-y-3" onsubmit="saveSession(event)">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Edit Session</h2>
|
||||
<form id="edit-form" onsubmit="saveSession(event)">
|
||||
|
||||
<!-- Label -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Label</label>
|
||||
<input type="text" id="edit-label" name="session_label"
|
||||
value="{{ session.session_label or '' }}"
|
||||
placeholder="e.g. NRL-1 — Mon 3/24 — Night"
|
||||
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
|
||||
<!-- Section: Required Recording Window -->
|
||||
<div class="mb-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
|
||||
<p class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-0.5">Required Recording Window</p>
|
||||
<p class="text-xs text-indigo-500 dark:text-indigo-400 mb-3">The hours that count for reports. Only data within this window is included.</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Period Type</label>
|
||||
<select id="edit-period-type" name="period_type"
|
||||
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
onchange="fillPeriodDefaults()"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
<option value="">— Not Set —</option>
|
||||
<option value="weekday_day" {% if session.period_type == 'weekday_day' %}selected{% endif %}>Weekday Day</option>
|
||||
<option value="weekday_night" {% if session.period_type == 'weekday_night' %}selected{% endif %}>Weekday Night</option>
|
||||
@@ -138,46 +154,77 @@
|
||||
<option value="weekend_night" {% if session.period_type == 'weekend_night' %}selected{% endif %}>Weekend Night</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (0–23)</label>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From (hour)</label>
|
||||
<div class="relative">
|
||||
<input type="number" min="0" max="23" id="edit-start-hour" name="period_start_hour"
|
||||
value="{{ session.period_start_hour if session.period_start_hour is not none else '' }}"
|
||||
placeholder="e.g. 19"
|
||||
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
oninput="updateWindowPreview()"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (0–23)</label>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To (hour)</label>
|
||||
<input type="number" min="0" max="23" id="edit-end-hour" name="period_end_hour"
|
||||
value="{{ session.period_end_hour if session.period_end_hour is not none else '' }}"
|
||||
placeholder="e.g. 7"
|
||||
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
oninput="updateWindowPreview()"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live preview -->
|
||||
<div id="window-preview" class="text-xs font-medium text-indigo-600 dark:text-indigo-300 min-h-[1rem]">
|
||||
{% if session.period_start_hour is not none and session.period_end_hour is not none %}
|
||||
{% set sh = session.period_start_hour %}
|
||||
{% set eh = session.period_end_hour %}
|
||||
Window: {{ (sh % 12) or 12 }}:00 {{ 'AM' if sh < 12 else 'PM' }} → {{ (eh % 12) or 12 }}:00 {{ 'AM' if eh < 12 else 'PM' }}{% if eh <= sh %} (crosses midnight){% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
Target Date <span class="text-gray-400">(optional — day sessions only)</span>
|
||||
</label>
|
||||
<input type="date" id="edit-report-date" name="report_date"
|
||||
value="{{ report_date or '' }}"
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave blank to auto-select the last day with data in the window.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Device On/Off Times -->
|
||||
<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-0.5">Device On/Off Times</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">When the meter was actually running. Usually set automatically from the data file.</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Time (local)</label>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered on</label>
|
||||
<input type="datetime-local" id="edit-started-at" name="started_at"
|
||||
value="{{ session.started_at|local_datetime_input if session.started_at else '' }}"
|
||||
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Time (local)</label>
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered off</label>
|
||||
<input type="datetime-local" id="edit-stopped-at" name="stopped_at"
|
||||
value="{{ session.stopped_at|local_datetime_input if session.stopped_at else '' }}"
|
||||
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||
</div>
|
||||
<div class="flex gap-2 pt-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="submit"
|
||||
class="flex-1 text-sm py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||
Save Changes
|
||||
</button>
|
||||
<button type="button" onclick="fillPeriodDefaults()"
|
||||
class="text-sm py-2 px-3 border border-gray-200 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Fill default hours for selected period type">
|
||||
Defaults
|
||||
</button>
|
||||
</div>
|
||||
<div id="save-status" class="hidden text-xs text-center pt-1"></div>
|
||||
<div id="save-status" class="hidden text-xs text-center pt-2"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,8 +354,23 @@ function fillPeriodDefaults() {
|
||||
document.getElementById('edit-start-hour').value = defaults.start;
|
||||
document.getElementById('edit-end-hour').value = defaults.end;
|
||||
}
|
||||
updateWindowPreview();
|
||||
}
|
||||
|
||||
function updateWindowPreview() {
|
||||
const sh = parseInt(document.getElementById('edit-start-hour').value, 10);
|
||||
const eh = parseInt(document.getElementById('edit-end-hour').value, 10);
|
||||
const el = document.getElementById('window-preview');
|
||||
if (!el) return;
|
||||
if (isNaN(sh) || isNaN(eh)) { el.textContent = ''; return; }
|
||||
function fmt(h) { return `${h % 12 || 12}:00 ${h < 12 ? 'AM' : 'PM'}`; }
|
||||
const crosses = eh <= sh;
|
||||
el.textContent = `Window: ${fmt(sh)} → ${fmt(eh)}${crosses ? ' (crosses midnight)' : ''}`;
|
||||
}
|
||||
|
||||
// Run once on load to populate preview if values already set
|
||||
document.addEventListener('DOMContentLoaded', updateWindowPreview);
|
||||
|
||||
async function saveSession(e) {
|
||||
e.preventDefault();
|
||||
const status = document.getElementById('save-status');
|
||||
@@ -330,6 +392,9 @@ async function saveSession(e) {
|
||||
payload.period_start_hour = sh !== '' ? parseInt(sh, 10) : null;
|
||||
payload.period_end_hour = eh !== '' ? parseInt(eh, 10) : null;
|
||||
|
||||
const rd = form.report_date.value;
|
||||
payload.report_date = rd || null;
|
||||
|
||||
const sa = form.started_at.value;
|
||||
if (sa) payload.started_at = sa;
|
||||
|
||||
@@ -354,6 +419,7 @@ async function saveSession(e) {
|
||||
weekday_day: 'Weekday Day', weekday_night: 'Weekday Night',
|
||||
weekend_day: 'Weekend Day', weekend_night: 'Weekend Night'
|
||||
}[result.period_type] || '—';
|
||||
document.getElementById('info-report-date').textContent = result.report_date || '— (auto)';
|
||||
|
||||
status.className = 'text-xs text-center pt-1 text-green-600 dark:text-green-400';
|
||||
status.textContent = 'Saved!';
|
||||
|
||||
Reference in New Issue
Block a user