""" Projects Router Provides API endpoints for the Projects system: - Project CRUD operations - Project dashboards - Project statistics - Type-aware features """ 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 func, and_ from datetime import datetime, timedelta from typing import Optional import uuid import json import logging from backend.database import get_db from backend.models import ( Project, ProjectType, MonitoringLocation, UnitAssignment, RecordingSession, ScheduledAction, RosterUnit, ) router = APIRouter(prefix="/api/projects", tags=["projects"]) templates = Jinja2Templates(directory="templates") logger = logging.getLogger(__name__) # ============================================================================ # Project List & Overview # ============================================================================ @router.get("/list", response_class=HTMLResponse) async def get_projects_list( request: Request, db: Session = Depends(get_db), status: Optional[str] = Query(None), project_type_id: Optional[str] = Query(None), view: Optional[str] = Query(None), ): """ Get list of all projects. Returns HTML partial with project cards. """ query = db.query(Project) # Filter by status if provided if status: query = query.filter(Project.status == status) # Filter by project type if provided if project_type_id: query = query.filter(Project.project_type_id == project_type_id) projects = query.order_by(Project.created_at.desc()).all() # Enrich each project with stats projects_data = [] for project in projects: # Get project type project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() # Count locations location_count = db.query(func.count(MonitoringLocation.id)).filter_by( project_id=project.id ).scalar() # Count assigned units unit_count = db.query(func.count(UnitAssignment.id)).filter( and_( UnitAssignment.project_id == project.id, UnitAssignment.status == "active", ) ).scalar() # Count active sessions active_session_count = db.query(func.count(RecordingSession.id)).filter( and_( RecordingSession.project_id == project.id, RecordingSession.status == "recording", ) ).scalar() projects_data.append({ "project": project, "project_type": project_type, "location_count": location_count, "unit_count": unit_count, "active_session_count": active_session_count, }) template_name = "partials/projects/project_list.html" if view == "compact": template_name = "partials/projects/project_list_compact.html" return templates.TemplateResponse(template_name, { "request": request, "projects": projects_data, }) @router.get("/stats", response_class=HTMLResponse) async def get_projects_stats(request: Request, db: Session = Depends(get_db)): """ Get summary statistics for projects overview. Returns HTML partial with stat cards. """ # Count projects by status total_projects = db.query(func.count(Project.id)).scalar() active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar() completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar() # Count total locations across all projects total_locations = db.query(func.count(MonitoringLocation.id)).scalar() # Count assigned units assigned_units = db.query(func.count(UnitAssignment.id)).filter_by( status="active" ).scalar() # Count active recording sessions active_sessions = db.query(func.count(RecordingSession.id)).filter_by( status="recording" ).scalar() return templates.TemplateResponse("partials/projects/project_stats.html", { "request": request, "total_projects": total_projects, "active_projects": active_projects, "completed_projects": completed_projects, "total_locations": total_locations, "assigned_units": assigned_units, "active_sessions": active_sessions, }) # ============================================================================ # Project CRUD # ============================================================================ @router.post("/create") async def create_project(request: Request, db: Session = Depends(get_db)): """ Create a new project. Expects form data with project details. """ form_data = await request.form() project = Project( id=str(uuid.uuid4()), name=form_data.get("name"), description=form_data.get("description"), project_type_id=form_data.get("project_type_id"), status="active", client_name=form_data.get("client_name"), site_address=form_data.get("site_address"), site_coordinates=form_data.get("site_coordinates"), start_date=datetime.fromisoformat(form_data.get("start_date")) if form_data.get("start_date") else None, end_date=datetime.fromisoformat(form_data.get("end_date")) if form_data.get("end_date") else None, ) db.add(project) db.commit() db.refresh(project) return JSONResponse({ "success": True, "project_id": project.id, "message": f"Project '{project.name}' created successfully", }) @router.get("/{project_id}") async def get_project(project_id: str, db: Session = Depends(get_db)): """ Get project details by ID. Returns JSON with full project data. """ project = db.query(Project).filter_by(id=project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() return { "id": project.id, "name": project.name, "description": project.description, "project_type_id": project.project_type_id, "project_type_name": project_type.name if project_type else None, "status": project.status, "client_name": project.client_name, "site_address": project.site_address, "site_coordinates": project.site_coordinates, "start_date": project.start_date.isoformat() if project.start_date else None, "end_date": project.end_date.isoformat() if project.end_date else None, "created_at": project.created_at.isoformat(), "updated_at": project.updated_at.isoformat(), } @router.put("/{project_id}") async def update_project( project_id: str, request: Request, db: Session = Depends(get_db), ): """ Update project details. Expects JSON body with fields to update. """ 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() # Update fields if provided if "name" in data: project.name = data["name"] if "description" in data: project.description = data["description"] if "status" in data: project.status = data["status"] if "client_name" in data: project.client_name = data["client_name"] if "site_address" in data: project.site_address = data["site_address"] if "site_coordinates" in data: project.site_coordinates = data["site_coordinates"] if "start_date" in data: project.start_date = datetime.fromisoformat(data["start_date"]) if data["start_date"] else None if "end_date" in data: project.end_date = datetime.fromisoformat(data["end_date"]) if data["end_date"] else None project.updated_at = datetime.utcnow() db.commit() return {"success": True, "message": "Project updated successfully"} @router.delete("/{project_id}") async def delete_project(project_id: str, db: Session = Depends(get_db)): """ Delete a project (soft delete by archiving). """ project = db.query(Project).filter_by(id=project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") project.status = "archived" project.updated_at = datetime.utcnow() db.commit() return {"success": True, "message": "Project archived successfully"} # ============================================================================ # Project Dashboard Data # ============================================================================ @router.get("/{project_id}/dashboard", response_class=HTMLResponse) async def get_project_dashboard( project_id: str, request: Request, db: Session = Depends(get_db), ): """ Get project dashboard data. Returns HTML partial with project summary. """ project = db.query(Project).filter_by(id=project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() # Get locations locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all() # Get assigned units with details assignments = db.query(UnitAssignment).filter( and_( UnitAssignment.project_id == project_id, UnitAssignment.status == "active", ) ).all() assigned_units = [] for assignment in assignments: unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() if unit: assigned_units.append({ "assignment": assignment, "unit": unit, }) # Get active recording sessions active_sessions = db.query(RecordingSession).filter( and_( RecordingSession.project_id == project_id, RecordingSession.status == "recording", ) ).all() # Get completed sessions count completed_sessions_count = db.query(func.count(RecordingSession.id)).filter( and_( RecordingSession.project_id == project_id, RecordingSession.status == "completed", ) ).scalar() # Get upcoming scheduled actions upcoming_actions = db.query(ScheduledAction).filter( and_( ScheduledAction.project_id == project_id, ScheduledAction.execution_status == "pending", ScheduledAction.scheduled_time > datetime.utcnow(), ) ).order_by(ScheduledAction.scheduled_time).limit(5).all() return templates.TemplateResponse("partials/projects/project_dashboard.html", { "request": request, "project": project, "project_type": project_type, "locations": locations, "assigned_units": assigned_units, "active_sessions": active_sessions, "completed_sessions_count": completed_sessions_count, "upcoming_actions": upcoming_actions, }) # ============================================================================ # Project Types # ============================================================================ @router.get("/{project_id}/header", response_class=JSONResponse) async def get_project_header(project_id: str, db: Session = Depends(get_db)): """ Get project header information for dynamic display. Returns JSON with project name, status, and type. """ project = db.query(Project).filter_by(id=project_id).first() if not project: raise HTTPException(status_code=404, detail="Project not found") project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() return JSONResponse({ "id": project.id, "name": project.name, "status": project.status, "project_type_id": project.project_type_id, "project_type_name": project_type.name if project_type else None, }) @router.get("/{project_id}/units", response_class=HTMLResponse) async def get_project_units( project_id: str, request: Request, db: Session = Depends(get_db), ): """ Get all units assigned to this project's locations. Returns HTML partial with unit list. """ from backend.models import DataFile # Get all assignments for this project assignments = db.query(UnitAssignment).filter( and_( UnitAssignment.project_id == project_id, UnitAssignment.status == "active", ) ).all() # Enrich with unit and location details units_data = [] for assignment in assignments: unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first() # Count sessions for this assignment session_count = db.query(func.count(RecordingSession.id)).filter_by( location_id=assignment.location_id, unit_id=assignment.unit_id, ).scalar() # Count files from sessions file_count = db.query(func.count(DataFile.id)).join( RecordingSession, DataFile.session_id == RecordingSession.id ).filter( RecordingSession.location_id == assignment.location_id, RecordingSession.unit_id == assignment.unit_id, ).scalar() # Check if currently recording active_session = db.query(RecordingSession).filter( and_( RecordingSession.location_id == assignment.location_id, RecordingSession.unit_id == assignment.unit_id, RecordingSession.status == "recording", ) ).first() units_data.append({ "assignment": assignment, "unit": unit, "location": location, "session_count": session_count, "file_count": file_count, "active_session": active_session, }) # Get project type for label context project = db.query(Project).filter_by(id=project_id).first() project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() if project else None return templates.TemplateResponse("partials/projects/unit_list.html", { "request": request, "project_id": project_id, "units": units_data, "project_type": project_type, }) @router.get("/{project_id}/schedules", response_class=HTMLResponse) async def get_project_schedules( project_id: str, request: Request, db: Session = Depends(get_db), status: Optional[str] = Query(None), ): """ Get scheduled actions for this project. Returns HTML partial with schedule list. Optional status filter: pending, completed, failed, cancelled """ query = db.query(ScheduledAction).filter_by(project_id=project_id) # Filter by status if provided if status: query = query.filter(ScheduledAction.execution_status == status) schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all() # Enrich with location details schedules_data = [] for schedule in schedules: location = None if schedule.location_id: location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first() schedules_data.append({ "schedule": schedule, "location": location, }) return templates.TemplateResponse("partials/projects/schedule_list.html", { "request": request, "project_id": project_id, "schedules": schedules_data, }) @router.get("/{project_id}/sessions", response_class=HTMLResponse) async def get_project_sessions( project_id: str, request: Request, db: Session = Depends(get_db), status: Optional[str] = Query(None), ): """ Get all recording sessions for this project. Returns HTML partial with session list. Optional status filter: recording, completed, paused, failed """ query = db.query(RecordingSession).filter_by(project_id=project_id) # Filter by status if provided if status: query = query.filter(RecordingSession.status == status) sessions = query.order_by(RecordingSession.started_at.desc()).all() # Enrich with unit and location details sessions_data = [] for session in sessions: unit = None location = None if session.unit_id: unit = db.query(RosterUnit).filter_by(id=session.unit_id).first() if session.location_id: location = db.query(MonitoringLocation).filter_by(id=session.location_id).first() sessions_data.append({ "session": session, "unit": unit, "location": location, }) return templates.TemplateResponse("partials/projects/session_list.html", { "request": request, "project_id": project_id, "sessions": sessions_data, }) @router.get("/{project_id}/files", response_class=HTMLResponse) async def get_project_files( project_id: str, request: Request, db: Session = Depends(get_db), file_type: Optional[str] = Query(None), ): """ Get all data files from all sessions in this project. Returns HTML partial with file list. Optional file_type filter: audio, data, log, etc. """ from backend.models import DataFile # Join through RecordingSession to get project files query = db.query(DataFile).join( RecordingSession, DataFile.session_id == RecordingSession.id ).filter(RecordingSession.project_id == project_id) # Filter by file type if provided if file_type: query = query.filter(DataFile.file_type == file_type) files = query.order_by(DataFile.created_at.desc()).all() # Enrich with session details files_data = [] for file in files: session = None if file.session_id: session = db.query(RecordingSession).filter_by(id=file.session_id).first() files_data.append({ "file": file, "session": session, }) return templates.TemplateResponse("partials/projects/file_list.html", { "request": request, "project_id": project_id, "files": files_data, }) @router.get("/{project_id}/ftp-browser", response_class=HTMLResponse) async def get_ftp_browser( project_id: str, request: Request, db: Session = Depends(get_db), ): """ Get FTP browser interface for downloading files from assigned SLMs. Returns HTML partial with FTP browser. """ from backend.models import DataFile # Get all assignments for this project assignments = db.query(UnitAssignment).filter( and_( UnitAssignment.project_id == project_id, UnitAssignment.status == "active", ) ).all() # Enrich with unit and location details units_data = [] for assignment in assignments: unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first() # Only include SLM units if unit and unit.device_type == "sound_level_meter": units_data.append({ "assignment": assignment, "unit": unit, "location": location, }) return templates.TemplateResponse("partials/projects/ftp_browser.html", { "request": request, "project_id": project_id, "units": units_data, }) @router.post("/{project_id}/ftp-download-to-server") async def ftp_download_to_server( project_id: str, request: Request, db: Session = Depends(get_db), ): """ Download a file from an SLM to the server via FTP. Creates a DataFile record and stores the file in data/Projects/{project_id}/ """ import httpx import os import hashlib from pathlib import Path from backend.models import DataFile data = await request.json() unit_id = data.get("unit_id") remote_path = data.get("remote_path") location_id = data.get("location_id") if not unit_id or not remote_path: raise HTTPException(status_code=400, detail="Missing unit_id or remote_path") # Get or create active session for this location/unit session = db.query(RecordingSession).filter( and_( RecordingSession.project_id == project_id, RecordingSession.location_id == location_id, RecordingSession.unit_id == unit_id, RecordingSession.status.in_(["recording", "paused"]) ) ).first() # If no active session, create one if not session: session = RecordingSession( id=str(uuid.uuid4()), project_id=project_id, location_id=location_id, unit_id=unit_id, status="completed", started_at=datetime.utcnow(), stopped_at=datetime.utcnow(), notes="Auto-created for FTP download" ) db.add(session) db.commit() db.refresh(session) # Download file from SLMM SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") try: async with httpx.AsyncClient(timeout=300.0) as client: response = await client.post( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download", json={"remote_path": remote_path} ) if not response.is_success: raise HTTPException( status_code=response.status_code, detail=f"Failed to download from SLMM: {response.text}" ) # Extract filename from remote_path filename = os.path.basename(remote_path) # Determine file type from extension ext = os.path.splitext(filename)[1].lower() file_type_map = { '.wav': 'audio', '.mp3': 'audio', '.csv': 'data', '.txt': 'data', '.log': 'log', '.json': 'data', } file_type = file_type_map.get(ext, 'data') # Create directory structure: data/Projects/{project_id}/{session_id}/ project_dir = Path(f"data/Projects/{project_id}/{session.id}") project_dir.mkdir(parents=True, exist_ok=True) # Save file to disk file_path = project_dir / filename file_content = response.content with open(file_path, 'wb') as f: f.write(file_content) # Calculate checksum checksum = hashlib.sha256(file_content).hexdigest() # Create DataFile record data_file = DataFile( id=str(uuid.uuid4()), session_id=session.id, file_path=str(file_path.relative_to("data")), # Store relative to data/ file_type=file_type, file_size_bytes=len(file_content), downloaded_at=datetime.utcnow(), checksum=checksum, file_metadata=json.dumps({ "source": "ftp", "remote_path": remote_path, "unit_id": unit_id, "location_id": location_id, }) ) db.add(data_file) db.commit() return { "success": True, "message": f"Downloaded {filename} to server", "file_id": data_file.id, "file_path": str(file_path), "file_size": len(file_content), } except httpx.TimeoutException: raise HTTPException( status_code=504, detail="Timeout downloading file from SLM" ) except Exception as e: logger.error(f"Error downloading file to server: {e}") raise HTTPException( status_code=500, detail=f"Failed to download file to server: {str(e)}" ) # ============================================================================ # Project Types # ============================================================================ @router.get("/types/list", response_class=HTMLResponse) async def get_project_types(request: Request, db: Session = Depends(get_db)): """ Get all available project types. Returns HTML partial with project type cards. """ project_types = db.query(ProjectType).all() return templates.TemplateResponse("partials/projects/project_type_cards.html", { "request": request, "project_types": project_types, })