diff --git a/backend/main.py b/backend/main.py index 3e08546..cd3797c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -58,8 +58,8 @@ app.add_middleware( # Mount static files app.mount("/static", StaticFiles(directory="backend/static"), name="static") -# Setup Jinja2 templates -templates = Jinja2Templates(directory="templates") +# Use shared templates configuration with timezone filters +from backend.templates_config import templates # Add custom context processor to inject environment variable into all templates @app.middleware("http") diff --git a/backend/routers/alerts.py b/backend/routers/alerts.py index 42a8353..67e4a47 100644 --- a/backend/routers/alerts.py +++ b/backend/routers/alerts.py @@ -5,7 +5,6 @@ API endpoints for managing in-app alerts. """ from fastapi import APIRouter, Request, Depends, HTTPException, Query -from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from typing import Optional @@ -14,9 +13,9 @@ from datetime import datetime, timedelta from backend.database import get_db from backend.models import Alert, RosterUnit from backend.services.alert_service import get_alert_service +from backend.templates_config import templates router = APIRouter(prefix="/api/alerts", tags=["alerts"]) -templates = Jinja2Templates(directory="templates") # ============================================================================ diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py index 525edec..ae1ae51 100644 --- a/backend/routers/dashboard.py +++ b/backend/routers/dashboard.py @@ -1,10 +1,9 @@ from fastapi import APIRouter, Request, Depends -from fastapi.templating import Jinja2Templates from backend.services.snapshot import emit_status_snapshot +from backend.templates_config import templates router = APIRouter() -templates = Jinja2Templates(directory="templates") @router.get("/dashboard/active") diff --git a/backend/routers/project_locations.py b/backend/routers/project_locations.py index 936ec47..54d36b1 100644 --- a/backend/routers/project_locations.py +++ b/backend/routers/project_locations.py @@ -6,7 +6,6 @@ and unit assignments within projects. """ from fastapi import APIRouter, Request, Depends, HTTPException, Query -from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from sqlalchemy import and_, or_ @@ -24,9 +23,9 @@ from backend.models import ( RosterUnit, RecordingSession, ) +from backend.templates_config import templates router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) -templates = Jinja2Templates(directory="templates") # ============================================================================ diff --git a/backend/routers/projects.py b/backend/routers/projects.py index 162283f..9b06de5 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -9,17 +9,19 @@ Provides API endpoints for the Projects system: """ from fastapi import APIRouter, Request, Depends, HTTPException, Query -from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import func, and_ from datetime import datetime, timedelta from typing import Optional +from collections import OrderedDict import uuid import json import logging import io +from backend.utils.timezone import utc_to_local, format_local_datetime + from backend.database import get_db from backend.models import ( Project, @@ -31,9 +33,9 @@ from backend.models import ( RecurringSchedule, RosterUnit, ) +from backend.templates_config import templates router = APIRouter(prefix="/api/projects", tags=["projects"]) -templates = Jinja2Templates(directory="templates") logger = logging.getLogger(__name__) @@ -461,16 +463,37 @@ async def get_project_schedules( if status: query = query.filter(ScheduledAction.execution_status == status) - schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all() + # For pending actions, show soonest first (ascending) + # For completed/failed, show most recent first (descending) + if status == "pending": + schedules = query.order_by(ScheduledAction.scheduled_time.asc()).all() + else: + schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all() - # Enrich with location details - schedules_data = [] + # Enrich with location details and group by date + schedules_by_date = OrderedDict() for schedule in schedules: location = None if schedule.location_id: location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first() - schedules_data.append({ + # Get local date for grouping + if schedule.scheduled_time: + local_dt = utc_to_local(schedule.scheduled_time) + date_key = local_dt.strftime("%Y-%m-%d") + date_display = local_dt.strftime("%A, %B %d, %Y") # "Wednesday, January 22, 2026" + else: + date_key = "unknown" + date_display = "Unknown Date" + + if date_key not in schedules_by_date: + schedules_by_date[date_key] = { + "date_display": date_display, + "date_key": date_key, + "actions": [], + } + + schedules_by_date[date_key]["actions"].append({ "schedule": schedule, "location": location, }) @@ -478,7 +501,7 @@ async def get_project_schedules( return templates.TemplateResponse("partials/projects/schedule_list.html", { "request": request, "project_id": project_id, - "schedules": schedules_data, + "schedules_by_date": schedules_by_date, }) diff --git a/backend/routers/recurring_schedules.py b/backend/routers/recurring_schedules.py index 3168edb..b784c5d 100644 --- a/backend/routers/recurring_schedules.py +++ b/backend/routers/recurring_schedules.py @@ -5,7 +5,6 @@ API endpoints for managing recurring monitoring schedules. """ from fastapi import APIRouter, Request, Depends, HTTPException, Query -from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from typing import Optional @@ -15,9 +14,9 @@ import json from backend.database import get_db from backend.models import RecurringSchedule, MonitoringLocation, Project, RosterUnit from backend.services.recurring_schedule_service import get_recurring_schedule_service +from backend.templates_config import templates router = APIRouter(prefix="/api/projects/{project_id}/recurring-schedules", tags=["recurring-schedules"]) -templates = Jinja2Templates(directory="templates") # ============================================================================ @@ -209,17 +208,25 @@ async def create_recurring_schedule( auto_increment_index=data.get("auto_increment_index", True), timezone=data.get("timezone", "America/New_York"), ) + + # Generate actions immediately so they appear right away + generated_actions = service.generate_actions_for_schedule(schedule, horizon_days=7) + created_schedules.append({ "schedule_id": schedule.id, "location_id": location.id, "location_name": location.name, + "actions_generated": len(generated_actions), }) + total_actions = sum(s.get("actions_generated", 0) for s in created_schedules) + return JSONResponse({ "success": True, "schedules": created_schedules, "count": len(created_schedules), - "message": f"Created {len(created_schedules)} recurring schedule(s)", + "actions_generated": total_actions, + "message": f"Created {len(created_schedules)} recurring schedule(s) with {total_actions} upcoming actions", }) diff --git a/backend/routers/scheduler.py b/backend/routers/scheduler.py index 40c9b26..3c65e1c 100644 --- a/backend/routers/scheduler.py +++ b/backend/routers/scheduler.py @@ -5,7 +5,6 @@ Handles scheduled actions for automated recording control. """ from fastapi import APIRouter, Request, Depends, HTTPException, Query -from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse, JSONResponse from sqlalchemy.orm import Session from sqlalchemy import and_, or_ @@ -23,9 +22,9 @@ from backend.models import ( RosterUnit, ) from backend.services.scheduler import get_scheduler +from backend.templates_config import templates router = APIRouter(prefix="/api/projects/{project_id}/scheduler", tags=["scheduler"]) -templates = Jinja2Templates(directory="templates") # ============================================================================ diff --git a/backend/routers/seismo_dashboard.py b/backend/routers/seismo_dashboard.py index e54a814..6f99d6d 100644 --- a/backend/routers/seismo_dashboard.py +++ b/backend/routers/seismo_dashboard.py @@ -5,13 +5,12 @@ Provides endpoints for the seismograph-specific dashboard from fastapi import APIRouter, Request, Depends, Query from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from backend.database import get_db from backend.models import RosterUnit +from backend.templates_config import templates router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"]) -templates = Jinja2Templates(directory="templates") @router.get("/stats", response_class=HTMLResponse) diff --git a/backend/routers/slm_dashboard.py b/backend/routers/slm_dashboard.py index 767626d..be70cc2 100644 --- a/backend/routers/slm_dashboard.py +++ b/backend/routers/slm_dashboard.py @@ -5,7 +5,6 @@ Provides API endpoints for the Sound Level Meters dashboard page. """ from fastapi import APIRouter, Request, Depends, Query -from fastapi.templating import Jinja2Templates from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from sqlalchemy import func @@ -18,11 +17,11 @@ import os from backend.database import get_db from backend.models import RosterUnit from backend.routers.roster_edit import sync_slm_to_slmm_cache +from backend.templates_config import templates logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) -templates = Jinja2Templates(directory="templates") # SLMM backend URL - configurable via environment variable SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") diff --git a/backend/routers/slm_ui.py b/backend/routers/slm_ui.py index 6aed25b..b003771 100644 --- a/backend/routers/slm_ui.py +++ b/backend/routers/slm_ui.py @@ -6,7 +6,6 @@ Provides endpoints for SLM dashboard cards, detail pages, and real-time data. from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from datetime import datetime import httpx @@ -15,11 +14,11 @@ import os from backend.database import get_db from backend.models import RosterUnit +from backend.templates_config import templates logger = logging.getLogger(__name__) router = APIRouter(prefix="/slm", tags=["slm-ui"]) -templates = Jinja2Templates(directory="templates") SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100") diff --git a/backend/services/recurring_schedule_service.py b/backend/services/recurring_schedule_service.py index b606329..d4a8d83 100644 --- a/backend/services/recurring_schedule_service.py +++ b/backend/services/recurring_schedule_service.py @@ -146,13 +146,22 @@ class RecurringScheduleService: return False # Delete pending generated actions for this schedule - # Note: We don't have recurring_schedule_id field yet, so we can't clean up - # generated actions. This is fine for now. + # The schedule_id is stored in the notes field as JSON + pending_actions = self.db.query(ScheduledAction).filter( + and_( + ScheduledAction.execution_status == "pending", + ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'), + ) + ).all() + + deleted_count = len(pending_actions) + for action in pending_actions: + self.db.delete(action) self.db.delete(schedule) self.db.commit() - logger.info(f"Deleted recurring schedule: {schedule.name}") + logger.info(f"Deleted recurring schedule: {schedule.name} (and {deleted_count} pending actions)") return True def enable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]: diff --git a/backend/templates_config.py b/backend/templates_config.py new file mode 100644 index 0000000..c0e4212 --- /dev/null +++ b/backend/templates_config.py @@ -0,0 +1,39 @@ +""" +Shared Jinja2 templates configuration. + +All routers should import `templates` from this module to get consistent +filter and global function registration. +""" + +from fastapi.templating import Jinja2Templates + +# Import timezone utilities +from backend.utils.timezone import ( + format_local_datetime, format_local_time, + get_user_timezone, get_timezone_abbreviation +) + + +def jinja_local_datetime(dt, fmt="%Y-%m-%d %H:%M"): + """Jinja filter to convert UTC datetime to local timezone.""" + return format_local_datetime(dt, fmt) + + +def jinja_local_time(dt): + """Jinja filter to format time in local timezone.""" + return format_local_time(dt) + + +def jinja_timezone_abbr(): + """Jinja global to get current timezone abbreviation.""" + return get_timezone_abbreviation() + + +# Create templates instance +templates = Jinja2Templates(directory="templates") + +# Register Jinja filters and globals +templates.env.filters["local_datetime"] = jinja_local_datetime +templates.env.filters["local_time"] = jinja_local_time +templates.env.globals["timezone_abbr"] = jinja_timezone_abbr +templates.env.globals["get_user_timezone"] = get_user_timezone diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..dd7ee44 --- /dev/null +++ b/backend/utils/__init__.py @@ -0,0 +1 @@ +# Utils package diff --git a/backend/utils/timezone.py b/backend/utils/timezone.py new file mode 100644 index 0000000..6a426cf --- /dev/null +++ b/backend/utils/timezone.py @@ -0,0 +1,173 @@ +""" +Timezone utilities for Terra-View. + +Provides consistent timezone handling throughout the application. +All database times are stored in UTC; this module converts for display. +""" + +from datetime import datetime +from zoneinfo import ZoneInfo +from typing import Optional + +from backend.database import SessionLocal +from backend.models import UserPreferences + + +# Default timezone if none set +DEFAULT_TIMEZONE = "America/New_York" + + +def get_user_timezone() -> str: + """ + Get the user's configured timezone from preferences. + + Returns: + Timezone string (e.g., "America/New_York") + """ + db = SessionLocal() + try: + prefs = db.query(UserPreferences).filter_by(id=1).first() + if prefs and prefs.timezone: + return prefs.timezone + return DEFAULT_TIMEZONE + finally: + db.close() + + +def get_timezone_info(tz_name: str = None) -> ZoneInfo: + """ + Get ZoneInfo object for the specified or user's timezone. + + Args: + tz_name: Timezone name, or None to use user preference + + Returns: + ZoneInfo object + """ + if tz_name is None: + tz_name = get_user_timezone() + try: + return ZoneInfo(tz_name) + except Exception: + return ZoneInfo(DEFAULT_TIMEZONE) + + +def utc_to_local(dt: datetime, tz_name: str = None) -> datetime: + """ + Convert a UTC datetime to local timezone. + + Args: + dt: Datetime in UTC (naive or aware) + tz_name: Target timezone, or None to use user preference + + Returns: + Datetime in local timezone + """ + if dt is None: + return None + + tz = get_timezone_info(tz_name) + + # Assume naive datetime is UTC + if dt.tzinfo is None: + dt = dt.replace(tzinfo=ZoneInfo("UTC")) + + return dt.astimezone(tz) + + +def local_to_utc(dt: datetime, tz_name: str = None) -> datetime: + """ + Convert a local datetime to UTC. + + Args: + dt: Datetime in local timezone (naive or aware) + tz_name: Source timezone, or None to use user preference + + Returns: + Datetime in UTC (naive, for database storage) + """ + if dt is None: + return None + + tz = get_timezone_info(tz_name) + + # Assume naive datetime is in local timezone + if dt.tzinfo is None: + dt = dt.replace(tzinfo=tz) + + # Convert to UTC and strip tzinfo for database storage + return dt.astimezone(ZoneInfo("UTC")).replace(tzinfo=None) + + +def format_local_datetime(dt: datetime, fmt: str = "%Y-%m-%d %H:%M", tz_name: str = None) -> str: + """ + Format a UTC datetime as local time string. + + Args: + dt: Datetime in UTC + fmt: strftime format string + tz_name: Target timezone, or None to use user preference + + Returns: + Formatted datetime string in local time + """ + if dt is None: + return "N/A" + + local_dt = utc_to_local(dt, tz_name) + return local_dt.strftime(fmt) + + +def format_local_time(dt: datetime, tz_name: str = None) -> str: + """ + Format a UTC datetime as local time (HH:MM format). + + Args: + dt: Datetime in UTC + tz_name: Target timezone + + Returns: + Time string in HH:MM format + """ + return format_local_datetime(dt, "%H:%M", tz_name) + + +def format_local_date(dt: datetime, tz_name: str = None) -> str: + """ + Format a UTC datetime as local date (YYYY-MM-DD format). + + Args: + dt: Datetime in UTC + tz_name: Target timezone + + Returns: + Date string + """ + return format_local_datetime(dt, "%Y-%m-%d", tz_name) + + +def get_timezone_abbreviation(tz_name: str = None) -> str: + """ + Get the abbreviation for a timezone (e.g., EST, EDT, PST). + + Args: + tz_name: Timezone name, or None to use user preference + + Returns: + Timezone abbreviation + """ + tz = get_timezone_info(tz_name) + now = datetime.now(tz) + return now.strftime("%Z") + + +# Common US timezone choices for settings dropdown +TIMEZONE_CHOICES = [ + ("America/New_York", "Eastern Time (ET)"), + ("America/Chicago", "Central Time (CT)"), + ("America/Denver", "Mountain Time (MT)"), + ("America/Los_Angeles", "Pacific Time (PT)"), + ("America/Anchorage", "Alaska Time (AKT)"), + ("Pacific/Honolulu", "Hawaii Time (HT)"), + ("UTC", "UTC"), +] diff --git a/templates/nrl_detail.html b/templates/nrl_detail.html index 5403237..470a93f 100644 --- a/templates/nrl_detail.html +++ b/templates/nrl_detail.html @@ -123,7 +123,7 @@ {% endif %}
Location: {{ item.location.name }}
{% endif %}- Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}{% else %}Unknown{% endif %} + Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at|local_datetime }}{% else %}Unknown{% endif %}