""" 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=HTMLResponse) async def get_project_header( project_id: str, request: Request, db: Session = Depends(get_db) ): """ Get project header information for dynamic display. Returns HTML partial 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 templates.TemplateResponse("partials/projects/project_header.html", { "request": request, "project": project, "project_type": project_type, }) @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}/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 == "slm": 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, session_type="sound", # SLMs are sound monitoring devices status="completed", started_at=datetime.utcnow(), stopped_at=datetime.utcnow(), session_metadata='{"source": "ftp_download", "note": "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 = { # Audio files '.wav': 'audio', '.mp3': 'audio', '.flac': 'audio', '.m4a': 'audio', '.aac': 'audio', # Data files '.csv': 'data', '.txt': 'data', '.json': 'data', '.xml': 'data', '.dat': 'data', # Log files '.log': 'log', # Archives '.zip': 'archive', '.tar': 'archive', '.gz': 'archive', '.7z': 'archive', '.rar': 'archive', # Images '.jpg': 'image', '.jpeg': 'image', '.png': 'image', '.gif': 'image', # Documents '.pdf': 'document', '.doc': 'document', '.docx': 'document', } 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)}" ) @router.post("/{project_id}/ftp-download-folder-to-server") async def ftp_download_folder_to_server( project_id: str, request: Request, db: Session = Depends(get_db), ): """ Download an entire folder from an SLM to the server via FTP. Extracts all files from the ZIP and preserves folder structure. Creates individual DataFile records for each file. """ import httpx import os import hashlib import zipfile import io 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, session_type="sound", # SLMs are sound monitoring devices status="completed", started_at=datetime.utcnow(), stopped_at=datetime.utcnow(), session_metadata='{"source": "ftp_folder_download", "note": "Auto-created for FTP folder download"}' ) db.add(session) db.commit() db.refresh(session) # Download folder from SLMM (returns ZIP) SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") try: async with httpx.AsyncClient(timeout=600.0) as client: # Longer timeout for folders response = await client.post( f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder", json={"remote_path": remote_path} ) if not response.is_success: raise HTTPException( status_code=response.status_code, detail=f"Failed to download folder from SLMM: {response.text}" ) # Extract folder name from remote_path folder_name = os.path.basename(remote_path.rstrip('/')) # Create base directory: data/Projects/{project_id}/{session_id}/{folder_name}/ base_dir = Path(f"data/Projects/{project_id}/{session.id}/{folder_name}") base_dir.mkdir(parents=True, exist_ok=True) # Extract ZIP and save individual files zip_content = response.content created_files = [] total_size = 0 # File type mapping for classification file_type_map = { # Audio files '.wav': 'audio', '.mp3': 'audio', '.flac': 'audio', '.m4a': 'audio', '.aac': 'audio', # Data files '.csv': 'data', '.txt': 'data', '.json': 'data', '.xml': 'data', '.dat': 'data', # Log files '.log': 'log', # Archives '.zip': 'archive', '.tar': 'archive', '.gz': 'archive', '.7z': 'archive', '.rar': 'archive', # Images '.jpg': 'image', '.jpeg': 'image', '.png': 'image', '.gif': 'image', # Documents '.pdf': 'document', '.doc': 'document', '.docx': 'document', } with zipfile.ZipFile(io.BytesIO(zip_content)) as zf: for zip_info in zf.filelist: # Skip directories if zip_info.is_dir(): continue # Read file from ZIP file_data = zf.read(zip_info.filename) # Determine file path (preserve structure within folder) # zip_info.filename might be like "Auto_0001/measurement.wav" file_path = base_dir / zip_info.filename file_path.parent.mkdir(parents=True, exist_ok=True) # Write file to disk with open(file_path, 'wb') as f: f.write(file_data) # Calculate checksum checksum = hashlib.sha256(file_data).hexdigest() # Determine file type ext = os.path.splitext(zip_info.filename)[1].lower() file_type = file_type_map.get(ext, 'data') # Create DataFile record data_file = DataFile( id=str(uuid.uuid4()), session_id=session.id, file_path=str(file_path.relative_to("data")), file_type=file_type, file_size_bytes=len(file_data), downloaded_at=datetime.utcnow(), checksum=checksum, file_metadata=json.dumps({ "source": "ftp_folder", "remote_path": remote_path, "unit_id": unit_id, "location_id": location_id, "folder_name": folder_name, "relative_path": zip_info.filename, }) ) db.add(data_file) created_files.append({ "filename": zip_info.filename, "size": len(file_data), "type": file_type }) total_size += len(file_data) db.commit() return { "success": True, "message": f"Downloaded folder {folder_name} with {len(created_files)} files", "folder_name": folder_name, "file_count": len(created_files), "total_size": total_size, "files": created_files, } except httpx.TimeoutException: raise HTTPException( status_code=504, detail="Timeout downloading folder from SLM (large folders may take a while)" ) except zipfile.BadZipFile: raise HTTPException( status_code=500, detail="Downloaded file is not a valid ZIP archive" ) except Exception as e: logger.error(f"Error downloading folder to server: {e}") raise HTTPException( status_code=500, detail=f"Failed to download folder to server: {str(e)}" ) # ============================================================================ # Project Types # ============================================================================ @router.get("/{project_id}/files-unified", response_class=HTMLResponse) async def get_unified_files( project_id: str, request: Request, db: Session = Depends(get_db), ): """ Get unified view of all files in this project. Groups files by recording session with full metadata. Returns HTML partial with hierarchical file listing. """ from backend.models import DataFile from pathlib import Path import json # Get all sessions for this project sessions = db.query(RecordingSession).filter_by( project_id=project_id ).order_by(RecordingSession.started_at.desc()).all() sessions_data = [] for session in sessions: # Get files for this session files = db.query(DataFile).filter_by(session_id=session.id).all() # Skip sessions with no files if not files: continue # Get session context 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() files_data = [] for file in files: # Check if file exists on disk file_path = Path("data") / file.file_path exists_on_disk = file_path.exists() # Get actual file size if exists actual_size = file_path.stat().st_size if exists_on_disk else None # Parse metadata JSON metadata = {} try: if file.file_metadata: metadata = json.loads(file.file_metadata) except Exception as e: logger.warning(f"Failed to parse metadata for file {file.id}: {e}") files_data.append({ "file": file, "exists_on_disk": exists_on_disk, "actual_size": actual_size, "metadata": metadata, }) sessions_data.append({ "session": session, "unit": unit, "location": location, "files": files_data, }) return templates.TemplateResponse("partials/projects/unified_files.html", { "request": request, "project_id": project_id, "sessions": sessions_data, }) @router.get("/{project_id}/files/{file_id}/download") async def download_project_file( project_id: str, file_id: str, db: Session = Depends(get_db), ): """ Download a data file from a project. Returns the file for download. """ from backend.models import DataFile from fastapi.responses import FileResponse from pathlib import Path # Get the file record file_record = db.query(DataFile).filter_by(id=file_id).first() if not file_record: raise HTTPException(status_code=404, detail="File not found") # Verify file belongs to this project session = db.query(RecordingSession).filter_by(id=file_record.session_id).first() if not session or session.project_id != project_id: raise HTTPException(status_code=403, detail="File does not belong to this project") # Build full file path file_path = Path("data") / file_record.file_path if not file_path.exists(): raise HTTPException(status_code=404, detail="File not found on disk") # Extract filename for download filename = file_path.name return FileResponse( path=str(file_path), filename=filename, media_type="application/octet-stream" ) @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, })