feat: Refactor template handling, improve scheduler functions, and add timezone utilities

- Moved Jinja2 template setup to a shared configuration file (templates_config.py) for consistent usage across routers.
- Introduced timezone utilities in a new module (timezone.py) to handle UTC to local time conversions and formatting.
- Updated all relevant routers to use the new shared template configuration and timezone filters.
- Enhanced templates to utilize local time formatting for various datetime fields, improving user experience with timezone awareness.
This commit is contained in:
serversdwn
2026-01-23 06:05:39 +00:00
parent c771a86675
commit 8431784708
23 changed files with 418 additions and 141 deletions

View File

@@ -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")

View File

@@ -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")
# ============================================================================

View File

@@ -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")

View File

@@ -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")
# ============================================================================

View File

@@ -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,
})

View File

@@ -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",
})

View File

@@ -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")
# ============================================================================

View File

@@ -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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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]:

View File

@@ -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

View File

@@ -0,0 +1 @@
# Utils package

173
backend/utils/timezone.py Normal file
View File

@@ -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"),
]