Compare commits
2 Commits
da4e5f66c5
...
8e292b1aca
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e292b1aca | |||
| 7516bbea70 |
@@ -18,7 +18,7 @@ from backend.models import (
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
ScheduledAction,
|
||||
RecordingSession,
|
||||
MonitoringSession,
|
||||
DataFile,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
@@ -312,7 +312,7 @@ async def nrl_detail_page(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""NRL (Noise Recording Location) detail page with tabs"""
|
||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile
|
||||
from sqlalchemy import and_
|
||||
|
||||
# Get project
|
||||
@@ -348,23 +348,24 @@ async def nrl_detail_page(
|
||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
|
||||
# Get session count
|
||||
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
||||
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
||||
|
||||
# Get file count (DataFile links to session, not directly to location)
|
||||
file_count = db.query(DataFile).join(
|
||||
RecordingSession,
|
||||
DataFile.session_id == RecordingSession.id
|
||||
).filter(RecordingSession.location_id == location_id).count()
|
||||
MonitoringSession,
|
||||
DataFile.session_id == MonitoringSession.id
|
||||
).filter(MonitoringSession.location_id == location_id).count()
|
||||
|
||||
# Check for active session
|
||||
active_session = db.query(RecordingSession).filter(
|
||||
active_session = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == location_id,
|
||||
RecordingSession.status == "recording"
|
||||
MonitoringSession.location_id == location_id,
|
||||
MonitoringSession.status == "recording"
|
||||
)
|
||||
).first()
|
||||
|
||||
return templates.TemplateResponse("nrl_detail.html", {
|
||||
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
|
||||
return templates.TemplateResponse(template, {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"location_id": location_id,
|
||||
|
||||
54
backend/migrate_rename_recording_to_monitoring_sessions.py
Normal file
54
backend/migrate_rename_recording_to_monitoring_sessions.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Migration: Rename recording_sessions table to monitoring_sessions
|
||||
|
||||
Renames the table and updates the model name from RecordingSession to MonitoringSession.
|
||||
Run once per database: python backend/migrate_rename_recording_to_monitoring_sessions.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def migrate(db_path: str):
|
||||
"""Run the migration."""
|
||||
print(f"Migrating database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='recording_sessions'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_sessions'")
|
||||
if cursor.fetchone():
|
||||
print("monitoring_sessions table already exists. Skipping migration.")
|
||||
else:
|
||||
print("recording_sessions table does not exist. Skipping migration.")
|
||||
return
|
||||
|
||||
print("Renaming recording_sessions -> monitoring_sessions...")
|
||||
cursor.execute("ALTER TABLE recording_sessions RENAME TO monitoring_sessions")
|
||||
|
||||
conn.commit()
|
||||
print("Migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
db_path = "./data/terra-view.db"
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
db_path = sys.argv[1]
|
||||
|
||||
if not Path(db_path).exists():
|
||||
print(f"Database not found: {db_path}")
|
||||
sys.exit(1)
|
||||
|
||||
migrate(db_path)
|
||||
@@ -245,17 +245,17 @@ class ScheduledAction(Base):
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class RecordingSession(Base):
|
||||
class MonitoringSession(Base):
|
||||
"""
|
||||
Recording sessions: tracks actual monitoring sessions.
|
||||
Created when recording starts, updated when it stops.
|
||||
Monitoring sessions: tracks actual monitoring sessions.
|
||||
Created when monitoring starts, updated when it stops.
|
||||
"""
|
||||
__tablename__ = "recording_sessions"
|
||||
__tablename__ = "monitoring_sessions"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
|
||||
|
||||
session_type = Column(String, nullable=False) # sound | vibration
|
||||
started_at = Column(DateTime, nullable=False)
|
||||
@@ -278,7 +278,7 @@ class DataFile(Base):
|
||||
__tablename__ = "data_files"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id
|
||||
session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id
|
||||
|
||||
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
||||
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
||||
|
||||
@@ -14,6 +14,12 @@ from typing import Optional
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import UploadFile, File
|
||||
import zipfile
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
Project,
|
||||
@@ -21,7 +27,8 @@ from backend.models import (
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
RosterUnit,
|
||||
RecordingSession,
|
||||
MonitoringSession,
|
||||
DataFile,
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
|
||||
@@ -70,8 +77,8 @@ async def get_project_locations(
|
||||
if assignment:
|
||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
|
||||
# Count recording sessions
|
||||
session_count = db.query(RecordingSession).filter_by(
|
||||
# Count monitoring sessions
|
||||
session_count = db.query(MonitoringSession).filter_by(
|
||||
location_id=location.id
|
||||
).count()
|
||||
|
||||
@@ -370,19 +377,19 @@ async def unassign_unit(
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Check if there are active recording sessions
|
||||
active_sessions = db.query(RecordingSession).filter(
|
||||
# Check if there are active monitoring sessions
|
||||
active_sessions = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == assignment.location_id,
|
||||
RecordingSession.unit_id == assignment.unit_id,
|
||||
RecordingSession.status == "recording",
|
||||
MonitoringSession.location_id == assignment.location_id,
|
||||
MonitoringSession.unit_id == assignment.unit_id,
|
||||
MonitoringSession.status == "recording",
|
||||
)
|
||||
).count()
|
||||
|
||||
if active_sessions > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot unassign unit with active recording sessions. Stop recording first.",
|
||||
detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
|
||||
)
|
||||
|
||||
assignment.status = "completed"
|
||||
@@ -451,14 +458,12 @@ async def get_nrl_sessions(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get recording sessions for a specific NRL.
|
||||
Get monitoring sessions for a specific NRL.
|
||||
Returns HTML partial with session list.
|
||||
"""
|
||||
from backend.models import RecordingSession, RosterUnit
|
||||
|
||||
sessions = db.query(RecordingSession).filter_by(
|
||||
sessions = db.query(MonitoringSession).filter_by(
|
||||
location_id=location_id
|
||||
).order_by(RecordingSession.started_at.desc()).all()
|
||||
).order_by(MonitoringSession.started_at.desc()).all()
|
||||
|
||||
# Enrich with unit details
|
||||
sessions_data = []
|
||||
@@ -491,14 +496,12 @@ async def get_nrl_files(
|
||||
Get data files for a specific NRL.
|
||||
Returns HTML partial with file list.
|
||||
"""
|
||||
from backend.models import DataFile, RecordingSession
|
||||
|
||||
# Join DataFile with RecordingSession to filter by location_id
|
||||
# Join DataFile with MonitoringSession to filter by location_id
|
||||
files = db.query(DataFile).join(
|
||||
RecordingSession,
|
||||
DataFile.session_id == RecordingSession.id
|
||||
MonitoringSession,
|
||||
DataFile.session_id == MonitoringSession.id
|
||||
).filter(
|
||||
RecordingSession.location_id == location_id
|
||||
MonitoringSession.location_id == location_id
|
||||
).order_by(DataFile.created_at.desc()).all()
|
||||
|
||||
# Enrich with session details
|
||||
@@ -506,7 +509,7 @@ async def get_nrl_files(
|
||||
for file in files:
|
||||
session = None
|
||||
if file.session_id:
|
||||
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
|
||||
|
||||
files_data.append({
|
||||
"file": file,
|
||||
@@ -519,3 +522,217 @@ async def get_nrl_files(
|
||||
"location_id": location_id,
|
||||
"files": files_data,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manual SD Card Data Upload
|
||||
# ============================================================================
|
||||
|
||||
def _parse_rnh(content: bytes) -> dict:
|
||||
"""
|
||||
Parse a Rion .rnh metadata file (INI-style with [Section] headers).
|
||||
Returns a dict of key metadata fields.
|
||||
"""
|
||||
result = {}
|
||||
try:
|
||||
text = content.decode("utf-8", errors="replace")
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("["):
|
||||
continue
|
||||
if "," in line:
|
||||
key, _, value = line.partition(",")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if key == "Serial Number":
|
||||
result["serial_number"] = value
|
||||
elif key == "Store Name":
|
||||
result["store_name"] = value
|
||||
elif key == "Index Number":
|
||||
result["index_number"] = value
|
||||
elif key == "Measurement Start Time":
|
||||
result["start_time_str"] = value
|
||||
elif key == "Measurement Stop Time":
|
||||
result["stop_time_str"] = value
|
||||
elif key == "Total Measurement Time":
|
||||
result["total_time_str"] = value
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _parse_rnh_datetime(s: str):
|
||||
"""Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime"""
|
||||
from datetime import datetime
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _classify_file(filename: str) -> str:
|
||||
"""Classify a file by name into a DataFile file_type."""
|
||||
name = filename.lower()
|
||||
if name.endswith(".rnh"):
|
||||
return "log"
|
||||
if name.endswith(".rnd"):
|
||||
return "measurement"
|
||||
if name.endswith(".zip"):
|
||||
return "archive"
|
||||
return "data"
|
||||
|
||||
|
||||
@router.post("/nrl/{location_id}/upload-data")
|
||||
async def upload_nrl_data(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
files: list[UploadFile] = File(...),
|
||||
):
|
||||
"""
|
||||
Manually upload SD card data for an offline NRL.
|
||||
|
||||
Accepts either:
|
||||
- A single .zip file (the Auto_#### folder zipped) — auto-extracted
|
||||
- Multiple .rnd / .rnh files selected directly from the SD card folder
|
||||
|
||||
Creates a MonitoringSession from .rnh metadata and DataFile records
|
||||
for each measurement file. No unit assignment required.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Verify project and location exist
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id, project_id=project_id
|
||||
).first()
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
# --- Step 1: Normalize to (filename, bytes) list ---
|
||||
file_entries: list[tuple[str, bytes]] = []
|
||||
|
||||
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
|
||||
raw = await files[0].read()
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
name = Path(info.filename).name # strip folder path
|
||||
if not name:
|
||||
continue
|
||||
file_entries.append((name, zf.read(info)))
|
||||
except zipfile.BadZipFile:
|
||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
|
||||
else:
|
||||
for uf in files:
|
||||
data = await uf.read()
|
||||
file_entries.append((uf.filename, data))
|
||||
|
||||
if not file_entries:
|
||||
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
||||
|
||||
# --- Step 2: Find and parse .rnh metadata ---
|
||||
rnh_meta = {}
|
||||
for fname, fbytes in file_entries:
|
||||
if fname.lower().endswith(".rnh"):
|
||||
rnh_meta = _parse_rnh(fbytes)
|
||||
break
|
||||
|
||||
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||
duration_seconds = None
|
||||
if started_at and stopped_at:
|
||||
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||
|
||||
store_name = rnh_meta.get("store_name", "")
|
||||
serial_number = rnh_meta.get("serial_number", "")
|
||||
index_number = rnh_meta.get("index_number", "")
|
||||
|
||||
# --- Step 3: Create MonitoringSession ---
|
||||
session_id = str(uuid.uuid4())
|
||||
monitoring_session = MonitoringSession(
|
||||
id=session_id,
|
||||
project_id=project_id,
|
||||
location_id=location_id,
|
||||
unit_id=None,
|
||||
session_type="sound",
|
||||
started_at=started_at,
|
||||
stopped_at=stopped_at,
|
||||
duration_seconds=duration_seconds,
|
||||
status="completed",
|
||||
session_metadata=json.dumps({
|
||||
"source": "manual_upload",
|
||||
"store_name": store_name,
|
||||
"serial_number": serial_number,
|
||||
"index_number": index_number,
|
||||
}),
|
||||
)
|
||||
db.add(monitoring_session)
|
||||
db.commit()
|
||||
db.refresh(monitoring_session)
|
||||
|
||||
# --- Step 4: Write files to disk and create DataFile records ---
|
||||
output_dir = Path("data/Projects") / project_id / session_id
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
leq_count = 0
|
||||
lp_count = 0
|
||||
metadata_count = 0
|
||||
files_imported = 0
|
||||
|
||||
for fname, fbytes in file_entries:
|
||||
file_type = _classify_file(fname)
|
||||
fname_lower = fname.lower()
|
||||
|
||||
# Track counts for summary
|
||||
if fname_lower.endswith(".rnd"):
|
||||
if "_leq_" in fname_lower:
|
||||
leq_count += 1
|
||||
elif "_lp" in fname_lower:
|
||||
lp_count += 1
|
||||
elif fname_lower.endswith(".rnh"):
|
||||
metadata_count += 1
|
||||
|
||||
# Write to disk
|
||||
dest = output_dir / fname
|
||||
dest.write_bytes(fbytes)
|
||||
|
||||
# Compute checksum
|
||||
checksum = hashlib.sha256(fbytes).hexdigest()
|
||||
|
||||
# Store relative path from data/ dir
|
||||
rel_path = str(dest.relative_to("data"))
|
||||
|
||||
data_file = DataFile(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
file_path=rel_path,
|
||||
file_type=file_type,
|
||||
file_size_bytes=len(fbytes),
|
||||
downloaded_at=datetime.utcnow(),
|
||||
checksum=checksum,
|
||||
file_metadata=json.dumps({
|
||||
"source": "manual_upload",
|
||||
"original_filename": fname,
|
||||
"store_name": store_name,
|
||||
}),
|
||||
)
|
||||
db.add(data_file)
|
||||
files_imported += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session_id": session_id,
|
||||
"files_imported": files_imported,
|
||||
"leq_files": leq_count,
|
||||
"lp_files": lp_count,
|
||||
"metadata_files": metadata_count,
|
||||
"store_name": store_name,
|
||||
"started_at": started_at.isoformat() if started_at else None,
|
||||
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ from backend.models import (
|
||||
ProjectType,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
RecordingSession,
|
||||
MonitoringSession,
|
||||
ScheduledAction,
|
||||
RecurringSchedule,
|
||||
RosterUnit,
|
||||
@@ -89,10 +89,10 @@ async def get_projects_list(
|
||||
).scalar()
|
||||
|
||||
# Count active sessions
|
||||
active_session_count = db.query(func.count(RecordingSession.id)).filter(
|
||||
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
|
||||
and_(
|
||||
RecordingSession.project_id == project.id,
|
||||
RecordingSession.status == "recording",
|
||||
MonitoringSession.project_id == project.id,
|
||||
MonitoringSession.status == "recording",
|
||||
)
|
||||
).scalar()
|
||||
|
||||
@@ -135,7 +135,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
||||
).scalar()
|
||||
|
||||
# Count active recording sessions
|
||||
active_sessions = db.query(func.count(RecordingSession.id)).filter_by(
|
||||
active_sessions = db.query(func.count(MonitoringSession.id)).filter_by(
|
||||
status="recording"
|
||||
).scalar()
|
||||
|
||||
@@ -410,7 +410,7 @@ async def permanently_delete_project(project_id: str, db: Session = Depends(get_
|
||||
# Delete related data
|
||||
db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
|
||||
db.query(ScheduledAction).filter_by(project_id=project_id).delete()
|
||||
db.query(RecordingSession).filter_by(project_id=project_id).delete()
|
||||
db.query(MonitoringSession).filter_by(project_id=project_id).delete()
|
||||
db.query(UnitAssignment).filter_by(project_id=project_id).delete()
|
||||
db.query(MonitoringLocation).filter_by(project_id=project_id).delete()
|
||||
db.delete(project)
|
||||
@@ -501,18 +501,18 @@ async def get_project_dashboard(
|
||||
})
|
||||
|
||||
# Get active recording sessions
|
||||
active_sessions = db.query(RecordingSession).filter(
|
||||
active_sessions = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
RecordingSession.project_id == project_id,
|
||||
RecordingSession.status == "recording",
|
||||
MonitoringSession.project_id == project_id,
|
||||
MonitoringSession.status == "recording",
|
||||
)
|
||||
).all()
|
||||
|
||||
# Get completed sessions count
|
||||
completed_sessions_count = db.query(func.count(RecordingSession.id)).filter(
|
||||
completed_sessions_count = db.query(func.count(MonitoringSession.id)).filter(
|
||||
and_(
|
||||
RecordingSession.project_id == project_id,
|
||||
RecordingSession.status == "completed",
|
||||
MonitoringSession.project_id == project_id,
|
||||
MonitoringSession.status == "completed",
|
||||
)
|
||||
).scalar()
|
||||
|
||||
@@ -591,26 +591,26 @@ async def get_project_units(
|
||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||
|
||||
# Count sessions for this assignment
|
||||
session_count = db.query(func.count(RecordingSession.id)).filter_by(
|
||||
session_count = db.query(func.count(MonitoringSession.id)).filter_by(
|
||||
location_id=assignment.location_id,
|
||||
unit_id=assignment.unit_id,
|
||||
).scalar()
|
||||
|
||||
# Count files from sessions
|
||||
file_count = db.query(func.count(DataFile.id)).join(
|
||||
RecordingSession,
|
||||
DataFile.session_id == RecordingSession.id
|
||||
MonitoringSession,
|
||||
DataFile.session_id == MonitoringSession.id
|
||||
).filter(
|
||||
RecordingSession.location_id == assignment.location_id,
|
||||
RecordingSession.unit_id == assignment.unit_id,
|
||||
MonitoringSession.location_id == assignment.location_id,
|
||||
MonitoringSession.unit_id == assignment.unit_id,
|
||||
).scalar()
|
||||
|
||||
# Check if currently recording
|
||||
active_session = db.query(RecordingSession).filter(
|
||||
active_session = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == assignment.location_id,
|
||||
RecordingSession.unit_id == assignment.unit_id,
|
||||
RecordingSession.status == "recording",
|
||||
MonitoringSession.location_id == assignment.location_id,
|
||||
MonitoringSession.unit_id == assignment.unit_id,
|
||||
MonitoringSession.status == "recording",
|
||||
)
|
||||
).first()
|
||||
|
||||
@@ -797,13 +797,13 @@ async def get_project_sessions(
|
||||
Returns HTML partial with session list.
|
||||
Optional status filter: recording, completed, paused, failed
|
||||
"""
|
||||
query = db.query(RecordingSession).filter_by(project_id=project_id)
|
||||
query = db.query(MonitoringSession).filter_by(project_id=project_id)
|
||||
|
||||
# Filter by status if provided
|
||||
if status:
|
||||
query = query.filter(RecordingSession.status == status)
|
||||
query = query.filter(MonitoringSession.status == status)
|
||||
|
||||
sessions = query.order_by(RecordingSession.started_at.desc()).all()
|
||||
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
|
||||
|
||||
# Enrich with unit and location details
|
||||
sessions_data = []
|
||||
@@ -895,18 +895,18 @@ async def ftp_download_to_server(
|
||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
||||
|
||||
# Get or create active session for this location/unit
|
||||
session = db.query(RecordingSession).filter(
|
||||
session = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
RecordingSession.project_id == project_id,
|
||||
RecordingSession.location_id == location_id,
|
||||
RecordingSession.unit_id == unit_id,
|
||||
RecordingSession.status.in_(["recording", "paused"])
|
||||
MonitoringSession.project_id == project_id,
|
||||
MonitoringSession.location_id == location_id,
|
||||
MonitoringSession.unit_id == unit_id,
|
||||
MonitoringSession.status.in_(["recording", "paused"])
|
||||
)
|
||||
).first()
|
||||
|
||||
# If no active session, create one
|
||||
if not session:
|
||||
session = RecordingSession(
|
||||
session = MonitoringSession(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_id=location_id,
|
||||
@@ -1060,18 +1060,18 @@ async def ftp_download_folder_to_server(
|
||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
||||
|
||||
# Get or create active session for this location/unit
|
||||
session = db.query(RecordingSession).filter(
|
||||
session = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
RecordingSession.project_id == project_id,
|
||||
RecordingSession.location_id == location_id,
|
||||
RecordingSession.unit_id == unit_id,
|
||||
RecordingSession.status.in_(["recording", "paused"])
|
||||
MonitoringSession.project_id == project_id,
|
||||
MonitoringSession.location_id == location_id,
|
||||
MonitoringSession.unit_id == unit_id,
|
||||
MonitoringSession.status.in_(["recording", "paused"])
|
||||
)
|
||||
).first()
|
||||
|
||||
# If no active session, create one
|
||||
if not session:
|
||||
session = RecordingSession(
|
||||
session = MonitoringSession(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_id=location_id,
|
||||
@@ -1231,9 +1231,9 @@ async def get_unified_files(
|
||||
import json
|
||||
|
||||
# Get all sessions for this project
|
||||
sessions = db.query(RecordingSession).filter_by(
|
||||
sessions = db.query(MonitoringSession).filter_by(
|
||||
project_id=project_id
|
||||
).order_by(RecordingSession.started_at.desc()).all()
|
||||
).order_by(MonitoringSession.started_at.desc()).all()
|
||||
|
||||
sessions_data = []
|
||||
for session in sessions:
|
||||
@@ -1310,7 +1310,7 @@ async def download_project_file(
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Verify file belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
@@ -1344,7 +1344,7 @@ async def download_session_files(
|
||||
import zipfile
|
||||
|
||||
# Verify session belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.project_id != project_id:
|
||||
@@ -1412,7 +1412,7 @@ async def delete_project_file(
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Verify file belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
@@ -1442,7 +1442,7 @@ async def delete_session(
|
||||
from pathlib import Path
|
||||
|
||||
# Verify session belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.project_id != project_id:
|
||||
@@ -1491,7 +1491,7 @@ async def view_rnd_file(
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Verify file belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
@@ -1557,7 +1557,7 @@ async def get_rnd_data(
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Verify file belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
@@ -1695,7 +1695,7 @@ async def generate_excel_report(
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Verify file belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
@@ -2101,7 +2101,7 @@ async def preview_report_data(
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# Verify file belongs to this project
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
@@ -2309,7 +2309,7 @@ async def generate_report_from_preview(
|
||||
if not file_record:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||
if not session or session.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||
|
||||
@@ -2471,7 +2471,7 @@ async def generate_combined_excel_report(
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Get all sessions with measurement files
|
||||
sessions = db.query(RecordingSession).filter_by(project_id=project_id).all()
|
||||
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||
|
||||
# Collect all Leq RND files grouped by location
|
||||
# Only include files with '_Leq_' in the path (15-minute averaged data)
|
||||
|
||||
@@ -92,15 +92,15 @@ async def rename_unit(
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update unit_assignments: {e}")
|
||||
|
||||
# Update recording_sessions table (if exists)
|
||||
# Update monitoring_sessions table (if exists)
|
||||
try:
|
||||
from backend.models import RecordingSession
|
||||
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
|
||||
from backend.models import MonitoringSession
|
||||
db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update(
|
||||
{"unit_id": new_id},
|
||||
synchronize_session=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update recording_sessions: {e}")
|
||||
logger.warning(f"Could not update monitoring_sessions: {e}")
|
||||
|
||||
# Commit all changes
|
||||
db.commit()
|
||||
|
||||
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
|
||||
from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule
|
||||
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
||||
from backend.services.alert_service import get_alert_service
|
||||
import uuid
|
||||
@@ -272,7 +272,7 @@ class SchedulerService:
|
||||
)
|
||||
|
||||
# Create recording session
|
||||
session = RecordingSession(
|
||||
session = MonitoringSession(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=action.project_id,
|
||||
location_id=action.location_id,
|
||||
@@ -336,11 +336,11 @@ class SchedulerService:
|
||||
)
|
||||
|
||||
# Find and update the active recording session
|
||||
active_session = db.query(RecordingSession).filter(
|
||||
active_session = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == action.location_id,
|
||||
RecordingSession.unit_id == unit_id,
|
||||
RecordingSession.status == "recording",
|
||||
MonitoringSession.location_id == action.location_id,
|
||||
MonitoringSession.unit_id == unit_id,
|
||||
MonitoringSession.status == "recording",
|
||||
)
|
||||
).first()
|
||||
|
||||
@@ -617,11 +617,11 @@ class SchedulerService:
|
||||
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
||||
|
||||
# Close out the old recording session
|
||||
active_session = db.query(RecordingSession).filter(
|
||||
active_session = db.query(MonitoringSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == action.location_id,
|
||||
RecordingSession.unit_id == unit_id,
|
||||
RecordingSession.status == "recording",
|
||||
MonitoringSession.location_id == action.location_id,
|
||||
MonitoringSession.unit_id == unit_id,
|
||||
MonitoringSession.status == "recording",
|
||||
)
|
||||
).first()
|
||||
|
||||
@@ -648,7 +648,7 @@ class SchedulerService:
|
||||
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
||||
|
||||
# Create new recording session
|
||||
new_session = RecordingSession(
|
||||
new_session = MonitoringSession(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=action.project_id,
|
||||
location_id=action.location_id,
|
||||
|
||||
@@ -90,14 +90,14 @@ def rename_unit(old_id: str, new_id: str):
|
||||
except Exception:
|
||||
pass # Table may not exist
|
||||
|
||||
# Update recording_sessions table (if exists)
|
||||
# Update monitoring_sessions table (if exists)
|
||||
try:
|
||||
result = session.execute(
|
||||
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||
text("UPDATE monitoring_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||
{"new_id": new_id, "old_id": old_id}
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
|
||||
print(f" ✓ Updated monitoring_sessions ({result.rowcount} rows)")
|
||||
except Exception:
|
||||
pass # Table may not exist
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<button onclick="switchTab('sessions')"
|
||||
data-tab="sessions"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Recording Sessions
|
||||
Monitoring Sessions
|
||||
</button>
|
||||
<button onclick="switchTab('data')"
|
||||
data-tab="data"
|
||||
@@ -302,11 +302,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recording Sessions Tab -->
|
||||
<!-- Monitoring Sessions Tab -->
|
||||
<div id="sessions-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||
{% if assigned_unit %}
|
||||
<button onclick="openScheduleModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
@@ -329,8 +329,40 @@
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span class="font-medium">{{ file_count }}</span> files
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500"><span class="font-medium">{{ file_count }}</span> files</span>
|
||||
<button onclick="toggleUploadPanel()"
|
||||
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
Upload Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Panel -->
|
||||
<div id="upload-panel" class="hidden mb-6 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload SD Card Data</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Select a ZIP file, or select all files from inside an <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">Auto_####</code> folder. File types (.rnd, .rnh) are auto-detected.
|
||||
</p>
|
||||
<input type="file" id="upload-input" multiple
|
||||
accept=".zip,.rnd,.rnh,.RND,.RNH"
|
||||
class="block w-full text-sm text-gray-500 dark:text-gray-400
|
||||
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<button onclick="submitUpload()"
|
||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||
Import Files
|
||||
</button>
|
||||
<button onclick="toggleUploadPanel()"
|
||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<span id="upload-status" class="text-sm hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -559,5 +591,64 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||
closeAssignModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Upload Data ─────────────────────────────────────────────────────────────
|
||||
|
||||
function toggleUploadPanel() {
|
||||
const panel = document.getElementById('upload-panel');
|
||||
const status = document.getElementById('upload-status');
|
||||
panel.classList.toggle('hidden');
|
||||
// Reset status when reopening
|
||||
if (!panel.classList.contains('hidden')) {
|
||||
status.textContent = '';
|
||||
status.className = 'text-sm hidden';
|
||||
document.getElementById('upload-input').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
const input = document.getElementById('upload-input');
|
||||
const status = document.getElementById('upload-status');
|
||||
|
||||
if (!input.files.length) {
|
||||
alert('Please select files to upload.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of input.files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
status.textContent = 'Uploading\u2026';
|
||||
status.className = 'text-sm text-gray-500';
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
||||
if (data.leq_files || data.lp_files) {
|
||||
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
||||
}
|
||||
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
||||
status.textContent = parts.join(' ');
|
||||
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||
input.value = '';
|
||||
// Refresh the file list
|
||||
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
||||
} else {
|
||||
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = `Error: ${err.message}`;
|
||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -151,9 +151,9 @@
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||
Files will appear here once they are downloaded from the sound level meter
|
||||
Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Schedule a single recording session with a specific start and end time.
|
||||
Schedule a single monitoring session with a specific start and end time.
|
||||
Duration can be between 15 minutes and 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Recording Sessions List -->
|
||||
<!-- Monitoring Sessions List -->
|
||||
{% if sessions %}
|
||||
<div class="space-y-4">
|
||||
{% for item in sessions %}
|
||||
@@ -87,7 +87,7 @@
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -99,7 +99,7 @@ function viewSession(sessionId) {
|
||||
}
|
||||
|
||||
function stopRecording(sessionId) {
|
||||
if (!confirm('Stop this recording session?')) return;
|
||||
if (!confirm('Stop this monitoring session?')) return;
|
||||
|
||||
// TODO: Implement stop recording API call
|
||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
||||
data-tab="sessions"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
Recording Sessions
|
||||
Monitoring Sessions
|
||||
</button>
|
||||
<button id="data-tab-btn" onclick="switchTab('data')"
|
||||
data-tab="data"
|
||||
@@ -185,11 +185,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording Sessions Tab -->
|
||||
<!-- Monitoring Sessions Tab -->
|
||||
<div id="sessions-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<select id="sessions-filter" onchange="filterSessions()"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
@@ -521,7 +521,7 @@
|
||||
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Single recording session with a specific start and end date/time (15 min - 24 hrs).
|
||||
Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -721,7 +721,7 @@ async function loadProjectDetails() {
|
||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||
}
|
||||
// Recording Sessions and Data Files tabs are SLM-only
|
||||
// Monitoring Sessions and Data Files tabs are SLM-only
|
||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||
|
||||
@@ -808,6 +808,10 @@ function openLocationModal(defaultType) {
|
||||
locationTypeSelect.value = 'sound';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
locationTypeSelect.value = 'vibration';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else {
|
||||
locationTypeSelect.disabled = false;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||
@@ -832,6 +836,10 @@ function openEditLocationModal(button) {
|
||||
locationTypeSelect.value = 'sound';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
locationTypeSelect.value = 'vibration';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else {
|
||||
locationTypeSelect.disabled = false;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||
@@ -855,6 +863,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
||||
let locationType = document.getElementById('location-type').value;
|
||||
if (projectTypeId === 'sound_monitoring') {
|
||||
locationType = 'sound';
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
locationType = 'vibration';
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -78,9 +78,16 @@
|
||||
<!-- Create Project Modal -->
|
||||
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||
</div>
|
||||
<button onclick="hideCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-4">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6" id="createProjectContent">
|
||||
@@ -97,6 +104,12 @@
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button type="button" onclick="hideCreateProjectModal()"
|
||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
||||
|
||||
@@ -1504,7 +1504,7 @@
|
||||
`• Unit roster entry\n` +
|
||||
`• All history records\n` +
|
||||
`• Project assignments\n` +
|
||||
`• Recording sessions\n` +
|
||||
`• Monitoring sessions\n` +
|
||||
`• Modem references\n\n` +
|
||||
`This action cannot be undone.`
|
||||
);
|
||||
|
||||
415
templates/vibration_location_detail.html
Normal file
415
templates/vibration_location_detail.html
Normal file
@@ -0,0 +1,415 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ location.name }} - Monitoring Location{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex items-center space-x-2 text-sm">
|
||||
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Projects
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<a href="/projects/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||
{{ project.name }}
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ location.name }}</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
{{ location.name }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Monitoring Location • {{ project.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if assigned_unit %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Unit Assigned
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
No Unit Assigned
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex space-x-6">
|
||||
<button onclick="switchTab('overview')"
|
||||
data-tab="overview"
|
||||
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||
Overview
|
||||
</button>
|
||||
<button onclick="switchTab('settings')"
|
||||
data-tab="settings"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div id="tab-content">
|
||||
<!-- Overview Tab -->
|
||||
<div id="overview-tab" class="tab-panel">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Location Details Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
|
||||
</div>
|
||||
{% if location.description %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if location.address %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if location.coordinates %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
|
||||
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||
{% if assigned_unit %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||
{{ assigned_unit.id }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if assigned_unit.device_type %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if assignment %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
|
||||
</div>
|
||||
{% if assignment.notes %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="pt-2">
|
||||
<button onclick="unassignUnit('{{ assignment.id }}')"
|
||||
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
||||
Unassign Unit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||
<button onclick="openAssignModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
Assign a Unit
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="settings-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
|
||||
|
||||
<form id="location-settings-form" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
||||
<input type="text" id="settings-name" value="{{ location.name }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<textarea id="settings-description" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">{{ location.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||
<input type="text" id="settings-address" value="{{ location.address or '' }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
|
||||
placeholder="40.7128,-74.0060"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
||||
</div>
|
||||
|
||||
<div id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
|
||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Unit Modal -->
|
||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
|
||||
</div>
|
||||
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="assign-form" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
||||
<select id="assign-unit-id" name="unit_id"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||
<option value="">Loading units...</option>
|
||||
</select>
|
||||
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="assign-notes" name="notes" rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="closeAssignModal()"
|
||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Assign Unit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const projectId = "{{ project_id }}";
|
||||
const locationId = "{{ location_id }}";
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||
panel.classList.add('hidden');
|
||||
});
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
const panel = document.getElementById(`${tabName}-tab`);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
||||
if (button) {
|
||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
}
|
||||
|
||||
// Location settings form submission
|
||||
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
name: document.getElementById('settings-name').value.trim(),
|
||||
description: document.getElementById('settings-description').value.trim() || null,
|
||||
address: document.getElementById('settings-address').value.trim() || null,
|
||||
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to update location');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('settings-error');
|
||||
errorEl.textContent = err.message || 'Failed to update location.';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Assign modal
|
||||
function openAssignModal() {
|
||||
document.getElementById('assign-modal').classList.remove('hidden');
|
||||
loadAvailableUnits();
|
||||
}
|
||||
|
||||
function closeAssignModal() {
|
||||
document.getElementById('assign-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function loadAvailableUnits() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||
if (!response.ok) throw new Error('Failed to load available units');
|
||||
const data = await response.json();
|
||||
const select = document.getElementById('assign-unit-id');
|
||||
select.innerHTML = '<option value="">Select a unit</option>';
|
||||
|
||||
if (!data.length) {
|
||||
document.getElementById('assign-empty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(unit => {
|
||||
const option = document.createElement('option');
|
||||
option.value = unit.id;
|
||||
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('assign-error');
|
||||
errorEl.textContent = err.message || 'Failed to load units.';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const unitId = document.getElementById('assign-unit-id').value;
|
||||
const notes = document.getElementById('assign-notes').value.trim();
|
||||
|
||||
if (!unitId) {
|
||||
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
||||
document.getElementById('assign-error').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('unit_id', unitId);
|
||||
formData.append('notes', notes);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to assign unit');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('assign-error');
|
||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function unassignUnit(assignmentId) {
|
||||
if (!confirm('Unassign this unit from the location?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to unassign unit');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to unassign unit.');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeAssignModal();
|
||||
});
|
||||
|
||||
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeAssignModal();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user