56bd3041cf
feat: Location no longer assigned directly to unit, locations and coords are assigned to location only, unit only is deployed or benched.
557 lines
19 KiB
Python
557 lines
19 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|
from fastapi.responses import StreamingResponse, FileResponse
|
|
from sqlalchemy.orm import Session
|
|
from datetime import datetime, date
|
|
from pydantic import BaseModel
|
|
from typing import Optional
|
|
import csv
|
|
import io
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from backend.database import get_db
|
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
|
from backend.services.database_backup import DatabaseBackupService
|
|
from backend.services.unit_location import bulk_active_locations
|
|
|
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|
|
|
|
|
@router.get("/export-csv")
|
|
def export_roster_csv(db: Session = Depends(get_db)):
|
|
"""Export all roster units to CSV"""
|
|
units = db.query(RosterUnit).all()
|
|
|
|
# Create CSV in memory. Location lives on MonitoringLocation now, so
|
|
# we don't export legacy address/coordinates/location columns here —
|
|
# round-trip CSV editing would otherwise look like it edits unit
|
|
# location, when it can't.
|
|
output = io.StringIO()
|
|
fieldnames = [
|
|
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
|
'note', 'project_id',
|
|
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
|
'ip_address', 'phone_number', 'hardware_model'
|
|
]
|
|
|
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
|
|
for unit in units:
|
|
writer.writerow({
|
|
'unit_id': unit.id,
|
|
'unit_type': unit.unit_type or '',
|
|
'device_type': unit.device_type or 'seismograph',
|
|
'deployed': 'true' if unit.deployed else 'false',
|
|
'retired': 'true' if unit.retired else 'false',
|
|
'note': unit.note or '',
|
|
'project_id': unit.project_id or '',
|
|
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
|
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') if unit.next_calibration_due else '',
|
|
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
|
|
'ip_address': unit.ip_address or '',
|
|
'phone_number': unit.phone_number or '',
|
|
'hardware_model': unit.hardware_model or ''
|
|
})
|
|
|
|
output.seek(0)
|
|
filename = f"roster_export_{date.today().isoformat()}.csv"
|
|
|
|
return StreamingResponse(
|
|
io.BytesIO(output.getvalue().encode('utf-8')),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
|
)
|
|
|
|
|
|
@router.get("/stats")
|
|
def get_table_stats(db: Session = Depends(get_db)):
|
|
"""Get counts for all tables"""
|
|
roster_count = db.query(RosterUnit).count()
|
|
emitters_count = db.query(Emitter).count()
|
|
ignored_count = db.query(IgnoredUnit).count()
|
|
|
|
return {
|
|
"roster": roster_count,
|
|
"emitters": emitters_count,
|
|
"ignored": ignored_count,
|
|
"total": roster_count + emitters_count + ignored_count
|
|
}
|
|
|
|
|
|
@router.get("/roster-units")
|
|
def get_all_roster_units(db: Session = Depends(get_db)):
|
|
"""Get all roster units for management table"""
|
|
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
|
active_locs = bulk_active_locations(db, units)
|
|
|
|
return [{
|
|
"id": unit.id,
|
|
"device_type": unit.device_type or "seismograph",
|
|
"unit_type": unit.unit_type or "series3",
|
|
"deployed": unit.deployed,
|
|
"retired": unit.retired,
|
|
"note": unit.note or "",
|
|
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
|
|
"address": (active_locs.get(unit.id) or {}).get("address") or "",
|
|
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "",
|
|
"location_name": (active_locs.get(unit.id) or {}).get("name") or "",
|
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
|
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
|
"ip_address": unit.ip_address or "",
|
|
"phone_number": unit.phone_number or "",
|
|
"hardware_model": unit.hardware_model or "",
|
|
"slm_host": unit.slm_host or "",
|
|
"slm_tcp_port": unit.slm_tcp_port,
|
|
"slm_model": unit.slm_model or "",
|
|
"slm_serial_number": unit.slm_serial_number or "",
|
|
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
|
"slm_time_weighting": unit.slm_time_weighting or "",
|
|
"slm_measurement_range": unit.slm_measurement_range or "",
|
|
"slm_last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
|
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
|
} for unit in units]
|
|
|
|
|
|
def parse_date(date_str):
|
|
"""Helper function to parse date strings"""
|
|
if not date_str or not date_str.strip():
|
|
return None
|
|
try:
|
|
return datetime.strptime(date_str.strip(), "%Y-%m-%d").date()
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
@router.post("/import-csv-replace")
|
|
async def import_csv_replace(
|
|
file: UploadFile = File(...),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Replace all roster data with CSV import (atomic transaction).
|
|
Clears roster table first, then imports all rows from CSV.
|
|
"""
|
|
|
|
if not file.filename.endswith('.csv'):
|
|
raise HTTPException(status_code=400, detail="File must be a CSV")
|
|
|
|
# Read and parse CSV
|
|
contents = await file.read()
|
|
csv_text = contents.decode('utf-8')
|
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
|
|
|
# Parse all rows FIRST (fail fast before deletion)
|
|
parsed_units = []
|
|
for row_num, row in enumerate(csv_reader, start=2):
|
|
unit_id = row.get('unit_id', '').strip()
|
|
if not unit_id:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Row {row_num}: Missing required field unit_id"
|
|
)
|
|
|
|
# Parse and validate dates
|
|
last_cal_date = parse_date(row.get('last_calibrated'))
|
|
next_cal_date = parse_date(row.get('next_calibration_due'))
|
|
|
|
parsed_units.append({
|
|
'id': unit_id,
|
|
'unit_type': row.get('unit_type', 'series3'),
|
|
'device_type': row.get('device_type', 'seismograph'),
|
|
'deployed': row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
|
'retired': row.get('retired', '').lower() in ('true', '1', 'yes'),
|
|
'note': row.get('note', ''),
|
|
'project_id': row.get('project_id') or None,
|
|
'location': row.get('location') or None,
|
|
'address': row.get('address') or None,
|
|
'coordinates': row.get('coordinates') or None,
|
|
'last_calibrated': last_cal_date,
|
|
'next_calibration_due': next_cal_date,
|
|
'deployed_with_modem_id': row.get('deployed_with_modem_id') or None,
|
|
'ip_address': row.get('ip_address') or None,
|
|
'phone_number': row.get('phone_number') or None,
|
|
'hardware_model': row.get('hardware_model') or None,
|
|
})
|
|
|
|
# Atomic transaction: delete all, then insert all
|
|
try:
|
|
deleted_count = db.query(RosterUnit).delete()
|
|
|
|
for unit_data in parsed_units:
|
|
new_unit = RosterUnit(**unit_data, last_updated=datetime.utcnow())
|
|
db.add(new_unit)
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"message": "Roster replaced successfully",
|
|
"deleted": deleted_count,
|
|
"added": len(parsed_units)
|
|
}
|
|
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
|
|
|
|
|
|
@router.post("/clear-all")
|
|
def clear_all_data(db: Session = Depends(get_db)):
|
|
"""Clear all tables (roster, emitters, ignored)"""
|
|
try:
|
|
roster_count = db.query(RosterUnit).delete()
|
|
emitters_count = db.query(Emitter).delete()
|
|
ignored_count = db.query(IgnoredUnit).delete()
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"message": "All data cleared",
|
|
"deleted": {
|
|
"roster": roster_count,
|
|
"emitters": emitters_count,
|
|
"ignored": ignored_count,
|
|
"total": roster_count + emitters_count + ignored_count
|
|
}
|
|
}
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
|
|
|
|
|
@router.post("/clear-roster")
|
|
def clear_roster(db: Session = Depends(get_db)):
|
|
"""Clear roster table only"""
|
|
try:
|
|
count = db.query(RosterUnit).delete()
|
|
db.commit()
|
|
return {"message": "Roster cleared", "deleted": count}
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
|
|
|
|
|
@router.post("/clear-emitters")
|
|
def clear_emitters(db: Session = Depends(get_db)):
|
|
"""Clear emitters table only"""
|
|
try:
|
|
count = db.query(Emitter).delete()
|
|
db.commit()
|
|
return {"message": "Emitters cleared", "deleted": count}
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
|
|
|
|
|
@router.post("/clear-ignored")
|
|
def clear_ignored(db: Session = Depends(get_db)):
|
|
"""Clear ignored units table only"""
|
|
try:
|
|
count = db.query(IgnoredUnit).delete()
|
|
db.commit()
|
|
return {"message": "Ignored units cleared", "deleted": count}
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
|
|
|
|
|
# User Preferences Endpoints
|
|
|
|
class PreferencesUpdate(BaseModel):
|
|
"""Schema for updating user preferences (all fields optional)"""
|
|
timezone: Optional[str] = None
|
|
theme: Optional[str] = None
|
|
auto_refresh_interval: Optional[int] = None
|
|
date_format: Optional[str] = None
|
|
table_rows_per_page: Optional[int] = None
|
|
calibration_interval_days: Optional[int] = None
|
|
calibration_warning_days: Optional[int] = None
|
|
status_ok_threshold_hours: Optional[int] = None
|
|
status_pending_threshold_hours: Optional[int] = None
|
|
mic_unit_pref: Optional[str] = None
|
|
|
|
|
|
@router.get("/preferences")
|
|
def get_preferences(db: Session = Depends(get_db)):
|
|
"""
|
|
Get user preferences. Creates default preferences if none exist.
|
|
"""
|
|
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
|
|
|
if not prefs:
|
|
# Create default preferences
|
|
prefs = UserPreferences(id=1)
|
|
db.add(prefs)
|
|
db.commit()
|
|
db.refresh(prefs)
|
|
|
|
return {
|
|
"timezone": prefs.timezone,
|
|
"theme": prefs.theme,
|
|
"auto_refresh_interval": prefs.auto_refresh_interval,
|
|
"date_format": prefs.date_format,
|
|
"table_rows_per_page": prefs.table_rows_per_page,
|
|
"calibration_interval_days": prefs.calibration_interval_days,
|
|
"calibration_warning_days": prefs.calibration_warning_days,
|
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
|
"mic_unit_pref": prefs.mic_unit_pref or "psi",
|
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
|
}
|
|
|
|
|
|
@router.put("/preferences")
|
|
def update_preferences(
|
|
updates: PreferencesUpdate,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Update user preferences. Accepts partial updates.
|
|
Creates default preferences if none exist.
|
|
"""
|
|
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
|
|
|
if not prefs:
|
|
# Create default preferences
|
|
prefs = UserPreferences(id=1)
|
|
db.add(prefs)
|
|
|
|
# Update only provided fields
|
|
update_data = updates.dict(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(prefs, field, value)
|
|
|
|
prefs.updated_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
db.refresh(prefs)
|
|
|
|
return {
|
|
"message": "Preferences updated successfully",
|
|
"timezone": prefs.timezone,
|
|
"theme": prefs.theme,
|
|
"auto_refresh_interval": prefs.auto_refresh_interval,
|
|
"date_format": prefs.date_format,
|
|
"table_rows_per_page": prefs.table_rows_per_page,
|
|
"calibration_interval_days": prefs.calibration_interval_days,
|
|
"calibration_warning_days": prefs.calibration_warning_days,
|
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
|
"mic_unit_pref": prefs.mic_unit_pref or "psi",
|
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
|
}
|
|
|
|
|
|
# Database Management Endpoints
|
|
|
|
backup_service = DatabaseBackupService()
|
|
|
|
|
|
@router.get("/database/stats")
|
|
def get_database_stats():
|
|
"""Get current database statistics"""
|
|
try:
|
|
stats = backup_service.get_database_stats()
|
|
return stats
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to get database stats: {str(e)}")
|
|
|
|
|
|
@router.post("/database/snapshot")
|
|
def create_database_snapshot(description: Optional[str] = None):
|
|
"""Create a full database snapshot"""
|
|
try:
|
|
snapshot = backup_service.create_snapshot(description=description)
|
|
return {
|
|
"message": "Snapshot created successfully",
|
|
"snapshot": snapshot
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Snapshot creation failed: {str(e)}")
|
|
|
|
|
|
@router.get("/database/snapshots")
|
|
def list_database_snapshots():
|
|
"""List all available database snapshots"""
|
|
try:
|
|
snapshots = backup_service.list_snapshots()
|
|
return {
|
|
"snapshots": snapshots,
|
|
"count": len(snapshots)
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Failed to list snapshots: {str(e)}")
|
|
|
|
|
|
@router.get("/database/snapshot/{filename}")
|
|
def download_snapshot(filename: str):
|
|
"""Download a specific snapshot file"""
|
|
try:
|
|
snapshot_path = backup_service.download_snapshot(filename)
|
|
return FileResponse(
|
|
path=str(snapshot_path),
|
|
filename=filename,
|
|
media_type="application/x-sqlite3"
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
|
|
|
|
|
|
@router.delete("/database/snapshot/{filename}")
|
|
def delete_database_snapshot(filename: str):
|
|
"""Delete a specific snapshot"""
|
|
try:
|
|
backup_service.delete_snapshot(filename)
|
|
return {
|
|
"message": f"Snapshot {filename} deleted successfully",
|
|
"filename": filename
|
|
}
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
|
|
|
|
|
class RestoreRequest(BaseModel):
|
|
"""Schema for restore request"""
|
|
filename: str
|
|
create_backup: bool = True
|
|
|
|
|
|
@router.post("/database/restore")
|
|
def restore_database(request: RestoreRequest, db: Session = Depends(get_db)):
|
|
"""Restore database from a snapshot"""
|
|
try:
|
|
# Close the database connection before restoring
|
|
db.close()
|
|
|
|
result = backup_service.restore_snapshot(
|
|
filename=request.filename,
|
|
create_backup_before_restore=request.create_backup
|
|
)
|
|
|
|
return result
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail=f"Snapshot {request.filename} not found")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Restore failed: {str(e)}")
|
|
|
|
|
|
@router.post("/database/upload-snapshot")
|
|
async def upload_snapshot(file: UploadFile = File(...)):
|
|
"""Upload a snapshot file to the backups directory"""
|
|
if not file.filename.endswith('.db'):
|
|
raise HTTPException(status_code=400, detail="File must be a .db file")
|
|
|
|
try:
|
|
# Save uploaded file to backups directory
|
|
backups_dir = Path("./data/backups")
|
|
backups_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
uploaded_filename = f"snapshot_uploaded_{timestamp}.db"
|
|
file_path = backups_dir / uploaded_filename
|
|
|
|
# Save file
|
|
with open(file_path, "wb") as buffer:
|
|
shutil.copyfileobj(file.file, buffer)
|
|
|
|
# Create metadata
|
|
metadata = {
|
|
"filename": uploaded_filename,
|
|
"created_at": timestamp,
|
|
"created_at_iso": datetime.utcnow().isoformat(),
|
|
"description": f"Uploaded: {file.filename}",
|
|
"size_bytes": file_path.stat().st_size,
|
|
"size_mb": round(file_path.stat().st_size / (1024 * 1024), 2),
|
|
"type": "uploaded"
|
|
}
|
|
|
|
metadata_path = backups_dir / f"{uploaded_filename}.meta.json"
|
|
import json
|
|
with open(metadata_path, 'w') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
return {
|
|
"message": "Snapshot uploaded successfully",
|
|
"snapshot": metadata
|
|
}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
|
|
|
|
|
# ============================================================================
|
|
# SLMM SYNC ENDPOINTS
|
|
# ============================================================================
|
|
|
|
@router.post("/slmm/sync-all")
|
|
async def sync_all_slms(db: Session = Depends(get_db)):
|
|
"""
|
|
Manually trigger full sync of all SLM devices from Terra-View roster to SLMM.
|
|
|
|
This ensures SLMM database matches Terra-View roster (source of truth).
|
|
Also cleans up orphaned devices in SLMM that are not in Terra-View.
|
|
"""
|
|
from backend.services.slmm_sync import sync_all_slms_to_slmm, cleanup_orphaned_slmm_devices
|
|
|
|
try:
|
|
# Sync all SLMs
|
|
sync_results = await sync_all_slms_to_slmm(db)
|
|
|
|
# Clean up orphaned devices
|
|
cleanup_results = await cleanup_orphaned_slmm_devices(db)
|
|
|
|
return {
|
|
"status": "ok",
|
|
"sync": sync_results,
|
|
"cleanup": cleanup_results
|
|
}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
|
|
|
|
|
|
@router.get("/slmm/status")
|
|
async def get_slmm_sync_status(db: Session = Depends(get_db)):
|
|
"""
|
|
Get status of SLMM synchronization.
|
|
|
|
Shows which devices are in Terra-View roster vs SLMM database.
|
|
"""
|
|
from backend.services.slmm_sync import get_slmm_devices
|
|
|
|
try:
|
|
# Get devices from both systems
|
|
roster_slms = db.query(RosterUnit).filter_by(device_type="slm").all()
|
|
slmm_devices = await get_slmm_devices()
|
|
|
|
if slmm_devices is None:
|
|
raise HTTPException(status_code=503, detail="SLMM service unavailable")
|
|
|
|
roster_unit_ids = {unit.unit_type for unit in roster_slms}
|
|
slmm_unit_ids = set(slmm_devices)
|
|
|
|
# Find differences
|
|
in_roster_only = roster_unit_ids - slmm_unit_ids
|
|
in_slmm_only = slmm_unit_ids - roster_unit_ids
|
|
in_both = roster_unit_ids & slmm_unit_ids
|
|
|
|
return {
|
|
"status": "ok",
|
|
"terra_view_total": len(roster_unit_ids),
|
|
"slmm_total": len(slmm_unit_ids),
|
|
"synced": len(in_both),
|
|
"missing_from_slmm": list(in_roster_only),
|
|
"orphaned_in_slmm": list(in_slmm_only),
|
|
"in_sync": len(in_roster_only) == 0 and len(in_slmm_only) == 0
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Status check failed: {str(e)}")
|