1038 lines
34 KiB
Python
1038 lines
34 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=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,
|
|
})
|