Compare commits
4 Commits
dev
..
0e2086d6bb
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2086d6bb | |||
| d0685baed5 | |||
| 275a168046 | |||
| f4fd1c943d |
@@ -9,7 +9,6 @@ import logging
|
|||||||
import httpx
|
import httpx
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -141,7 +140,6 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
days = int(hours_ago / 24)
|
days = int(hours_ago / 24)
|
||||||
time_ago = f"{days}d ago"
|
time_ago = f"{days}d ago"
|
||||||
|
|
||||||
loc = get_active_location(db, emitter.id) if roster_unit else None
|
|
||||||
call_in = {
|
call_in = {
|
||||||
"unit_id": emitter.id,
|
"unit_id": emitter.id,
|
||||||
"last_seen": emitter.last_seen.isoformat(),
|
"last_seen": emitter.last_seen.isoformat(),
|
||||||
@@ -150,7 +148,7 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
||||||
"deployed": roster_unit.deployed if roster_unit else False,
|
"deployed": roster_unit.deployed if roster_unit else False,
|
||||||
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
|
||||||
}
|
}
|
||||||
call_ins.append(call_in)
|
call_ins.append(call_in)
|
||||||
|
|
||||||
|
|||||||
@@ -750,17 +750,15 @@ async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
# Last seen from emitter
|
# Last seen from emitter
|
||||||
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
||||||
|
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
loc = get_active_location(db, u.id)
|
|
||||||
return {
|
return {
|
||||||
"id": u.id,
|
"id": u.id,
|
||||||
"unit_type": u.unit_type,
|
"unit_type": u.unit_type,
|
||||||
"deployed": u.deployed,
|
"deployed": u.deployed,
|
||||||
"out_for_calibration": u.out_for_calibration or False,
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
"note": u.note or "",
|
"note": u.note or "",
|
||||||
"project_id": (loc or {}).get("project_id") or u.project_id or "",
|
"project_id": u.project_id or "",
|
||||||
"address": (loc or {}).get("address") or "",
|
"address": u.address or u.location or "",
|
||||||
"coordinates": (loc or {}).get("coordinates") or "",
|
"coordinates": u.coordinates or "",
|
||||||
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import logging
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -86,7 +85,8 @@ async def get_modem_units(
|
|||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.ip_address.ilike(search_term)) |
|
(RosterUnit.ip_address.ilike(search_term)) |
|
||||||
(RosterUnit.hardware_model.ilike(search_term)) |
|
(RosterUnit.hardware_model.ilike(search_term)) |
|
||||||
(RosterUnit.phone_number.ilike(search_term))
|
(RosterUnit.phone_number.ilike(search_term)) |
|
||||||
|
(RosterUnit.location.ilike(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
modems = query.order_by(
|
modems = query.order_by(
|
||||||
@@ -128,8 +128,6 @@ async def get_modem_units(
|
|||||||
if filter_status and status != filter_status:
|
if filter_status and status != filter_status:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Inherit location from the paired device's active assignment.
|
|
||||||
loc = get_active_location(db, modem.id) if paired else None
|
|
||||||
modem_list.append({
|
modem_list.append({
|
||||||
"id": modem.id,
|
"id": modem.id,
|
||||||
"ip_address": modem.ip_address,
|
"ip_address": modem.ip_address,
|
||||||
@@ -137,8 +135,8 @@ async def get_modem_units(
|
|||||||
"hardware_model": modem.hardware_model,
|
"hardware_model": modem.hardware_model,
|
||||||
"deployed": modem.deployed,
|
"deployed": modem.deployed,
|
||||||
"retired": modem.retired,
|
"retired": modem.retired,
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
"location": modem.location,
|
||||||
"project_id": (loc or {}).get("project_id") or modem.project_id,
|
"project_id": modem.project_id,
|
||||||
"paired_device": paired,
|
"paired_device": paired,
|
||||||
"status": status
|
"status": status
|
||||||
})
|
})
|
||||||
@@ -167,15 +165,14 @@ async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if device:
|
if device:
|
||||||
loc = get_active_location(db, device.id)
|
|
||||||
return {
|
return {
|
||||||
"paired": True,
|
"paired": True,
|
||||||
"device": {
|
"device": {
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": (loc or {}).get("project_id") or device.project_id,
|
"project_id": device.project_id,
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or ""
|
"location": device.location or device.address
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +314,8 @@ async def get_pairable_devices(
|
|||||||
query = query.filter(
|
query = query.filter(
|
||||||
(RosterUnit.id.ilike(search_term)) |
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
(RosterUnit.project_id.ilike(search_term)) |
|
(RosterUnit.project_id.ilike(search_term)) |
|
||||||
|
(RosterUnit.location.ilike(search_term)) |
|
||||||
|
(RosterUnit.address.ilike(search_term)) |
|
||||||
(RosterUnit.note.ilike(search_term))
|
(RosterUnit.note.ilike(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -339,13 +338,12 @@ async def get_pairable_devices(
|
|||||||
if hide_paired and is_paired_to_other:
|
if hide_paired and is_paired_to_other:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
loc = get_active_location(db, device.id)
|
|
||||||
device_list.append({
|
device_list.append({
|
||||||
"id": device.id,
|
"id": device.id,
|
||||||
"device_type": device.device_type,
|
"device_type": device.device_type,
|
||||||
"deployed": device.deployed,
|
"deployed": device.deployed,
|
||||||
"project_id": (loc or {}).get("project_id") or device.project_id,
|
"project_id": device.project_id,
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
"location": device.location or device.address,
|
||||||
"note": device.note,
|
"note": device.note,
|
||||||
"paired_modem_id": device.deployed_with_modem_id,
|
"paired_modem_id": device.deployed_with_modem_id,
|
||||||
"is_paired_to_this": is_paired_to_this,
|
"is_paired_to_this": is_paired_to_this,
|
||||||
|
|||||||
@@ -1483,13 +1483,11 @@ async def get_available_units(
|
|||||||
).distinct().all()
|
).distinct().all()
|
||||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||||||
|
|
||||||
# These units have no active assignment by definition, so there's no
|
|
||||||
# current location to show — leave the field empty.
|
|
||||||
available_units = [
|
available_units = [
|
||||||
{
|
{
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"location": "",
|
"location": unit.address or unit.location,
|
||||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||||
"deployed": bool(unit.deployed),
|
"deployed": bool(unit.deployed),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from backend.database import get_db
|
|||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||||
import uuid
|
import uuid
|
||||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -183,6 +182,9 @@ async def add_roster_unit(
|
|||||||
out_for_calibration: str = Form(None),
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -247,6 +249,9 @@ async def add_roster_unit(
|
|||||||
out_for_calibration=out_for_calibration_bool,
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
location=location,
|
||||||
|
address=address,
|
||||||
|
coordinates=coordinates,
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated=last_cal_date,
|
last_calibrated=last_cal_date,
|
||||||
@@ -268,15 +273,19 @@ async def add_roster_unit(
|
|||||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-fill data from modem if pairing and fields are empty.
|
# Auto-fill data from modem if pairing and fields are empty
|
||||||
# Location/address/coordinates now come from MonitoringLocation via the
|
|
||||||
# active UnitAssignment, so there's nothing to copy from the modem row.
|
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
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:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
if not unit.note and modem.note:
|
||||||
@@ -484,8 +493,6 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail="Unit not found")
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
active_loc = get_active_location(db, unit_id)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"device_type": unit.device_type or "seismograph",
|
"device_type": unit.device_type or "seismograph",
|
||||||
@@ -497,11 +504,9 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"active_location": active_loc,
|
"location": unit.location or "",
|
||||||
# Convenience fields so the unit-detail page can read the same shape
|
"address": unit.address or "",
|
||||||
# whether or not there's an active assignment.
|
"coordinates": unit.coordinates or "",
|
||||||
"address": (active_loc or {}).get("address") or "",
|
|
||||||
"coordinates": (active_loc or {}).get("coordinates") or "",
|
|
||||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
"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 "",
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
@@ -533,6 +538,9 @@ async def edit_roster_unit(
|
|||||||
allocated_to_project_id: str = Form(None),
|
allocated_to_project_id: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
last_calibrated: str = Form(None),
|
last_calibrated: str = Form(None),
|
||||||
next_calibration_due: str = Form(None),
|
next_calibration_due: str = Form(None),
|
||||||
@@ -557,6 +565,8 @@ async def edit_roster_unit(
|
|||||||
cascade_deployed: str = Form(None),
|
cascade_deployed: str = Form(None),
|
||||||
cascade_retired: str = Form(None),
|
cascade_retired: str = Form(None),
|
||||||
cascade_project: str = Form(None),
|
cascade_project: str = Form(None),
|
||||||
|
cascade_location: str = Form(None),
|
||||||
|
cascade_coordinates: str = Form(None),
|
||||||
cascade_note: str = Form(None),
|
cascade_note: str = Form(None),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
@@ -610,6 +620,9 @@ async def edit_roster_unit(
|
|||||||
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
|
unit.location = location
|
||||||
|
unit.address = address
|
||||||
|
unit.coordinates = coordinates
|
||||||
unit.last_updated = datetime.utcnow()
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -617,15 +630,20 @@ async def edit_roster_unit(
|
|||||||
unit.next_calibration_due = next_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
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
# Auto-fill data from modem if pairing and fields are empty.
|
# Auto-fill data from modem if pairing and fields are empty
|
||||||
# Location/address/coordinates live on MonitoringLocation now, nothing
|
|
||||||
# to copy across roster rows.
|
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
RosterUnit.device_type == "modem"
|
RosterUnit.device_type == "modem"
|
||||||
).first()
|
).first()
|
||||||
if modem:
|
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:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
if not unit.note and modem.note:
|
if not unit.note and modem.note:
|
||||||
@@ -751,6 +769,26 @@ async def edit_roster_unit(
|
|||||||
record_history(db, paired_unit.id, "project_change", "project_id",
|
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||||
old_paired_project or "", project_id or "", f"cascade from {unit_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
|
# Cascade note
|
||||||
if cascade_note in ['true', 'True', '1', 'yes']:
|
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||||
old_paired_note = paired_unit.note
|
old_paired_note = paired_unit.note
|
||||||
@@ -973,8 +1011,9 @@ async def import_csv(
|
|||||||
- retired: Boolean
|
- retired: Boolean
|
||||||
- note: Notes about the unit
|
- note: Notes about the unit
|
||||||
- project_id: Project identifier
|
- project_id: Project identifier
|
||||||
(Location / address / coordinates are not roster fields anymore — they
|
- location: Location description
|
||||||
live on the MonitoringLocation a unit is assigned to.)
|
- address: Street address
|
||||||
|
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
||||||
|
|
||||||
Seismograph-specific:
|
Seismograph-specific:
|
||||||
- last_calibrated: Date (YYYY-MM-DD)
|
- last_calibrated: Date (YYYY-MM-DD)
|
||||||
@@ -1087,6 +1126,9 @@ async def import_csv(
|
|||||||
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
||||||
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
||||||
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
||||||
|
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
|
||||||
|
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
|
||||||
|
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
|
||||||
existing_unit.last_updated = datetime.utcnow()
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
# Seismograph-specific fields
|
# Seismograph-specific fields
|
||||||
@@ -1152,6 +1194,9 @@ async def import_csv(
|
|||||||
retired=_parse_bool(row.get('retired', '')),
|
retired=_parse_bool(row.get('retired', '')),
|
||||||
note=_get_csv_value(row, 'note', ''),
|
note=_get_csv_value(row, 'note', ''),
|
||||||
project_id=_get_csv_value(row, 'project_id'),
|
project_id=_get_csv_value(row, 'project_id'),
|
||||||
|
location=_get_csv_value(row, 'location'),
|
||||||
|
address=_get_csv_value(row, 'address'),
|
||||||
|
coordinates=_get_csv_value(row, 'coordinates'),
|
||||||
last_updated=datetime.utcnow(),
|
last_updated=datetime.utcnow(),
|
||||||
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
# Seismograph fields - auto-calc next_calibration_due from last_calibrated
|
||||||
last_calibrated=last_cal,
|
last_calibrated=last_cal,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from pathlib import Path
|
|||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
from backend.services.unit_location import bulk_active_locations
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -22,14 +21,11 @@ def export_roster_csv(db: Session = Depends(get_db)):
|
|||||||
"""Export all roster units to CSV"""
|
"""Export all roster units to CSV"""
|
||||||
units = db.query(RosterUnit).all()
|
units = db.query(RosterUnit).all()
|
||||||
|
|
||||||
# Create CSV in memory. Location lives on MonitoringLocation now, so
|
# Create CSV in memory
|
||||||
# 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()
|
output = io.StringIO()
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
||||||
'note', 'project_id',
|
'note', 'project_id', 'location', 'address', 'coordinates',
|
||||||
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
||||||
'ip_address', 'phone_number', 'hardware_model'
|
'ip_address', 'phone_number', 'hardware_model'
|
||||||
]
|
]
|
||||||
@@ -46,6 +42,9 @@ def export_roster_csv(db: Session = Depends(get_db)):
|
|||||||
'retired': 'true' if unit.retired else 'false',
|
'retired': 'true' if unit.retired else 'false',
|
||||||
'note': unit.note or '',
|
'note': unit.note or '',
|
||||||
'project_id': unit.project_id or '',
|
'project_id': unit.project_id or '',
|
||||||
|
'location': unit.location or '',
|
||||||
|
'address': unit.address or '',
|
||||||
|
'coordinates': unit.coordinates or '',
|
||||||
'last_calibrated': unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
'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 '',
|
'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 '',
|
'deployed_with_modem_id': unit.deployed_with_modem_id or '',
|
||||||
@@ -83,7 +82,6 @@ def get_table_stats(db: Session = Depends(get_db)):
|
|||||||
def get_all_roster_units(db: Session = Depends(get_db)):
|
def get_all_roster_units(db: Session = Depends(get_db)):
|
||||||
"""Get all roster units for management table"""
|
"""Get all roster units for management table"""
|
||||||
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
||||||
active_locs = bulk_active_locations(db, units)
|
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
@@ -92,10 +90,10 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": (active_locs.get(unit.id) or {}).get("project_id") or unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"address": (active_locs.get(unit.id) or {}).get("address") or "",
|
"location": unit.location or "",
|
||||||
"coordinates": (active_locs.get(unit.id) or {}).get("coordinates") or "",
|
"address": unit.address or "",
|
||||||
"location_name": (active_locs.get(unit.id) or {}).get("name") or "",
|
"coordinates": unit.coordinates or "",
|
||||||
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
"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,
|
"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 "",
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import os
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -59,14 +58,13 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||||
|
|
||||||
loc = get_active_location(db, unit_id)
|
|
||||||
return {
|
return {
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"device_type": "slm",
|
"device_type": "slm",
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"model": unit.slm_model or "NL-43",
|
"model": unit.slm_model or "NL-43",
|
||||||
"location": (loc or {}).get("address") or (loc or {}).get("name") or "",
|
"location": unit.address or unit.location,
|
||||||
"coordinates": (loc or {}).get("coordinates") or "",
|
"coordinates": unit.coordinates,
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"status": status_data,
|
"status": status_data,
|
||||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
|||||||
+16
-12
@@ -5,7 +5,6 @@ from typing import Dict, Any, Optional
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.services.unit_location import get_active_location
|
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
@@ -14,8 +13,7 @@ router = APIRouter(prefix="/api", tags=["units"])
|
|||||||
@router.get("/unit/{unit_id}")
|
@router.get("/unit/{unit_id}")
|
||||||
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Returns detailed data for a single unit, including its active deployment
|
Returns detailed data for a single unit.
|
||||||
location (or None if benched / unassigned).
|
|
||||||
"""
|
"""
|
||||||
snapshot = emit_status_snapshot()
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
@@ -23,7 +21,17 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
unit_data = snapshot["units"][unit_id]
|
unit_data = snapshot["units"][unit_id]
|
||||||
active_loc = get_active_location(db, unit_id)
|
|
||||||
|
# Mock coordinates for now (will be replaced with real data)
|
||||||
|
mock_coords = {
|
||||||
|
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
||||||
|
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
||||||
|
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
||||||
|
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
||||||
|
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
||||||
|
}
|
||||||
|
|
||||||
|
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit_id,
|
"id": unit_id,
|
||||||
@@ -33,7 +41,7 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"last_file": unit_data.get("fname", ""),
|
"last_file": unit_data.get("fname", ""),
|
||||||
"deployed": unit_data["deployed"],
|
"deployed": unit_data["deployed"],
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"active_location": active_loc,
|
"coordinates": coords
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -41,16 +49,12 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Get unit data directly from the roster (for settings/configuration).
|
Get unit data directly from the roster (for settings/configuration).
|
||||||
Address/coordinates come from the active MonitoringLocation, not the
|
|
||||||
roster row.
|
|
||||||
"""
|
"""
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
|
||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
active_loc = get_active_location(db, unit_id)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"unit_type": unit.unit_type,
|
"unit_type": unit.unit_type,
|
||||||
@@ -58,9 +62,9 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
"note": unit.note,
|
"note": unit.note,
|
||||||
"active_location": active_loc,
|
"location": unit.location,
|
||||||
"address": (active_loc or {}).get("address") or "",
|
"address": unit.address,
|
||||||
"coordinates": (active_loc or {}).get("coordinates") or "",
|
"coordinates": unit.coordinates,
|
||||||
"slm_host": unit.slm_host,
|
"slm_host": unit.slm_host,
|
||||||
"slm_tcp_port": unit.slm_tcp_port,
|
"slm_tcp_port": unit.slm_tcp_port,
|
||||||
"slm_ftp_port": unit.slm_ftp_port,
|
"slm_ftp_port": unit.slm_ftp_port,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from backend.database import get_db_session
|
from backend.database import get_db_session
|
||||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
from backend.services.unit_location import bulk_active_locations
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -138,10 +137,6 @@ def emit_status_snapshot():
|
|||||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||||
|
|
||||||
# Active-assignment location lookup for all roster units (direct only;
|
|
||||||
# modems inherit from their paired device below in the derive loop).
|
|
||||||
active_locs = bulk_active_locations(db, list(roster.values()))
|
|
||||||
|
|
||||||
# SFM event-forwards are now the primary "last seen" signal for
|
# SFM event-forwards are now the primary "last seen" signal for
|
||||||
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
# seismographs. Watcher heartbeats stay as a backup — if SFM is down
|
||||||
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
# or hasn't seen a serial, we fall back to Emitter.last_seen.
|
||||||
@@ -230,13 +225,10 @@ def emit_status_snapshot():
|
|||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
# Location for mapping — sourced from active UnitAssignment
|
# Location for mapping
|
||||||
# → MonitoringLocation. Empty for benched / unassigned.
|
"location": r.location or "",
|
||||||
"address": (active_locs.get(unit_id) or {}).get("address") or "",
|
"address": r.address or "",
|
||||||
"coordinates": (active_locs.get(unit_id) or {}).get("coordinates") or "",
|
"coordinates": r.coordinates or "",
|
||||||
"location_name": (active_locs.get(unit_id) or {}).get("name") or "",
|
|
||||||
"project_id": (active_locs.get(unit_id) or {}).get("project_id") or "",
|
|
||||||
"location_id": (active_locs.get(unit_id) or {}).get("location_id") or "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Add unexpected emitter-only units ---
|
# --- Add unexpected emitter-only units ---
|
||||||
@@ -275,12 +267,10 @@ def emit_status_snapshot():
|
|||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
# Location fields — unknown units have no assignment
|
# Location fields
|
||||||
|
"location": "",
|
||||||
"address": "",
|
"address": "",
|
||||||
"coordinates": "",
|
"coordinates": "",
|
||||||
"location_name": "",
|
|
||||||
"project_id": "",
|
|
||||||
"location_id": "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Derive modem status from paired devices ---
|
# --- Derive modem status from paired devices ---
|
||||||
@@ -311,11 +301,6 @@ def emit_status_snapshot():
|
|||||||
unit_data["last"] = paired_unit.get("last")
|
unit_data["last"] = paired_unit.get("last")
|
||||||
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
unit_data["last_seen_source"] = paired_unit.get("last_seen_source", "none")
|
||||||
unit_data["derived_from"] = paired_unit_id
|
unit_data["derived_from"] = paired_unit_id
|
||||||
# Inherit deployment location too — modems don't carry
|
|
||||||
# their own UnitAssignment.
|
|
||||||
for k in ("address", "coordinates", "location_name", "project_id", "location_id"):
|
|
||||||
if not unit_data.get(k):
|
|
||||||
unit_data[k] = paired_unit.get(k, "")
|
|
||||||
|
|
||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
"""
|
|
||||||
Active-assignment location resolution for roster units.
|
|
||||||
|
|
||||||
`RosterUnit.location`, `.address`, `.coordinates` are legacy per-unit fields.
|
|
||||||
The current source of truth for "where is this unit deployed right now" is the
|
|
||||||
active `UnitAssignment` (assigned_until IS NULL) pointing at a
|
|
||||||
`MonitoringLocation`, which carries the canonical address/coordinates/name.
|
|
||||||
|
|
||||||
Modems don't get their own `UnitAssignment` — they're paired with a
|
|
||||||
seismograph or SLM via `deployed_with_unit_id`. A deployed modem inherits the
|
|
||||||
location of its paired device's active assignment.
|
|
||||||
|
|
||||||
Returned dict shape (or None if no active assignment resolvable):
|
|
||||||
{
|
|
||||||
"location_id": "uuid",
|
|
||||||
"project_id": "uuid",
|
|
||||||
"name": "NRL-001",
|
|
||||||
"address": "123 Main St" | None,
|
|
||||||
"coordinates": "34.0522,-118.2437" | None,
|
|
||||||
"via_paired_unit_id": "BE1234" | None, # set only for modems
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from backend.models import MonitoringLocation, RosterUnit, UnitAssignment
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize(loc: MonitoringLocation, via_paired_unit_id: Optional[str] = None) -> dict:
|
|
||||||
return {
|
|
||||||
"location_id": loc.id,
|
|
||||||
"project_id": loc.project_id,
|
|
||||||
"name": loc.name,
|
|
||||||
"address": loc.address or None,
|
|
||||||
"coordinates": loc.coordinates or None,
|
|
||||||
"via_paired_unit_id": via_paired_unit_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _active_location_for_unit_id(db: Session, unit_id: str) -> Optional[MonitoringLocation]:
|
|
||||||
"""Return the MonitoringLocation tied to this unit's active assignment, if any."""
|
|
||||||
row = (
|
|
||||||
db.query(MonitoringLocation)
|
|
||||||
.join(UnitAssignment, UnitAssignment.location_id == MonitoringLocation.id)
|
|
||||||
.filter(
|
|
||||||
UnitAssignment.unit_id == unit_id,
|
|
||||||
UnitAssignment.assigned_until == None, # noqa: E711
|
|
||||||
)
|
|
||||||
.order_by(UnitAssignment.assigned_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_location(db: Session, unit_id: str) -> Optional[dict]:
|
|
||||||
"""
|
|
||||||
Resolve the active deployment location for a unit.
|
|
||||||
|
|
||||||
Seismographs / SLMs: their own active UnitAssignment.
|
|
||||||
Modems: follow `deployed_with_unit_id` to the paired device's active
|
|
||||||
assignment (modems don't carry their own assignment).
|
|
||||||
"""
|
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
|
||||||
if unit is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if (unit.device_type or "seismograph") == "modem":
|
|
||||||
paired_id = unit.deployed_with_unit_id
|
|
||||||
if not paired_id:
|
|
||||||
return None
|
|
||||||
loc = _active_location_for_unit_id(db, paired_id)
|
|
||||||
return _serialize(loc, via_paired_unit_id=paired_id) if loc else None
|
|
||||||
|
|
||||||
loc = _active_location_for_unit_id(db, unit_id)
|
|
||||||
return _serialize(loc) if loc else None
|
|
||||||
|
|
||||||
|
|
||||||
def bulk_active_locations(db: Session, units: list[RosterUnit]) -> dict[str, dict]:
|
|
||||||
"""
|
|
||||||
Resolve active locations for many units in two queries. Use this from
|
|
||||||
snapshot-style loops to avoid N+1 lookups.
|
|
||||||
|
|
||||||
Returns {unit_id: <serialized location dict>} — only populated for units
|
|
||||||
that resolve to an active assignment. Modems are resolved by walking
|
|
||||||
`deployed_with_unit_id` to the paired device's entry in the same map.
|
|
||||||
"""
|
|
||||||
if not units:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
direct_unit_ids = [
|
|
||||||
u.id for u in units
|
|
||||||
if (u.device_type or "seismograph") != "modem"
|
|
||||||
]
|
|
||||||
|
|
||||||
direct: dict[str, MonitoringLocation] = {}
|
|
||||||
if direct_unit_ids:
|
|
||||||
rows = (
|
|
||||||
db.query(UnitAssignment.unit_id, MonitoringLocation)
|
|
||||||
.join(MonitoringLocation, MonitoringLocation.id == UnitAssignment.location_id)
|
|
||||||
.filter(
|
|
||||||
UnitAssignment.unit_id.in_(direct_unit_ids),
|
|
||||||
UnitAssignment.assigned_until == None, # noqa: E711
|
|
||||||
)
|
|
||||||
.order_by(UnitAssignment.assigned_at.desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
# First row wins per unit_id (most recent assigned_at).
|
|
||||||
for unit_id, loc in rows:
|
|
||||||
direct.setdefault(unit_id, loc)
|
|
||||||
|
|
||||||
out: dict[str, dict] = {
|
|
||||||
uid: _serialize(loc) for uid, loc in direct.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Modems inherit from paired device.
|
|
||||||
for u in units:
|
|
||||||
if (u.device_type or "seismograph") != "modem":
|
|
||||||
continue
|
|
||||||
paired_id = u.deployed_with_unit_id
|
|
||||||
if paired_id and paired_id in direct:
|
|
||||||
out[u.id] = _serialize(direct[paired_id], via_paired_unit_id=paired_id)
|
|
||||||
|
|
||||||
return out
|
|
||||||
+51
-86
@@ -150,55 +150,46 @@ setInterval(_refreshPendingDeployBanner, 30000);
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 card-content" id="fleet-summary-content">
|
<div class="space-y-3 card-content" id="fleet-summary-content">
|
||||||
<!-- Seismographs -->
|
<div class="flex justify-between items-center">
|
||||||
<div>
|
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
|
||||||
|
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/seismographs" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||||
</div>
|
</div>
|
||||||
<span id="seismo-count" class="text-lg font-bold text-blue-600 dark:text-blue-400">--</span>
|
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
<div class="flex justify-between items-center mb-2">
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
|
||||||
<span id="seismo-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
|
||||||
<span id="seismo-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sound Level Meters -->
|
|
||||||
<div>
|
|
||||||
<div class="flex justify-between items-center mb-1.5">
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="/sound-level-meters" class="text-sm font-semibold text-gray-800 dark:text-gray-200 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||||
</div>
|
|
||||||
<span id="slm-count" class="text-lg font-bold text-purple-600 dark:text-purple-400">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="pl-6 flex flex-col gap-0.5 text-sm">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Deployed</span>
|
|
||||||
<span id="slm-deployed" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">Benched</span>
|
|
||||||
<span id="slm-benched" class="font-medium text-gray-800 dark:text-gray-200">--</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Call-in Status:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||||
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||||
@@ -637,14 +628,9 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||||
fleetMarkers = [];
|
fleetMarkers = [];
|
||||||
|
|
||||||
// Get deployed units with coordinates that pass the filter.
|
// Get deployed units with coordinates that pass the filter
|
||||||
// Modems are not plotted — they inherit the paired device's location,
|
|
||||||
// which would just stack a duplicate marker on the same pin.
|
|
||||||
const deployedUnits = Object.entries(allUnits || {})
|
const deployedUnits = Object.entries(allUnits || {})
|
||||||
.filter(([_, u]) => u.deployed
|
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
|
||||||
&& u.coordinates
|
|
||||||
&& (u.device_type || 'seismograph') !== 'modem'
|
|
||||||
&& unitPassesFilter(u));
|
|
||||||
|
|
||||||
if (deployedUnits.length === 0) {
|
if (deployedUnits.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -686,12 +672,10 @@ function updateFleetMapFiltered(allUnits) {
|
|||||||
// Popup with device type
|
// Popup with device type
|
||||||
const deviceLabel = getDeviceTypeLabel(deviceType);
|
const deviceLabel = getDeviceTypeLabel(deviceType);
|
||||||
|
|
||||||
const locName = unit.location_name || '';
|
|
||||||
marker.bindPopup(`
|
marker.bindPopup(`
|
||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
<h3 class="font-bold text-lg">${id}</h3>
|
<h3 class="font-bold text-lg">${id}</h3>
|
||||||
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
||||||
${locName ? `<p class="text-sm text-gray-700">📍 ${locName}</p>` : ''}
|
|
||||||
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||||
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
||||||
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
||||||
@@ -799,51 +783,32 @@ function updateDashboard(event) {
|
|||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Fleet Summary: per-device-type counts (always unfiltered) =====
|
// ===== Fleet summary numbers (always unfiltered) =====
|
||||||
// Deployed = unit has an active UnitAssignment (location_id set by
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
// the snapshot helper). Benched = no active assignment.
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
// Retired, out-for-calibration, and roster-unknown units (emitters
|
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||||
// not in the roster) are excluded from totals.
|
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
||||||
const counts = {
|
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||||
seismograph: { total: 0, deployed: 0, benched: 0 },
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
sound_level_meter: { total: 0, deployed: 0, benched: 0 },
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
};
|
|
||||||
let monitoredOk = 0, monitoredPending = 0, monitoredMissing = 0;
|
|
||||||
const unknownIds = new Set(Object.keys(data.unknown || {}));
|
|
||||||
|
|
||||||
Object.entries(data.units || {}).forEach(([uid, unit]) => {
|
// ===== Device type counts (always unfiltered) =====
|
||||||
if (unit.retired || unit.out_for_calibration) return;
|
let seismoCount = 0;
|
||||||
if (unknownIds.has(uid)) return;
|
let slmCount = 0;
|
||||||
const dt = unit.device_type || 'seismograph';
|
let modemCount = 0;
|
||||||
const bucket = counts[dt];
|
Object.values(data.units || {}).forEach(unit => {
|
||||||
if (!bucket) return; // skip modems and anything else
|
if (unit.retired) return; // Don't count retired units
|
||||||
|
const deviceType = unit.device_type || 'seismograph';
|
||||||
bucket.total++;
|
if (deviceType === 'seismograph') {
|
||||||
if (unit.location_id) {
|
seismoCount++;
|
||||||
bucket.deployed++;
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
} else {
|
slmCount++;
|
||||||
bucket.benched++;
|
} else if (deviceType === 'modem') {
|
||||||
}
|
modemCount++;
|
||||||
|
|
||||||
// Status tally only for seismographs + SLMs that are actually
|
|
||||||
// deployed (assigned). Mirrors the per-device buckets so the
|
|
||||||
// sum matches.
|
|
||||||
if (unit.location_id) {
|
|
||||||
if (unit.status === 'OK') monitoredOk++;
|
|
||||||
else if (unit.status === 'Pending') monitoredPending++;
|
|
||||||
else if (unit.status === 'Missing') monitoredMissing++;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
document.getElementById('seismo-count').textContent = seismoCount;
|
||||||
document.getElementById('seismo-count').textContent = counts.seismograph.total;
|
document.getElementById('slm-count').textContent = slmCount;
|
||||||
document.getElementById('seismo-deployed').textContent = counts.seismograph.deployed;
|
|
||||||
document.getElementById('seismo-benched').textContent = counts.seismograph.benched;
|
|
||||||
document.getElementById('slm-count').textContent = counts.sound_level_meter.total;
|
|
||||||
document.getElementById('slm-deployed').textContent = counts.sound_level_meter.deployed;
|
|
||||||
document.getElementById('slm-benched').textContent = counts.sound_level_meter.benched;
|
|
||||||
document.getElementById('status-ok').textContent = monitoredOk;
|
|
||||||
document.getElementById('status-pending').textContent = monitoredPending;
|
|
||||||
document.getElementById('status-missing').textContent = monitoredMissing;
|
|
||||||
|
|
||||||
// ===== Apply filters and render map + alerts =====
|
// ===== Apply filters and render map + alerts =====
|
||||||
renderFilteredDashboard(data);
|
renderFilteredDashboard(data);
|
||||||
|
|||||||
+35
-47
@@ -129,15 +129,6 @@
|
|||||||
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployment Location</label>
|
|
||||||
<p id="viewLocationContainer" class="mt-1">
|
|
||||||
<a id="viewLocationLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
||||||
<span id="viewLocationText">--</span>
|
|
||||||
</a>
|
|
||||||
<span id="viewLocationNoLink" class="text-gray-500 dark:text-gray-400 italic">Not deployed</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
@@ -648,12 +639,18 @@
|
|||||||
{% include "partials/project_picker.html" with context %}
|
{% include "partials/project_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Address / coordinates are managed on the project's
|
<!-- Address -->
|
||||||
MonitoringLocation, not the unit itself. Edit them on
|
<div>
|
||||||
the project page. -->
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
<div class="md:col-span-2 rounded-lg bg-gray-50 dark:bg-slate-700/50 border border-gray-200 dark:border-gray-700 p-3 text-sm text-gray-600 dark:text-gray-400">
|
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
|
||||||
Address & coordinates are set on the deployment location.
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
Open the project to edit them.
|
</div>
|
||||||
|
|
||||||
|
<!-- Coordinates -->
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||||
|
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -851,6 +848,16 @@
|
|||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
||||||
|
</label>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
@@ -1161,28 +1168,8 @@ function populateViewMode() {
|
|||||||
if (projectLink) projectLink.classList.add('hidden');
|
if (projectLink) projectLink.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deployment Location — comes from the active UnitAssignment →
|
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||||
// MonitoringLocation. Show project link if present, otherwise
|
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||||
// "Not deployed" placeholder.
|
|
||||||
const locLink = document.getElementById('viewLocationLink');
|
|
||||||
const locText = document.getElementById('viewLocationText');
|
|
||||||
const locNoLink = document.getElementById('viewLocationNoLink');
|
|
||||||
const activeLoc = currentUnit.active_location;
|
|
||||||
if (activeLoc && activeLoc.location_id) {
|
|
||||||
if (locText) locText.textContent = activeLoc.name || activeLoc.address || 'Active location';
|
|
||||||
if (locLink) {
|
|
||||||
locLink.href = `/projects/${activeLoc.project_id}`;
|
|
||||||
locLink.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
if (locNoLink) locNoLink.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
if (locLink) locLink.classList.add('hidden');
|
|
||||||
if (locNoLink) locNoLink.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Address / coordinates also come from the active assignment.
|
|
||||||
document.getElementById('viewAddress').textContent = (activeLoc && activeLoc.address) || '--';
|
|
||||||
document.getElementById('viewCoordinates').textContent = (activeLoc && activeLoc.coordinates) || '--';
|
|
||||||
|
|
||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||||
@@ -1340,6 +1327,8 @@ function populateEditForm() {
|
|||||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('address').value = currentUnit.address || '';
|
||||||
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||||
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
||||||
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||||
@@ -1620,13 +1609,8 @@ function initUnitMap() {
|
|||||||
// Update marker (can be called multiple times)
|
// Update marker (can be called multiple times)
|
||||||
updateMapMarker(lat, lon);
|
updateMapMarker(lat, lon);
|
||||||
|
|
||||||
// Update location text — prefer the assignment's location name, fall
|
// Update location text
|
||||||
// back to address, then coordinates.
|
|
||||||
const locationParts = [];
|
const locationParts = [];
|
||||||
const loc = currentUnit.active_location;
|
|
||||||
if (loc && loc.name) {
|
|
||||||
locationParts.push(loc.name);
|
|
||||||
}
|
|
||||||
if (currentUnit.address) {
|
if (currentUnit.address) {
|
||||||
locationParts.push(currentUnit.address);
|
locationParts.push(currentUnit.address);
|
||||||
}
|
}
|
||||||
@@ -1740,12 +1724,13 @@ async function uploadPhoto(file) {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
// Show success message with metadata info. Location is on the
|
// Show success message with metadata info
|
||||||
// assignment's MonitoringLocation now, so we just surface what GPS
|
|
||||||
// came in — the backend no longer mutates the unit row.
|
|
||||||
let message = 'Photo uploaded successfully!';
|
let message = 'Photo uploaded successfully!';
|
||||||
if (result.metadata && result.metadata.coordinates) {
|
if (result.metadata && result.metadata.coordinates) {
|
||||||
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
||||||
|
if (result.coordinates_updated) {
|
||||||
|
message += ' (Unit coordinates updated automatically)';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
message += ' No GPS data found in photo.';
|
message += ' No GPS data found in photo.';
|
||||||
}
|
}
|
||||||
@@ -1753,8 +1738,11 @@ async function uploadPhoto(file) {
|
|||||||
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||||
statusDiv.textContent = message;
|
statusDiv.textContent = message;
|
||||||
|
|
||||||
// Reload photos
|
// Reload photos and unit data
|
||||||
await loadPhotos();
|
await loadPhotos();
|
||||||
|
if (result.coordinates_updated) {
|
||||||
|
await loadUnitData();
|
||||||
|
}
|
||||||
|
|
||||||
// Hide status after 5 seconds
|
// Hide status after 5 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user