BIG update: Update to 0.5.1. Added:
-Project management -Modem Managerment -Modem/unit pairing and more
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
@@ -150,6 +150,8 @@ async def add_roster_unit(
|
||||
ip_address: str = Form(None),
|
||||
phone_number: str = Form(None),
|
||||
hardware_model: str = Form(None),
|
||||
deployment_type: str = Form(None), # "seismograph" | "slm" - what device type modem is deployed with
|
||||
deployed_with_unit_id: str = Form(None), # ID of seismograph/SLM this modem is deployed with
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host: str = Form(None),
|
||||
slm_tcp_port: str = Form(None),
|
||||
@@ -209,6 +211,7 @@ async def add_roster_unit(
|
||||
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,
|
||||
deployment_type=deployment_type if deployment_type else None,
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host=slm_host if slm_host else None,
|
||||
slm_tcp_port=slm_tcp_port_int,
|
||||
@@ -219,6 +222,23 @@ async def add_roster_unit(
|
||||
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||
)
|
||||
|
||||
# Auto-fill location data from modem if pairing and fields are empty
|
||||
if deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter(
|
||||
RosterUnit.id == deployed_with_modem_id,
|
||||
RosterUnit.device_type == "modem"
|
||||
).first()
|
||||
if modem:
|
||||
if not unit.location and modem.location:
|
||||
unit.location = modem.location
|
||||
if not unit.address and modem.address:
|
||||
unit.address = modem.address
|
||||
if not unit.coordinates and modem.coordinates:
|
||||
unit.coordinates = modem.coordinates
|
||||
if not unit.project_id and modem.project_id:
|
||||
unit.project_id = modem.project_id
|
||||
|
||||
db.add(unit)
|
||||
db.commit()
|
||||
|
||||
@@ -259,6 +279,145 @@ def get_modems_list(db: Session = Depends(get_db)):
|
||||
]
|
||||
|
||||
|
||||
@router.get("/search/modems")
|
||||
def search_modems(
|
||||
request: Request,
|
||||
q: str = Query("", description="Search term"),
|
||||
deployed_only: bool = Query(False, description="Only show deployed modems"),
|
||||
exclude_retired: bool = Query(True, description="Exclude retired modems"),
|
||||
limit: int = Query(10, le=50),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search modems by ID, IP address, or note. Returns HTML partial for HTMX dropdown.
|
||||
|
||||
Used by modem picker component to find modems to link with seismographs/SLMs.
|
||||
"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
query = db.query(RosterUnit).filter(RosterUnit.device_type == "modem")
|
||||
|
||||
if deployed_only:
|
||||
query = query.filter(RosterUnit.deployed == True)
|
||||
|
||||
if exclude_retired:
|
||||
query = query.filter(RosterUnit.retired == False)
|
||||
|
||||
# Search by ID, IP address, or note
|
||||
if q and q.strip():
|
||||
search_term = f"%{q.strip()}%"
|
||||
query = query.filter(
|
||||
(RosterUnit.id.ilike(search_term)) |
|
||||
(RosterUnit.ip_address.ilike(search_term)) |
|
||||
(RosterUnit.note.ilike(search_term))
|
||||
)
|
||||
|
||||
modems = query.order_by(RosterUnit.id).limit(limit).all()
|
||||
|
||||
# Build results
|
||||
results = []
|
||||
for modem in modems:
|
||||
# Build display text: ID - IP - Note (if available)
|
||||
display_parts = [modem.id]
|
||||
if modem.ip_address:
|
||||
display_parts.append(modem.ip_address)
|
||||
if modem.note:
|
||||
display_parts.append(modem.note)
|
||||
display = " - ".join(display_parts)
|
||||
|
||||
results.append({
|
||||
"id": modem.id,
|
||||
"ip_address": modem.ip_address or "",
|
||||
"phone_number": modem.phone_number or "",
|
||||
"note": modem.note or "",
|
||||
"deployed": modem.deployed,
|
||||
"display": display
|
||||
})
|
||||
|
||||
# Determine if we should show "no results" message
|
||||
show_empty = len(results) == 0 and q and q.strip()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/modem_search_results.html",
|
||||
{
|
||||
"request": request,
|
||||
"modems": results,
|
||||
"query": q,
|
||||
"show_empty": show_empty
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/search/units")
|
||||
def search_units(
|
||||
request: Request,
|
||||
q: str = Query("", description="Search term"),
|
||||
device_type: str = Query(None, description="Filter by device type: seismograph, modem, slm"),
|
||||
deployed_only: bool = Query(False, description="Only show deployed units"),
|
||||
exclude_retired: bool = Query(True, description="Exclude retired units"),
|
||||
limit: int = Query(10, le=50),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Search roster units by ID or note. Returns HTML partial for HTMX dropdown.
|
||||
|
||||
Used by unit picker component to find seismographs/SLMs to link with modems.
|
||||
"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
query = db.query(RosterUnit)
|
||||
|
||||
# Apply filters
|
||||
if device_type:
|
||||
query = query.filter(RosterUnit.device_type == device_type)
|
||||
|
||||
if deployed_only:
|
||||
query = query.filter(RosterUnit.deployed == True)
|
||||
|
||||
if exclude_retired:
|
||||
query = query.filter(RosterUnit.retired == False)
|
||||
|
||||
# Search by ID or note
|
||||
if q and q.strip():
|
||||
search_term = f"%{q.strip()}%"
|
||||
query = query.filter(
|
||||
(RosterUnit.id.ilike(search_term)) |
|
||||
(RosterUnit.note.ilike(search_term))
|
||||
)
|
||||
|
||||
units = query.order_by(RosterUnit.id).limit(limit).all()
|
||||
|
||||
# Build results
|
||||
results = []
|
||||
for unit in units:
|
||||
results.append({
|
||||
"id": unit.id,
|
||||
"device_type": unit.device_type or "seismograph",
|
||||
"note": unit.note or "",
|
||||
"deployed": unit.deployed,
|
||||
"display": f"{unit.id}" + (f" - {unit.note}" if unit.note else "")
|
||||
})
|
||||
|
||||
# Determine if we should show "no results" message
|
||||
show_empty = len(results) == 0 and q and q.strip()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/unit_search_results.html",
|
||||
{
|
||||
"request": request,
|
||||
"units": results,
|
||||
"query": q,
|
||||
"show_empty": show_empty
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{unit_id}")
|
||||
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a single roster unit by ID"""
|
||||
@@ -283,6 +442,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"ip_address": unit.ip_address or "",
|
||||
"phone_number": unit.phone_number or "",
|
||||
"hardware_model": unit.hardware_model or "",
|
||||
"deployment_type": unit.deployment_type or "",
|
||||
"deployed_with_unit_id": unit.deployed_with_unit_id or "",
|
||||
"slm_host": unit.slm_host or "",
|
||||
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||
@@ -314,6 +475,8 @@ def edit_roster_unit(
|
||||
ip_address: str = Form(None),
|
||||
phone_number: str = Form(None),
|
||||
hardware_model: str = Form(None),
|
||||
deployment_type: str = Form(None),
|
||||
deployed_with_unit_id: str = Form(None),
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host: str = Form(None),
|
||||
slm_tcp_port: str = Form(None),
|
||||
@@ -323,6 +486,14 @@ def edit_roster_unit(
|
||||
slm_frequency_weighting: str = Form(None),
|
||||
slm_time_weighting: str = Form(None),
|
||||
slm_measurement_range: str = Form(None),
|
||||
# Cascade options - sync fields to paired device
|
||||
cascade_to_unit_id: str = Form(None),
|
||||
cascade_deployed: str = Form(None),
|
||||
cascade_retired: str = Form(None),
|
||||
cascade_project: str = Form(None),
|
||||
cascade_location: str = Form(None),
|
||||
cascade_coordinates: str = Form(None),
|
||||
cascade_note: str = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
@@ -374,10 +545,29 @@ def edit_roster_unit(
|
||||
unit.next_calibration_due = next_cal_date
|
||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||
|
||||
# Auto-fill location data from modem if pairing and fields are empty
|
||||
if deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter(
|
||||
RosterUnit.id == deployed_with_modem_id,
|
||||
RosterUnit.device_type == "modem"
|
||||
).first()
|
||||
if modem:
|
||||
# Only fill if the device field is empty
|
||||
if not unit.location and modem.location:
|
||||
unit.location = modem.location
|
||||
if not unit.address and modem.address:
|
||||
unit.address = modem.address
|
||||
if not unit.coordinates and modem.coordinates:
|
||||
unit.coordinates = modem.coordinates
|
||||
if not unit.project_id and modem.project_id:
|
||||
unit.project_id = modem.project_id
|
||||
|
||||
# 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
|
||||
unit.deployment_type = deployment_type if deployment_type else None
|
||||
unit.deployed_with_unit_id = deployed_with_unit_id if deployed_with_unit_id else None
|
||||
|
||||
# Sound Level Meter-specific fields
|
||||
unit.slm_host = slm_host if slm_host else None
|
||||
@@ -403,8 +593,79 @@ def edit_roster_unit(
|
||||
old_status_text = "retired" if old_retired else "active"
|
||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||
|
||||
# Handle cascade to paired device
|
||||
cascaded_unit_id = None
|
||||
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
||||
paired_unit = db.query(RosterUnit).filter(RosterUnit.id == cascade_to_unit_id).first()
|
||||
if paired_unit:
|
||||
cascaded_unit_id = paired_unit.id
|
||||
|
||||
# Cascade deployed status
|
||||
if cascade_deployed in ['true', 'True', '1', 'yes']:
|
||||
old_paired_deployed = paired_unit.deployed
|
||||
paired_unit.deployed = deployed_bool
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_deployed != deployed_bool:
|
||||
status_text = "deployed" if deployed_bool else "benched"
|
||||
old_status_text = "deployed" if old_paired_deployed else "benched"
|
||||
record_history(db, paired_unit.id, "deployed_change", "deployed",
|
||||
old_status_text, status_text, f"cascade from {unit_id}")
|
||||
|
||||
# Cascade retired status
|
||||
if cascade_retired in ['true', 'True', '1', 'yes']:
|
||||
old_paired_retired = paired_unit.retired
|
||||
paired_unit.retired = retired_bool
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_retired != retired_bool:
|
||||
status_text = "retired" if retired_bool else "active"
|
||||
old_status_text = "retired" if old_paired_retired else "active"
|
||||
record_history(db, paired_unit.id, "retired_change", "retired",
|
||||
old_status_text, status_text, f"cascade from {unit_id}")
|
||||
|
||||
# Cascade project
|
||||
if cascade_project in ['true', 'True', '1', 'yes']:
|
||||
old_paired_project = paired_unit.project_id
|
||||
paired_unit.project_id = project_id
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_project != project_id:
|
||||
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
||||
|
||||
# Cascade address/location
|
||||
if cascade_location in ['true', 'True', '1', 'yes']:
|
||||
old_paired_address = paired_unit.address
|
||||
old_paired_location = paired_unit.location
|
||||
paired_unit.address = address
|
||||
paired_unit.location = location
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_address != address:
|
||||
record_history(db, paired_unit.id, "address_change", "address",
|
||||
old_paired_address or "", address or "", f"cascade from {unit_id}")
|
||||
|
||||
# Cascade coordinates
|
||||
if cascade_coordinates in ['true', 'True', '1', 'yes']:
|
||||
old_paired_coords = paired_unit.coordinates
|
||||
paired_unit.coordinates = coordinates
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_coords != coordinates:
|
||||
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
|
||||
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
|
||||
|
||||
# Cascade note
|
||||
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||
old_paired_note = paired_unit.note
|
||||
paired_unit.note = note
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_note != note:
|
||||
record_history(db, paired_unit.id, "note_change", "note",
|
||||
old_paired_note or "", note or "", f"cascade from {unit_id}")
|
||||
|
||||
db.commit()
|
||||
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||
|
||||
response = {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||
if cascaded_unit_id:
|
||||
response["cascaded_to"] = cascaded_unit_id
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/set-deployed/{unit_id}")
|
||||
@@ -624,6 +885,40 @@ async def import_csv(
|
||||
filtered_lines = [line for line in lines if not line.strip().startswith('#')]
|
||||
csv_text = '\n'.join(filtered_lines)
|
||||
|
||||
# First pass: validate for duplicates and empty unit_ids
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||
seen_unit_ids = {} # unit_id -> list of row numbers
|
||||
empty_unit_id_rows = []
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2):
|
||||
unit_id = row.get('unit_id', '').strip()
|
||||
if not unit_id:
|
||||
empty_unit_id_rows.append(row_num)
|
||||
else:
|
||||
if unit_id not in seen_unit_ids:
|
||||
seen_unit_ids[unit_id] = []
|
||||
seen_unit_ids[unit_id].append(row_num)
|
||||
|
||||
# Check for validation errors
|
||||
validation_errors = []
|
||||
|
||||
# Report empty unit_ids
|
||||
if empty_unit_id_rows:
|
||||
validation_errors.append(f"Empty unit_id on row(s): {', '.join(map(str, empty_unit_id_rows))}")
|
||||
|
||||
# Report duplicates
|
||||
duplicates = {uid: rows for uid, rows in seen_unit_ids.items() if len(rows) > 1}
|
||||
if duplicates:
|
||||
for uid, rows in duplicates.items():
|
||||
validation_errors.append(f"Duplicate unit_id '{uid}' on rows: {', '.join(map(str, rows))}")
|
||||
|
||||
if validation_errors:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="CSV validation failed:\n" + "\n".join(validation_errors)
|
||||
)
|
||||
|
||||
# Second pass: actually import the data
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||
|
||||
results = {
|
||||
@@ -682,6 +977,10 @@ async def import_csv(
|
||||
existing_unit.phone_number = _get_csv_value(row, 'phone_number')
|
||||
if row.get('hardware_model'):
|
||||
existing_unit.hardware_model = _get_csv_value(row, 'hardware_model')
|
||||
if row.get('deployment_type'):
|
||||
existing_unit.deployment_type = _get_csv_value(row, 'deployment_type')
|
||||
if row.get('deployed_with_unit_id'):
|
||||
existing_unit.deployed_with_unit_id = _get_csv_value(row, 'deployed_with_unit_id')
|
||||
|
||||
# SLM-specific fields
|
||||
if row.get('slm_host'):
|
||||
@@ -724,6 +1023,8 @@ async def import_csv(
|
||||
ip_address=_get_csv_value(row, 'ip_address'),
|
||||
phone_number=_get_csv_value(row, 'phone_number'),
|
||||
hardware_model=_get_csv_value(row, 'hardware_model'),
|
||||
deployment_type=_get_csv_value(row, 'deployment_type'),
|
||||
deployed_with_unit_id=_get_csv_value(row, 'deployed_with_unit_id'),
|
||||
# SLM fields
|
||||
slm_host=_get_csv_value(row, 'slm_host'),
|
||||
slm_tcp_port=_parse_int(row.get('slm_tcp_port', '')),
|
||||
|
||||
Reference in New Issue
Block a user