721 lines
26 KiB
Python
721 lines
26 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from sqlalchemy.orm import Session
|
|
from datetime import datetime, date
|
|
import csv
|
|
import io
|
|
import logging
|
|
import httpx
|
|
import os
|
|
|
|
from backend.database import get_db
|
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
|
|
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# SLMM backend URL for syncing device configs to cache
|
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
|
|
|
|
|
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
|
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
|
"""Helper function to record a change in unit history"""
|
|
history_entry = UnitHistory(
|
|
unit_id=unit_id,
|
|
change_type=change_type,
|
|
field_name=field_name,
|
|
old_value=old_value,
|
|
new_value=new_value,
|
|
changed_at=datetime.utcnow(),
|
|
source=source,
|
|
notes=notes
|
|
)
|
|
db.add(history_entry)
|
|
# Note: caller is responsible for db.commit()
|
|
|
|
|
|
def get_or_create_roster_unit(db: Session, unit_id: str):
|
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
|
if not unit:
|
|
unit = RosterUnit(id=unit_id)
|
|
db.add(unit)
|
|
db.commit()
|
|
db.refresh(unit)
|
|
return unit
|
|
|
|
|
|
async def sync_slm_to_slmm_cache(
|
|
unit_id: str,
|
|
host: str = None,
|
|
tcp_port: int = None,
|
|
ftp_port: int = None,
|
|
ftp_username: str = None,
|
|
ftp_password: str = None,
|
|
deployed_with_modem_id: str = None,
|
|
db: Session = None
|
|
) -> dict:
|
|
"""
|
|
Sync SLM device configuration to SLMM backend cache.
|
|
|
|
Terra-View is the source of truth for device configs. This function updates
|
|
SLMM's config cache (NL43Config table) so SLMM can look up device connection
|
|
info by unit_id without Terra-View passing host:port with every request.
|
|
|
|
Args:
|
|
unit_id: Unique identifier for the SLM device
|
|
host: Direct IP address/hostname OR will be resolved from modem
|
|
tcp_port: TCP control port (default: 2255)
|
|
ftp_port: FTP port (default: 21)
|
|
ftp_username: FTP username (optional)
|
|
ftp_password: FTP password (optional)
|
|
deployed_with_modem_id: If set, resolve modem IP as host
|
|
db: Database session for modem lookup
|
|
|
|
Returns:
|
|
dict: {"success": bool, "message": str}
|
|
"""
|
|
# Resolve host from modem if assigned
|
|
if deployed_with_modem_id and db:
|
|
modem = db.query(RosterUnit).filter_by(
|
|
id=deployed_with_modem_id,
|
|
device_type="modem"
|
|
).first()
|
|
if modem and modem.ip_address:
|
|
host = modem.ip_address
|
|
logger.info(f"Resolved host from modem {deployed_with_modem_id}: {host}")
|
|
|
|
# Validate required fields
|
|
if not host:
|
|
logger.warning(f"Cannot sync SLM {unit_id} to SLMM: no host/IP address provided")
|
|
return {"success": False, "message": "No host IP address available"}
|
|
|
|
# Set defaults
|
|
tcp_port = tcp_port or 2255
|
|
ftp_port = ftp_port or 21
|
|
|
|
# Build SLMM cache payload
|
|
config_payload = {
|
|
"host": host,
|
|
"tcp_port": tcp_port,
|
|
"tcp_enabled": True,
|
|
"ftp_enabled": bool(ftp_username and ftp_password),
|
|
"web_enabled": False
|
|
}
|
|
|
|
if ftp_username and ftp_password:
|
|
config_payload["ftp_username"] = ftp_username
|
|
config_payload["ftp_password"] = ftp_password
|
|
|
|
# Call SLMM cache update API
|
|
slmm_url = f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
response = await client.put(slmm_url, json=config_payload)
|
|
|
|
if response.status_code in [200, 201]:
|
|
logger.info(f"Successfully synced SLM {unit_id} to SLMM cache")
|
|
return {"success": True, "message": "Device config cached in SLMM"}
|
|
else:
|
|
logger.error(f"SLMM cache sync failed for {unit_id}: HTTP {response.status_code}")
|
|
return {"success": False, "message": f"SLMM returned status {response.status_code}"}
|
|
|
|
except httpx.ConnectError:
|
|
logger.error(f"Cannot connect to SLMM service at {SLMM_BASE_URL}")
|
|
return {"success": False, "message": "SLMM service unavailable"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error syncing SLM {unit_id} to SLMM: {e}")
|
|
return {"success": False, "message": str(e)}
|
|
|
|
|
|
@router.post("/add")
|
|
async def add_roster_unit(
|
|
id: str = Form(...),
|
|
device_type: str = Form("seismograph"),
|
|
unit_type: str = Form("series3"),
|
|
deployed: str = Form(None),
|
|
retired: str = Form(None),
|
|
note: str = Form(""),
|
|
project_id: str = Form(None),
|
|
location: str = Form(None),
|
|
address: str = Form(None),
|
|
coordinates: str = Form(None),
|
|
# Seismograph-specific fields
|
|
last_calibrated: str = Form(None),
|
|
next_calibration_due: str = Form(None),
|
|
deployed_with_modem_id: str = Form(None),
|
|
# Modem-specific fields
|
|
ip_address: str = Form(None),
|
|
phone_number: str = Form(None),
|
|
hardware_model: str = Form(None),
|
|
# Sound Level Meter-specific fields
|
|
slm_host: str = Form(None),
|
|
slm_tcp_port: str = Form(None),
|
|
slm_ftp_port: str = Form(None),
|
|
slm_model: str = Form(None),
|
|
slm_serial_number: str = Form(None),
|
|
slm_frequency_weighting: str = Form(None),
|
|
slm_time_weighting: str = Form(None),
|
|
slm_measurement_range: str = Form(None),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}")
|
|
|
|
# Convert boolean strings to actual booleans
|
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
|
|
|
# Convert port strings to integers
|
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
|
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
|
|
|
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
|
raise HTTPException(status_code=400, detail="Unit already exists")
|
|
|
|
# Parse date fields if provided
|
|
last_cal_date = None
|
|
if last_calibrated:
|
|
try:
|
|
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
|
|
|
next_cal_date = None
|
|
if next_calibration_due:
|
|
try:
|
|
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
|
|
|
unit = RosterUnit(
|
|
id=id,
|
|
device_type=device_type,
|
|
unit_type=unit_type,
|
|
deployed=deployed_bool,
|
|
retired=retired_bool,
|
|
note=note,
|
|
project_id=project_id,
|
|
location=location,
|
|
address=address,
|
|
coordinates=coordinates,
|
|
last_updated=datetime.utcnow(),
|
|
# Seismograph-specific fields
|
|
last_calibrated=last_cal_date,
|
|
next_calibration_due=next_cal_date,
|
|
deployed_with_modem_id=deployed_with_modem_id if deployed_with_modem_id else None,
|
|
# Modem-specific fields
|
|
ip_address=ip_address if ip_address else None,
|
|
phone_number=phone_number if phone_number else None,
|
|
hardware_model=hardware_model if hardware_model else None,
|
|
# Sound Level Meter-specific fields
|
|
slm_host=slm_host if slm_host else None,
|
|
slm_tcp_port=slm_tcp_port_int,
|
|
slm_ftp_port=slm_ftp_port_int,
|
|
slm_model=slm_model if slm_model else None,
|
|
slm_serial_number=slm_serial_number if slm_serial_number else None,
|
|
slm_frequency_weighting=slm_frequency_weighting if slm_frequency_weighting else None,
|
|
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
|
)
|
|
db.add(unit)
|
|
db.commit()
|
|
|
|
# If sound level meter, sync config to SLMM cache
|
|
if device_type == "sound_level_meter":
|
|
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
|
result = await sync_slm_to_slmm_cache(
|
|
unit_id=id,
|
|
host=slm_host,
|
|
tcp_port=slm_tcp_port_int,
|
|
ftp_port=slm_ftp_port_int,
|
|
deployed_with_modem_id=deployed_with_modem_id,
|
|
db=db
|
|
)
|
|
|
|
if not result["success"]:
|
|
logger.warning(f"SLMM cache sync warning for {id}: {result['message']}")
|
|
# Don't fail the operation - device is still added to Terra-View roster
|
|
# User can manually sync later or SLMM will be synced on next config update
|
|
|
|
return {"message": "Unit added", "id": id, "device_type": device_type}
|
|
|
|
|
|
@router.get("/modems")
|
|
def get_modems_list(db: Session = Depends(get_db)):
|
|
"""Get list of all modem units for dropdown selection"""
|
|
modems = db.query(RosterUnit).filter_by(device_type="modem", retired=False).order_by(RosterUnit.id).all()
|
|
|
|
return [
|
|
{
|
|
"id": modem.id,
|
|
"ip_address": modem.ip_address,
|
|
"phone_number": modem.phone_number,
|
|
"hardware_model": modem.hardware_model,
|
|
"deployed": modem.deployed
|
|
}
|
|
for modem in modems
|
|
]
|
|
|
|
|
|
@router.get("/{unit_id}")
|
|
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|
"""Get a single roster unit by ID"""
|
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
|
if not unit:
|
|
raise HTTPException(status_code=404, detail="Unit not found")
|
|
|
|
return {
|
|
"id": unit.id,
|
|
"device_type": unit.device_type or "seismograph",
|
|
"unit_type": unit.unit_type,
|
|
"deployed": unit.deployed,
|
|
"retired": unit.retired,
|
|
"note": unit.note or "",
|
|
"project_id": unit.project_id or "",
|
|
"location": unit.location or "",
|
|
"address": unit.address or "",
|
|
"coordinates": unit.coordinates or "",
|
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
|
"next_calibration_due": unit.next_calibration_due.isoformat() 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 "",
|
|
"slm_host": unit.slm_host or "",
|
|
"slm_tcp_port": unit.slm_tcp_port or "",
|
|
"slm_ftp_port": unit.slm_ftp_port or "",
|
|
"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 "",
|
|
}
|
|
|
|
|
|
@router.post("/edit/{unit_id}")
|
|
def edit_roster_unit(
|
|
unit_id: str,
|
|
device_type: str = Form("seismograph"),
|
|
unit_type: str = Form("series3"),
|
|
deployed: str = Form(None),
|
|
retired: str = Form(None),
|
|
note: str = Form(""),
|
|
project_id: str = Form(None),
|
|
location: str = Form(None),
|
|
address: str = Form(None),
|
|
coordinates: str = Form(None),
|
|
# Seismograph-specific fields
|
|
last_calibrated: str = Form(None),
|
|
next_calibration_due: str = Form(None),
|
|
deployed_with_modem_id: str = Form(None),
|
|
# Modem-specific fields
|
|
ip_address: str = Form(None),
|
|
phone_number: str = Form(None),
|
|
hardware_model: str = Form(None),
|
|
# Sound Level Meter-specific fields
|
|
slm_host: str = Form(None),
|
|
slm_tcp_port: str = Form(None),
|
|
slm_ftp_port: str = Form(None),
|
|
slm_model: str = Form(None),
|
|
slm_serial_number: str = Form(None),
|
|
slm_frequency_weighting: str = Form(None),
|
|
slm_time_weighting: str = Form(None),
|
|
slm_measurement_range: str = Form(None),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
|
if not unit:
|
|
raise HTTPException(status_code=404, detail="Unit not found")
|
|
|
|
# Convert boolean strings to actual booleans
|
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
|
|
|
# Convert port strings to integers
|
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
|
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
|
|
|
# Parse date fields if provided
|
|
last_cal_date = None
|
|
if last_calibrated:
|
|
try:
|
|
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
|
|
|
next_cal_date = None
|
|
if next_calibration_due:
|
|
try:
|
|
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
|
|
|
# Track changes for history
|
|
old_note = unit.note
|
|
old_deployed = unit.deployed
|
|
old_retired = unit.retired
|
|
|
|
# Update all fields
|
|
unit.device_type = device_type
|
|
unit.unit_type = unit_type
|
|
unit.deployed = deployed_bool
|
|
unit.retired = retired_bool
|
|
unit.note = note
|
|
unit.project_id = project_id
|
|
unit.location = location
|
|
unit.address = address
|
|
unit.coordinates = coordinates
|
|
unit.last_updated = datetime.utcnow()
|
|
|
|
# Seismograph-specific fields
|
|
unit.last_calibrated = last_cal_date
|
|
unit.next_calibration_due = next_cal_date
|
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
|
|
|
# Modem-specific fields
|
|
unit.ip_address = ip_address if ip_address else None
|
|
unit.phone_number = phone_number if phone_number else None
|
|
unit.hardware_model = hardware_model if hardware_model else None
|
|
|
|
# Sound Level Meter-specific fields
|
|
unit.slm_host = slm_host if slm_host else None
|
|
unit.slm_tcp_port = slm_tcp_port_int
|
|
unit.slm_ftp_port = slm_ftp_port_int
|
|
unit.slm_model = slm_model if slm_model else None
|
|
unit.slm_serial_number = slm_serial_number if slm_serial_number else None
|
|
unit.slm_frequency_weighting = slm_frequency_weighting if slm_frequency_weighting else None
|
|
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
|
|
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None
|
|
|
|
# Record history entries for changed fields
|
|
if old_note != note:
|
|
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
|
|
|
if old_deployed != deployed:
|
|
status_text = "deployed" if deployed else "benched"
|
|
old_status_text = "deployed" if old_deployed else "benched"
|
|
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
|
|
|
if old_retired != retired:
|
|
status_text = "retired" if retired else "active"
|
|
old_status_text = "retired" if old_retired else "active"
|
|
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
|
|
|
db.commit()
|
|
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
|
|
|
|
|
@router.post("/set-deployed/{unit_id}")
|
|
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
|
unit = get_or_create_roster_unit(db, unit_id)
|
|
old_deployed = unit.deployed
|
|
unit.deployed = deployed
|
|
unit.last_updated = datetime.utcnow()
|
|
|
|
# Record history entry for deployed status change
|
|
if old_deployed != deployed:
|
|
status_text = "deployed" if deployed else "benched"
|
|
old_status_text = "deployed" if old_deployed else "benched"
|
|
record_history(
|
|
db=db,
|
|
unit_id=unit_id,
|
|
change_type="deployed_change",
|
|
field_name="deployed",
|
|
old_value=old_status_text,
|
|
new_value=status_text,
|
|
source="manual"
|
|
)
|
|
|
|
db.commit()
|
|
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
|
|
|
|
|
@router.post("/set-retired/{unit_id}")
|
|
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
|
unit = get_or_create_roster_unit(db, unit_id)
|
|
old_retired = unit.retired
|
|
unit.retired = retired
|
|
unit.last_updated = datetime.utcnow()
|
|
|
|
# Record history entry for retired status change
|
|
if old_retired != retired:
|
|
status_text = "retired" if retired else "active"
|
|
old_status_text = "retired" if old_retired else "active"
|
|
record_history(
|
|
db=db,
|
|
unit_id=unit_id,
|
|
change_type="retired_change",
|
|
field_name="retired",
|
|
old_value=old_status_text,
|
|
new_value=status_text,
|
|
source="manual"
|
|
)
|
|
|
|
db.commit()
|
|
return {"message": "Updated", "id": unit_id, "retired": retired}
|
|
|
|
|
|
@router.delete("/{unit_id}")
|
|
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Permanently delete a unit from the database.
|
|
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
|
|
"""
|
|
deleted = False
|
|
|
|
# Try to delete from roster table
|
|
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
|
if roster_unit:
|
|
db.delete(roster_unit)
|
|
deleted = True
|
|
|
|
# Try to delete from emitters table
|
|
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
|
|
if emitter:
|
|
db.delete(emitter)
|
|
deleted = True
|
|
|
|
# Try to delete from ignored_units table
|
|
ignored_unit = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
|
if ignored_unit:
|
|
db.delete(ignored_unit)
|
|
deleted = True
|
|
|
|
# If not found in any table, return error
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Unit not found")
|
|
|
|
db.commit()
|
|
return {"message": "Unit deleted", "id": unit_id}
|
|
|
|
|
|
@router.post("/set-note/{unit_id}")
|
|
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
|
unit = get_or_create_roster_unit(db, unit_id)
|
|
old_note = unit.note
|
|
unit.note = note
|
|
unit.last_updated = datetime.utcnow()
|
|
|
|
# Record history entry for note change
|
|
if old_note != note:
|
|
record_history(
|
|
db=db,
|
|
unit_id=unit_id,
|
|
change_type="note_change",
|
|
field_name="note",
|
|
old_value=old_note,
|
|
new_value=note,
|
|
source="manual"
|
|
)
|
|
|
|
db.commit()
|
|
return {"message": "Updated", "id": unit_id, "note": note}
|
|
|
|
|
|
@router.post("/import-csv")
|
|
async def import_csv(
|
|
file: UploadFile = File(...),
|
|
update_existing: bool = Form(True),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Import roster units from CSV file.
|
|
|
|
Expected CSV columns (unit_id is required, others are optional):
|
|
- unit_id: Unique identifier for the unit
|
|
- unit_type: Type of unit (default: "series3")
|
|
- deployed: Boolean for deployment status (default: False)
|
|
- retired: Boolean for retirement status (default: False)
|
|
- note: Notes about the unit
|
|
- project_id: Project identifier
|
|
- location: Location description
|
|
|
|
Args:
|
|
file: CSV file upload
|
|
update_existing: If True, update existing units; if False, skip them
|
|
"""
|
|
|
|
if not file.filename.endswith('.csv'):
|
|
raise HTTPException(status_code=400, detail="File must be a CSV")
|
|
|
|
# Read file content
|
|
contents = await file.read()
|
|
csv_text = contents.decode('utf-8')
|
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
|
|
|
results = {
|
|
"added": [],
|
|
"updated": [],
|
|
"skipped": [],
|
|
"errors": []
|
|
}
|
|
|
|
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 to account for header
|
|
try:
|
|
# Validate required field
|
|
unit_id = row.get('unit_id', '').strip()
|
|
if not unit_id:
|
|
results["errors"].append({
|
|
"row": row_num,
|
|
"error": "Missing required field: unit_id"
|
|
})
|
|
continue
|
|
|
|
# Check if unit exists
|
|
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
|
|
|
if existing_unit:
|
|
if not update_existing:
|
|
results["skipped"].append(unit_id)
|
|
continue
|
|
|
|
# Update existing unit
|
|
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
|
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
|
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
|
existing_unit.note = row.get('note', existing_unit.note or '')
|
|
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
|
existing_unit.location = row.get('location', existing_unit.location)
|
|
existing_unit.address = row.get('address', existing_unit.address)
|
|
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
|
|
existing_unit.last_updated = datetime.utcnow()
|
|
|
|
results["updated"].append(unit_id)
|
|
else:
|
|
# Create new unit
|
|
new_unit = RosterUnit(
|
|
id=unit_id,
|
|
unit_type=row.get('unit_type', 'series3'),
|
|
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'),
|
|
location=row.get('location'),
|
|
address=row.get('address'),
|
|
coordinates=row.get('coordinates'),
|
|
last_updated=datetime.utcnow()
|
|
)
|
|
db.add(new_unit)
|
|
results["added"].append(unit_id)
|
|
|
|
except Exception as e:
|
|
results["errors"].append({
|
|
"row": row_num,
|
|
"unit_id": row.get('unit_id', 'unknown'),
|
|
"error": str(e)
|
|
})
|
|
|
|
# Commit all changes
|
|
try:
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
|
|
|
return {
|
|
"message": "CSV import completed",
|
|
"summary": {
|
|
"added": len(results["added"]),
|
|
"updated": len(results["updated"]),
|
|
"skipped": len(results["skipped"]),
|
|
"errors": len(results["errors"])
|
|
},
|
|
"details": results
|
|
}
|
|
|
|
|
|
@router.post("/ignore/{unit_id}")
|
|
def ignore_unit(unit_id: str, reason: str = Form(""), db: Session = Depends(get_db)):
|
|
"""
|
|
Add a unit to the ignore list to suppress it from unknown emitters.
|
|
"""
|
|
# Check if already ignored
|
|
if db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first():
|
|
raise HTTPException(status_code=400, detail="Unit already ignored")
|
|
|
|
ignored = IgnoredUnit(
|
|
id=unit_id,
|
|
reason=reason,
|
|
ignored_at=datetime.utcnow()
|
|
)
|
|
db.add(ignored)
|
|
db.commit()
|
|
return {"message": "Unit ignored", "id": unit_id}
|
|
|
|
|
|
@router.delete("/ignore/{unit_id}")
|
|
def unignore_unit(unit_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Remove a unit from the ignore list.
|
|
"""
|
|
ignored = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
|
if not ignored:
|
|
raise HTTPException(status_code=404, detail="Unit not in ignore list")
|
|
|
|
db.delete(ignored)
|
|
db.commit()
|
|
return {"message": "Unit unignored", "id": unit_id}
|
|
|
|
|
|
@router.get("/ignored")
|
|
def list_ignored_units(db: Session = Depends(get_db)):
|
|
"""
|
|
Get list of all ignored units.
|
|
"""
|
|
ignored_units = db.query(IgnoredUnit).all()
|
|
return {
|
|
"ignored": [
|
|
{
|
|
"id": unit.id,
|
|
"reason": unit.reason,
|
|
"ignored_at": unit.ignored_at.isoformat()
|
|
}
|
|
for unit in ignored_units
|
|
]
|
|
}
|
|
|
|
|
|
@router.get("/history/{unit_id}")
|
|
def get_unit_history(unit_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Get complete history timeline for a unit.
|
|
Returns all historical changes ordered by most recent first.
|
|
"""
|
|
history_entries = db.query(UnitHistory).filter(
|
|
UnitHistory.unit_id == unit_id
|
|
).order_by(UnitHistory.changed_at.desc()).all()
|
|
|
|
return {
|
|
"unit_id": unit_id,
|
|
"history": [
|
|
{
|
|
"id": entry.id,
|
|
"change_type": entry.change_type,
|
|
"field_name": entry.field_name,
|
|
"old_value": entry.old_value,
|
|
"new_value": entry.new_value,
|
|
"changed_at": entry.changed_at.isoformat(),
|
|
"source": entry.source,
|
|
"notes": entry.notes
|
|
}
|
|
for entry in history_entries
|
|
]
|
|
}
|
|
|
|
|
|
@router.delete("/history/{history_id}")
|
|
def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
|
|
"""
|
|
Delete a specific history entry by ID.
|
|
Allows manual cleanup of old history entries.
|
|
"""
|
|
history_entry = db.query(UnitHistory).filter(UnitHistory.id == history_id).first()
|
|
if not history_entry:
|
|
raise HTTPException(status_code=404, detail="History entry not found")
|
|
|
|
db.delete(history_entry)
|
|
db.commit()
|
|
return {"message": "History entry deleted", "id": history_id}
|