- Created `schedule_list.html` to display scheduled actions with execution status, location, and timestamps. - Implemented buttons for executing and canceling schedules, along with a details view placeholder. - Created `unit_list.html` to show assigned units with their status, location, model, and session/file counts. - Added conditional rendering for active sessions and links to view unit and location details.
584 lines
19 KiB
Python
584 lines
19 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
|
|
|
|
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")
|
|
|
|
|
|
# ============================================================================
|
|
# 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,
|
|
})
|
|
|
|
|
|
# ============================================================================
|
|
# 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,
|
|
})
|