- 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.
466 lines
15 KiB
Python
466 lines
15 KiB
Python
"""
|
|
Recurring Schedules Router
|
|
|
|
API endpoints for managing recurring monitoring schedules.
|
|
"""
|
|
|
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
|
from fastapi.responses import HTMLResponse, JSONResponse
|
|
from sqlalchemy.orm import Session
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
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"])
|
|
|
|
|
|
# ============================================================================
|
|
# List and Get
|
|
# ============================================================================
|
|
|
|
@router.get("/")
|
|
async def list_recurring_schedules(
|
|
project_id: str,
|
|
db: Session = Depends(get_db),
|
|
enabled_only: bool = Query(False),
|
|
):
|
|
"""
|
|
List all recurring schedules for a project.
|
|
"""
|
|
project = db.query(Project).filter_by(id=project_id).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
query = db.query(RecurringSchedule).filter_by(project_id=project_id)
|
|
if enabled_only:
|
|
query = query.filter_by(enabled=True)
|
|
|
|
schedules = query.order_by(RecurringSchedule.created_at.desc()).all()
|
|
|
|
return {
|
|
"schedules": [
|
|
{
|
|
"id": s.id,
|
|
"name": s.name,
|
|
"schedule_type": s.schedule_type,
|
|
"device_type": s.device_type,
|
|
"location_id": s.location_id,
|
|
"unit_id": s.unit_id,
|
|
"enabled": s.enabled,
|
|
"weekly_pattern": json.loads(s.weekly_pattern) if s.weekly_pattern else None,
|
|
"interval_type": s.interval_type,
|
|
"cycle_time": s.cycle_time,
|
|
"include_download": s.include_download,
|
|
"timezone": s.timezone,
|
|
"next_occurrence": s.next_occurrence.isoformat() if s.next_occurrence else None,
|
|
"last_generated_at": s.last_generated_at.isoformat() if s.last_generated_at else None,
|
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
|
}
|
|
for s in schedules
|
|
],
|
|
"count": len(schedules),
|
|
}
|
|
|
|
|
|
@router.get("/{schedule_id}")
|
|
async def get_recurring_schedule(
|
|
project_id: str,
|
|
schedule_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Get a specific recurring schedule.
|
|
"""
|
|
schedule = db.query(RecurringSchedule).filter_by(
|
|
id=schedule_id,
|
|
project_id=project_id,
|
|
).first()
|
|
|
|
if not schedule:
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
|
|
# Get related location and unit info
|
|
location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first()
|
|
unit = None
|
|
if schedule.unit_id:
|
|
unit = db.query(RosterUnit).filter_by(id=schedule.unit_id).first()
|
|
|
|
return {
|
|
"id": schedule.id,
|
|
"name": schedule.name,
|
|
"schedule_type": schedule.schedule_type,
|
|
"device_type": schedule.device_type,
|
|
"location_id": schedule.location_id,
|
|
"location_name": location.name if location else None,
|
|
"unit_id": schedule.unit_id,
|
|
"unit_name": unit.id if unit else None,
|
|
"enabled": schedule.enabled,
|
|
"weekly_pattern": json.loads(schedule.weekly_pattern) if schedule.weekly_pattern else None,
|
|
"interval_type": schedule.interval_type,
|
|
"cycle_time": schedule.cycle_time,
|
|
"include_download": schedule.include_download,
|
|
"timezone": schedule.timezone,
|
|
"next_occurrence": schedule.next_occurrence.isoformat() if schedule.next_occurrence else None,
|
|
"last_generated_at": schedule.last_generated_at.isoformat() if schedule.last_generated_at else None,
|
|
"created_at": schedule.created_at.isoformat() if schedule.created_at else None,
|
|
"updated_at": schedule.updated_at.isoformat() if schedule.updated_at else None,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Create
|
|
# ============================================================================
|
|
|
|
@router.post("/")
|
|
async def create_recurring_schedule(
|
|
project_id: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Create recurring schedules for one or more locations.
|
|
|
|
Body for weekly_calendar (supports multiple locations):
|
|
{
|
|
"name": "Weeknight Monitoring",
|
|
"schedule_type": "weekly_calendar",
|
|
"location_ids": ["uuid1", "uuid2"], // Array of location IDs
|
|
"weekly_pattern": {
|
|
"monday": {"enabled": true, "start": "19:00", "end": "07:00"},
|
|
"tuesday": {"enabled": false},
|
|
...
|
|
},
|
|
"include_download": true,
|
|
"auto_increment_index": true,
|
|
"timezone": "America/New_York"
|
|
}
|
|
|
|
Body for simple_interval (supports multiple locations):
|
|
{
|
|
"name": "24/7 Continuous",
|
|
"schedule_type": "simple_interval",
|
|
"location_ids": ["uuid1", "uuid2"], // Array of location IDs
|
|
"interval_type": "daily",
|
|
"cycle_time": "00:00",
|
|
"include_download": true,
|
|
"auto_increment_index": true,
|
|
"timezone": "America/New_York"
|
|
}
|
|
|
|
Legacy single location support (backwards compatible):
|
|
{
|
|
"name": "...",
|
|
"location_id": "uuid", // Single location ID
|
|
...
|
|
}
|
|
"""
|
|
project = db.query(Project).filter_by(id=project_id).first()
|
|
if not project:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
|
|
data = await request.json()
|
|
|
|
# Support both location_ids (array) and location_id (single) for backwards compatibility
|
|
location_ids = data.get("location_ids", [])
|
|
if not location_ids and data.get("location_id"):
|
|
location_ids = [data.get("location_id")]
|
|
|
|
if not location_ids:
|
|
raise HTTPException(status_code=400, detail="At least one location is required")
|
|
|
|
# Validate all locations exist
|
|
locations = db.query(MonitoringLocation).filter(
|
|
MonitoringLocation.id.in_(location_ids),
|
|
MonitoringLocation.project_id == project_id,
|
|
).all()
|
|
|
|
if len(locations) != len(location_ids):
|
|
raise HTTPException(status_code=404, detail="One or more locations not found")
|
|
|
|
service = get_recurring_schedule_service(db)
|
|
created_schedules = []
|
|
base_name = data.get("name", "Unnamed Schedule")
|
|
|
|
# Create a schedule for each location
|
|
for location in locations:
|
|
# Determine device type from location
|
|
device_type = "slm" if location.location_type == "sound" else "seismograph"
|
|
|
|
# Append location name if multiple locations
|
|
schedule_name = f"{base_name} - {location.name}" if len(locations) > 1 else base_name
|
|
|
|
schedule = service.create_schedule(
|
|
project_id=project_id,
|
|
location_id=location.id,
|
|
name=schedule_name,
|
|
schedule_type=data.get("schedule_type", "weekly_calendar"),
|
|
device_type=device_type,
|
|
unit_id=data.get("unit_id"),
|
|
weekly_pattern=data.get("weekly_pattern"),
|
|
interval_type=data.get("interval_type"),
|
|
cycle_time=data.get("cycle_time"),
|
|
include_download=data.get("include_download", True),
|
|
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),
|
|
"actions_generated": total_actions,
|
|
"message": f"Created {len(created_schedules)} recurring schedule(s) with {total_actions} upcoming actions",
|
|
})
|
|
|
|
|
|
# ============================================================================
|
|
# Update
|
|
# ============================================================================
|
|
|
|
@router.put("/{schedule_id}")
|
|
async def update_recurring_schedule(
|
|
project_id: str,
|
|
schedule_id: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Update a recurring schedule.
|
|
"""
|
|
schedule = db.query(RecurringSchedule).filter_by(
|
|
id=schedule_id,
|
|
project_id=project_id,
|
|
).first()
|
|
|
|
if not schedule:
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
|
|
data = await request.json()
|
|
service = get_recurring_schedule_service(db)
|
|
|
|
# Build update kwargs
|
|
update_kwargs = {}
|
|
for field in ["name", "weekly_pattern", "interval_type", "cycle_time",
|
|
"include_download", "auto_increment_index", "timezone", "unit_id"]:
|
|
if field in data:
|
|
update_kwargs[field] = data[field]
|
|
|
|
updated = service.update_schedule(schedule_id, **update_kwargs)
|
|
|
|
return {
|
|
"success": True,
|
|
"schedule_id": updated.id,
|
|
"message": "Schedule updated successfully",
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Delete
|
|
# ============================================================================
|
|
|
|
@router.delete("/{schedule_id}")
|
|
async def delete_recurring_schedule(
|
|
project_id: str,
|
|
schedule_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Delete a recurring schedule.
|
|
"""
|
|
service = get_recurring_schedule_service(db)
|
|
deleted = service.delete_schedule(schedule_id)
|
|
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Schedule deleted successfully",
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Enable/Disable
|
|
# ============================================================================
|
|
|
|
@router.post("/{schedule_id}/enable")
|
|
async def enable_schedule(
|
|
project_id: str,
|
|
schedule_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Enable a disabled schedule.
|
|
"""
|
|
service = get_recurring_schedule_service(db)
|
|
schedule = service.enable_schedule(schedule_id)
|
|
|
|
if not schedule:
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
|
|
return {
|
|
"success": True,
|
|
"schedule_id": schedule.id,
|
|
"enabled": schedule.enabled,
|
|
"message": "Schedule enabled",
|
|
}
|
|
|
|
|
|
@router.post("/{schedule_id}/disable")
|
|
async def disable_schedule(
|
|
project_id: str,
|
|
schedule_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Disable a schedule.
|
|
"""
|
|
service = get_recurring_schedule_service(db)
|
|
schedule = service.disable_schedule(schedule_id)
|
|
|
|
if not schedule:
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
|
|
return {
|
|
"success": True,
|
|
"schedule_id": schedule.id,
|
|
"enabled": schedule.enabled,
|
|
"message": "Schedule disabled",
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Preview Generated Actions
|
|
# ============================================================================
|
|
|
|
@router.post("/{schedule_id}/generate-preview")
|
|
async def preview_generated_actions(
|
|
project_id: str,
|
|
schedule_id: str,
|
|
db: Session = Depends(get_db),
|
|
days: int = Query(7, ge=1, le=30),
|
|
):
|
|
"""
|
|
Preview what actions would be generated without saving them.
|
|
"""
|
|
schedule = db.query(RecurringSchedule).filter_by(
|
|
id=schedule_id,
|
|
project_id=project_id,
|
|
).first()
|
|
|
|
if not schedule:
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
|
|
service = get_recurring_schedule_service(db)
|
|
actions = service.generate_actions_for_schedule(
|
|
schedule,
|
|
horizon_days=days,
|
|
preview_only=True,
|
|
)
|
|
|
|
return {
|
|
"schedule_id": schedule_id,
|
|
"schedule_name": schedule.name,
|
|
"preview_days": days,
|
|
"actions": [
|
|
{
|
|
"action_type": a.action_type,
|
|
"scheduled_time": a.scheduled_time.isoformat(),
|
|
"notes": a.notes,
|
|
}
|
|
for a in actions
|
|
],
|
|
"action_count": len(actions),
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Manual Generation Trigger
|
|
# ============================================================================
|
|
|
|
@router.post("/{schedule_id}/generate")
|
|
async def generate_actions_now(
|
|
project_id: str,
|
|
schedule_id: str,
|
|
db: Session = Depends(get_db),
|
|
days: int = Query(7, ge=1, le=30),
|
|
):
|
|
"""
|
|
Manually trigger action generation for a schedule.
|
|
"""
|
|
schedule = db.query(RecurringSchedule).filter_by(
|
|
id=schedule_id,
|
|
project_id=project_id,
|
|
).first()
|
|
|
|
if not schedule:
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
|
|
|
if not schedule.enabled:
|
|
raise HTTPException(status_code=400, detail="Schedule is disabled")
|
|
|
|
service = get_recurring_schedule_service(db)
|
|
actions = service.generate_actions_for_schedule(
|
|
schedule,
|
|
horizon_days=days,
|
|
preview_only=False,
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"schedule_id": schedule_id,
|
|
"generated_count": len(actions),
|
|
"message": f"Generated {len(actions)} scheduled actions",
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# HTML Partials
|
|
# ============================================================================
|
|
|
|
@router.get("/partials/list", response_class=HTMLResponse)
|
|
async def get_schedule_list_partial(
|
|
project_id: str,
|
|
request: Request,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""
|
|
Return HTML partial for schedule list.
|
|
"""
|
|
schedules = db.query(RecurringSchedule).filter_by(
|
|
project_id=project_id
|
|
).order_by(RecurringSchedule.created_at.desc()).all()
|
|
|
|
# Enrich with location info
|
|
schedule_data = []
|
|
for s in schedules:
|
|
location = db.query(MonitoringLocation).filter_by(id=s.location_id).first()
|
|
schedule_data.append({
|
|
"schedule": s,
|
|
"location": location,
|
|
"pattern": json.loads(s.weekly_pattern) if s.weekly_pattern else None,
|
|
})
|
|
|
|
return templates.TemplateResponse("partials/projects/recurring_schedule_list.html", {
|
|
"request": request,
|
|
"project_id": project_id,
|
|
"schedules": schedule_data,
|
|
})
|