Files
terra-view/backend/routers/projects.py
2026-01-13 18:57:31 +00:00

763 lines
24 KiB
Python

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