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