diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d37d9d..bc36a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to Terra-View will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-02-06 + +### Added +- **Calendar & Reservation Mode**: Fleet calendar view with reservation system for scheduling device deployments +- **Device Pairing Interface**: New two-column pairing page (`/pair-devices`) for linking recorders (seismographs/SLMs) with modems + - Visual pairing interface with drag-and-drop style interactions + - Fuzzy-search modem pairing for SLMs + - Pairing options now accessible from modem page + - Improved pair status sharing across views +- **Modem Dashboard Enhancements**: + - Modem model number now a dedicated configuration field with per-model options + - Direct link to modem login page from unit detail view + - Modem view converted to list format +- **Seismograph List Improvements**: + - Enhanced visibility with better filtering and sorting + - Calibration dates now color-coded for quick status assessment + - User sets date of previous calibration (not expiry) for clearer workflow +- **SLMM Device Control Lock**: Prevents command flooding to NL-43 devices + +### Changed +- **Calibration Date UX**: Users now set the date of the previous calibration rather than upcoming expiry dates - more intuitive workflow +- **Settings Persistence**: Settings save no longer reloads the page +- **Tab State**: Tab state now persists in URL hash for better navigation +- **Scheduler Management**: Schedule changes now cascade to individual events +- **Dashboard Filtering**: Enhanced dashboard with additional filtering options and SLM status sync +- **SLMM Polling Intervals**: Fixed and improved polling intervals for better responsiveness +- **24-Hour Scheduler Cycle**: Improved cycle handling to prevent issues with scheduled downloads + +### Fixed +- **SLM Modal Fields**: Modal now only contains correct device-specific fields +- **IP Address Handling**: IP address correctly passed via modem pairing +- **Mobile Type Display**: Fixed incorrect device type display in roster and device tables +- **SLMM Scheduled Downloads**: Fixed issues with scheduled download operations + ## [0.5.1] - 2026-01-27 ### Added @@ -399,6 +433,7 @@ No database migration required for v0.4.0. All new features use existing databas - Photo management per unit - Automated status categorization (OK/Pending/Missing) +[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0 [0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0 [0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4 diff --git a/README.md b/README.md index 5248f17..dd1eb03 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.5.1 +# Terra-View v0.6.0 Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. ## Features @@ -496,6 +496,14 @@ docker compose down -v ## Release Highlights +### v0.6.0 — 2026-02-06 +- **Calendar & Reservation Mode**: Fleet calendar view with device deployment scheduling and reservation system +- **Device Pairing Interface**: New `/pair-devices` page with two-column layout for linking recorders with modems, fuzzy-search, and visual pairing workflow +- **Calibration UX Overhaul**: Users now set date of previous calibration (not expiry); seismograph list enhanced with color-coded calibration status, filtering, and sorting +- **Modem Dashboard**: Model number as dedicated config, modem login links, list view format, and pairing options accessible from modem page +- **SLMM Improvements**: Device control lock prevents command flooding, fixed polling intervals and scheduled downloads +- **UI Polish**: Tab state persists in URL hash, settings save without reload, scheduler changes cascade to events, fixed mobile type display + ### v0.4.3 — 2026-01-14 - **Sound Level Meter workflow**: Roster manager surfaces SLM metadata, supports rename actions, and adds return-to-project navigation plus schedule/unit templates for project planning. - **Project insight panels**: Project dashboards now expose file and session lists so teams can see what each project stores before diving into units. @@ -571,9 +579,11 @@ MIT ## Version -**Current: 0.5.1** — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27) +**Current: 0.6.0** — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06) -Previous: 0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23) +Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27) + +0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23) 0.4.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14) diff --git a/backend/main.py b/backend/main.py index c4f77e4..e789e51 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.5.1" +VERSION = "0.6.0" app = FastAPI( title="Seismo Fleet Manager", description="Backend API for managing seismograph fleet status", @@ -115,6 +115,10 @@ app.include_router(alerts.router) from backend.routers import recurring_schedules app.include_router(recurring_schedules.router) +# Fleet Calendar router +from backend.routers import fleet_calendar +app.include_router(fleet_calendar.router) + # Start scheduler service and device status monitor on application startup from backend.services.scheduler import start_scheduler, stop_scheduler from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor diff --git a/backend/migrate_add_job_reservations.py b/backend/migrate_add_job_reservations.py new file mode 100644 index 0000000..01d4b4b --- /dev/null +++ b/backend/migrate_add_job_reservations.py @@ -0,0 +1,103 @@ +""" +Migration script to add job reservations for the Fleet Calendar feature. + +This creates two tables: +- job_reservations: Track future unit assignments for jobs/projects +- job_reservation_units: Link specific units to reservations + +Run this script once to migrate an existing database. +""" + +import sqlite3 +import os + +# Database path +DB_PATH = "./data/seismo_fleet.db" + + +def migrate_database(): + """Create the job_reservations and job_reservation_units tables""" + + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + print("The database will be created automatically when you run the application.") + return + + print(f"Migrating database: {DB_PATH}") + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Check if job_reservations table already exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'") + if cursor.fetchone(): + print("Migration already applied - job_reservations table exists") + conn.close() + return + + print("Creating job_reservations table...") + + try: + # Create job_reservations table + cursor.execute(""" + CREATE TABLE job_reservations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + project_id TEXT, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + assignment_type TEXT NOT NULL DEFAULT 'quantity', + device_type TEXT DEFAULT 'seismograph', + quantity_needed INTEGER, + notes TEXT, + color TEXT DEFAULT '#3B82F6', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + print(" Created job_reservations table") + + # Create indexes for job_reservations + cursor.execute("CREATE INDEX idx_job_reservations_project_id ON job_reservations(project_id)") + print(" Created index on project_id") + + cursor.execute("CREATE INDEX idx_job_reservations_dates ON job_reservations(start_date, end_date)") + print(" Created index on dates") + + # Create job_reservation_units table + print("Creating job_reservation_units table...") + cursor.execute(""" + CREATE TABLE job_reservation_units ( + id TEXT PRIMARY KEY, + reservation_id TEXT NOT NULL, + unit_id TEXT NOT NULL, + assignment_source TEXT DEFAULT 'specific', + assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (reservation_id) REFERENCES job_reservations(id), + FOREIGN KEY (unit_id) REFERENCES roster(id) + ) + """) + print(" Created job_reservation_units table") + + # Create indexes for job_reservation_units + cursor.execute("CREATE INDEX idx_job_reservation_units_reservation_id ON job_reservation_units(reservation_id)") + print(" Created index on reservation_id") + + cursor.execute("CREATE INDEX idx_job_reservation_units_unit_id ON job_reservation_units(unit_id)") + print(" Created index on unit_id") + + conn.commit() + print("\nMigration completed successfully!") + print("You can now use the Fleet Calendar to manage unit reservations.") + + except sqlite3.Error as e: + print(f"\nError during migration: {e}") + conn.rollback() + raise + + finally: + conn.close() + + +if __name__ == "__main__": + migrate_database() diff --git a/backend/migrate_add_tbd_dates.py b/backend/migrate_add_tbd_dates.py new file mode 100644 index 0000000..d205624 --- /dev/null +++ b/backend/migrate_add_tbd_dates.py @@ -0,0 +1,89 @@ +""" +Migration: Add TBD date support to job reservations + +Adds columns: +- job_reservations.estimated_end_date: For planning when end is TBD +- job_reservations.end_date_tbd: Boolean flag for TBD end dates +- job_reservation_units.unit_start_date: Unit-specific start (for swaps) +- job_reservation_units.unit_end_date: Unit-specific end (for swaps) +- job_reservation_units.unit_end_tbd: Unit-specific TBD flag +- job_reservation_units.notes: Notes for the assignment + +Also makes job_reservations.end_date nullable. +""" + +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: + # Check if job_reservations table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'") + if not cursor.fetchone(): + print("job_reservations table does not exist. Skipping migration.") + return + + # Get existing columns in job_reservations + cursor.execute("PRAGMA table_info(job_reservations)") + existing_cols = {row[1] for row in cursor.fetchall()} + + # Add new columns to job_reservations if they don't exist + if 'estimated_end_date' not in existing_cols: + print("Adding estimated_end_date column to job_reservations...") + cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_end_date DATE") + + if 'end_date_tbd' not in existing_cols: + print("Adding end_date_tbd column to job_reservations...") + cursor.execute("ALTER TABLE job_reservations ADD COLUMN end_date_tbd BOOLEAN DEFAULT 0") + + # Get existing columns in job_reservation_units + cursor.execute("PRAGMA table_info(job_reservation_units)") + unit_cols = {row[1] for row in cursor.fetchall()} + + # Add new columns to job_reservation_units if they don't exist + if 'unit_start_date' not in unit_cols: + print("Adding unit_start_date column to job_reservation_units...") + cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_start_date DATE") + + if 'unit_end_date' not in unit_cols: + print("Adding unit_end_date column to job_reservation_units...") + cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_end_date DATE") + + if 'unit_end_tbd' not in unit_cols: + print("Adding unit_end_tbd column to job_reservation_units...") + cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_end_tbd BOOLEAN DEFAULT 0") + + if 'notes' not in unit_cols: + print("Adding notes column to job_reservation_units...") + cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN notes TEXT") + + conn.commit() + print("Migration completed successfully!") + + except Exception as e: + print(f"Migration failed: {e}") + conn.rollback() + raise + finally: + conn.close() + + +if __name__ == "__main__": + # Default to dev database + db_path = "./data-dev/seismo_fleet.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) diff --git a/backend/migrate_fix_end_date_nullable.py b/backend/migrate_fix_end_date_nullable.py new file mode 100644 index 0000000..60aec41 --- /dev/null +++ b/backend/migrate_fix_end_date_nullable.py @@ -0,0 +1,105 @@ +""" +Migration: Make job_reservations.end_date nullable for TBD support + +SQLite doesn't support ALTER COLUMN, so we need to: +1. Create a new table with the correct schema +2. Copy data +3. Drop old table +4. Rename new table +""" + +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: + # Check if job_reservations table exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'") + if not cursor.fetchone(): + print("job_reservations table does not exist. Skipping migration.") + return + + # Check current schema + cursor.execute("PRAGMA table_info(job_reservations)") + columns = cursor.fetchall() + col_info = {row[1]: row for row in columns} + + # Check if end_date is already nullable (notnull=0) + if 'end_date' in col_info and col_info['end_date'][3] == 0: + print("end_date is already nullable. Skipping table recreation.") + return + + print("Recreating job_reservations table with nullable end_date...") + + # Create new table with correct schema + cursor.execute(""" + CREATE TABLE job_reservations_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + project_id TEXT, + start_date DATE NOT NULL, + end_date DATE, + estimated_end_date DATE, + end_date_tbd BOOLEAN DEFAULT 0, + assignment_type TEXT NOT NULL DEFAULT 'quantity', + device_type TEXT DEFAULT 'seismograph', + quantity_needed INTEGER, + notes TEXT, + color TEXT DEFAULT '#3B82F6', + created_at DATETIME, + updated_at DATETIME + ) + """) + + # Copy existing data + cursor.execute(""" + INSERT INTO job_reservations_new + SELECT + id, name, project_id, start_date, end_date, + COALESCE(estimated_end_date, NULL) as estimated_end_date, + COALESCE(end_date_tbd, 0) as end_date_tbd, + assignment_type, device_type, quantity_needed, notes, color, + created_at, updated_at + FROM job_reservations + """) + + # Drop old table + cursor.execute("DROP TABLE job_reservations") + + # Rename new table + cursor.execute("ALTER TABLE job_reservations_new RENAME TO job_reservations") + + # Recreate index + cursor.execute("CREATE INDEX IF NOT EXISTS ix_job_reservations_id ON job_reservations (id)") + cursor.execute("CREATE INDEX IF NOT EXISTS ix_job_reservations_project_id ON job_reservations (project_id)") + + conn.commit() + print("Migration completed successfully!") + + except Exception as e: + print(f"Migration failed: {e}") + conn.rollback() + raise + finally: + conn.close() + + +if __name__ == "__main__": + # Default to dev database + db_path = "./data-dev/seismo_fleet.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) diff --git a/backend/models.py b/backend/models.py index 41a9c4c..b6d2c6f 100644 --- a/backend/models.py +++ b/backend/models.py @@ -402,3 +402,72 @@ class Alert(Base): created_at = Column(DateTime, default=datetime.utcnow) expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time + + +# ============================================================================ +# Fleet Calendar & Job Reservations +# ============================================================================ + +class JobReservation(Base): + """ + Job reservations: reserve units for future jobs/projects. + + Supports two assignment modes: + - "specific": Pick exact units (SN-001, SN-002, etc.) + - "quantity": Reserve a number of units (e.g., "need 8 seismographs") + + Used by the Fleet Calendar to visualize unit availability over time. + """ + __tablename__ = "job_reservations" + + id = Column(String, primary_key=True, index=True) # UUID + name = Column(String, nullable=False) # "Job A - March deployment" + project_id = Column(String, nullable=True, index=True) # Optional FK to Project + + # Date range for the reservation + start_date = Column(Date, nullable=False) + end_date = Column(Date, nullable=True) # Nullable = TBD / ongoing + estimated_end_date = Column(Date, nullable=True) # For planning when end is TBD + end_date_tbd = Column(Boolean, default=False) # True = end date unknown + + # Assignment type: "specific" or "quantity" + assignment_type = Column(String, nullable=False, default="quantity") + + # For quantity reservations + device_type = Column(String, default="seismograph") # seismograph | slm + quantity_needed = Column(Integer, nullable=True) # e.g., 8 units + + # Metadata + notes = Column(Text, nullable=True) + color = Column(String, default="#3B82F6") # For calendar display (blue default) + + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + +class JobReservationUnit(Base): + """ + Links specific units to job reservations. + + Used when: + - assignment_type="specific": Units are directly assigned + - assignment_type="quantity": Units can be filled in later + + Supports unit swaps: same reservation can have multiple units with + different date ranges (e.g., BE17353 Feb-Jun, then BE18438 Jun-Nov). + """ + __tablename__ = "job_reservation_units" + + id = Column(String, primary_key=True, index=True) # UUID + reservation_id = Column(String, nullable=False, index=True) # FK to JobReservation + unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit + + # Unit-specific date range (for swaps) - defaults to reservation dates if null + unit_start_date = Column(Date, nullable=True) # When this specific unit starts + unit_end_date = Column(Date, nullable=True) # When this unit ends (swap out date) + unit_end_tbd = Column(Boolean, default=False) # True = end unknown (until cal expires or job ends) + + # Track how this assignment was made + assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap" + assigned_at = Column(DateTime, default=datetime.utcnow) + notes = Column(Text, nullable=True) # "Replacing BE17353" etc. diff --git a/backend/routers/fleet_calendar.py b/backend/routers/fleet_calendar.py new file mode 100644 index 0000000..170c7e0 --- /dev/null +++ b/backend/routers/fleet_calendar.py @@ -0,0 +1,610 @@ +""" +Fleet Calendar Router + +API endpoints for the Fleet Calendar feature: +- Calendar page and data +- Job reservation CRUD +- Unit assignment management +- Availability checking +""" + +from fastapi import APIRouter, Request, Depends, HTTPException, Query +from fastapi.responses import HTMLResponse, JSONResponse +from sqlalchemy.orm import Session +from datetime import datetime, date, timedelta +from typing import Optional, List +import uuid +import logging + +from backend.database import get_db +from backend.models import ( + RosterUnit, JobReservation, JobReservationUnit, + UserPreferences, Project +) +from backend.templates_config import templates +from backend.services.fleet_calendar_service import ( + get_day_summary, + get_calendar_year_data, + get_rolling_calendar_data, + check_calibration_conflicts, + get_available_units_for_period, + get_calibration_status +) + +router = APIRouter(tags=["fleet-calendar"]) +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Calendar Page +# ============================================================================ + +@router.get("/fleet-calendar", response_class=HTMLResponse) +async def fleet_calendar_page( + request: Request, + year: Optional[int] = None, + month: Optional[int] = None, + device_type: str = "seismograph", + db: Session = Depends(get_db) +): + """Main Fleet Calendar page with rolling 12-month view.""" + today = date.today() + + # Default to current month as the start + if year is None: + year = today.year + if month is None: + month = today.month + + # Get calendar data for 12 months starting from year/month + calendar_data = get_rolling_calendar_data(db, year, month, device_type) + + # Get projects for the reservation form dropdown + projects = db.query(Project).filter( + Project.status == "active" + ).order_by(Project.name).all() + + # Calculate prev/next month navigation + prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1) + next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1) + + return templates.TemplateResponse( + "fleet_calendar.html", + { + "request": request, + "start_year": year, + "start_month": month, + "prev_year": prev_year, + "prev_month": prev_month, + "next_year": next_year, + "next_month": next_month, + "device_type": device_type, + "calendar_data": calendar_data, + "projects": projects, + "today": today.isoformat() + } + ) + + +# ============================================================================ +# Calendar Data API +# ============================================================================ + +@router.get("/api/fleet-calendar/data", response_class=JSONResponse) +async def get_calendar_data( + year: int, + device_type: str = "seismograph", + db: Session = Depends(get_db) +): + """Get calendar data for a specific year.""" + return get_calendar_year_data(db, year, device_type) + + +@router.get("/api/fleet-calendar/day/{date_str}", response_class=HTMLResponse) +async def get_day_detail( + request: Request, + date_str: str, + device_type: str = "seismograph", + db: Session = Depends(get_db) +): + """Get detailed view for a specific day (HTMX partial).""" + try: + check_date = date.fromisoformat(date_str) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + + day_data = get_day_summary(db, check_date, device_type) + + # Get projects for display names + projects = {p.id: p for p in db.query(Project).all()} + + return templates.TemplateResponse( + "partials/fleet_calendar/day_detail.html", + { + "request": request, + "day_data": day_data, + "date_str": date_str, + "date_display": check_date.strftime("%B %d, %Y"), + "device_type": device_type, + "projects": projects + } + ) + + +# ============================================================================ +# Reservation CRUD +# ============================================================================ + +@router.post("/api/fleet-calendar/reservations", response_class=JSONResponse) +async def create_reservation( + request: Request, + db: Session = Depends(get_db) +): + """Create a new job reservation.""" + data = await request.json() + + # Validate required fields + required = ["name", "start_date", "assignment_type"] + for field in required: + if field not in data: + raise HTTPException(status_code=400, detail=f"Missing required field: {field}") + + # Need either end_date or end_date_tbd + end_date_tbd = data.get("end_date_tbd", False) + if not end_date_tbd and not data.get("end_date"): + raise HTTPException(status_code=400, detail="End date is required unless marked as TBD") + + try: + start_date = date.fromisoformat(data["start_date"]) + end_date = date.fromisoformat(data["end_date"]) if data.get("end_date") else None + estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data.get("estimated_end_date") else None + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + + if end_date and end_date < start_date: + raise HTTPException(status_code=400, detail="End date must be after start date") + + if estimated_end_date and estimated_end_date < start_date: + raise HTTPException(status_code=400, detail="Estimated end date must be after start date") + + reservation = JobReservation( + id=str(uuid.uuid4()), + name=data["name"], + project_id=data.get("project_id"), + start_date=start_date, + end_date=end_date, + estimated_end_date=estimated_end_date, + end_date_tbd=end_date_tbd, + assignment_type=data["assignment_type"], + device_type=data.get("device_type", "seismograph"), + quantity_needed=data.get("quantity_needed"), + notes=data.get("notes"), + color=data.get("color", "#3B82F6") + ) + + db.add(reservation) + + # If specific units were provided, assign them + if data.get("unit_ids") and data["assignment_type"] == "specific": + for unit_id in data["unit_ids"]: + assignment = JobReservationUnit( + id=str(uuid.uuid4()), + reservation_id=reservation.id, + unit_id=unit_id, + assignment_source="specific" + ) + db.add(assignment) + + db.commit() + + logger.info(f"Created reservation: {reservation.name} ({reservation.id})") + + return { + "success": True, + "reservation_id": reservation.id, + "message": f"Created reservation: {reservation.name}" + } + + +@router.get("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse) +async def get_reservation( + reservation_id: str, + db: Session = Depends(get_db) +): + """Get a specific reservation with its assigned units.""" + reservation = db.query(JobReservation).filter_by(id=reservation_id).first() + if not reservation: + raise HTTPException(status_code=404, detail="Reservation not found") + + # Get assigned units + assignments = db.query(JobReservationUnit).filter_by( + reservation_id=reservation_id + ).all() + + unit_ids = [a.unit_id for a in assignments] + units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else [] + + return { + "id": reservation.id, + "name": reservation.name, + "project_id": reservation.project_id, + "start_date": reservation.start_date.isoformat(), + "end_date": reservation.end_date.isoformat() if reservation.end_date else None, + "estimated_end_date": reservation.estimated_end_date.isoformat() if reservation.estimated_end_date else None, + "end_date_tbd": reservation.end_date_tbd, + "assignment_type": reservation.assignment_type, + "device_type": reservation.device_type, + "quantity_needed": reservation.quantity_needed, + "notes": reservation.notes, + "color": reservation.color, + "assigned_units": [ + { + "id": u.id, + "last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, + "deployed": u.deployed + } + for u in units + ] + } + + +@router.put("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse) +async def update_reservation( + reservation_id: str, + request: Request, + db: Session = Depends(get_db) +): + """Update an existing reservation.""" + reservation = db.query(JobReservation).filter_by(id=reservation_id).first() + if not reservation: + raise HTTPException(status_code=404, detail="Reservation not found") + + data = await request.json() + + # Update fields if provided + if "name" in data: + reservation.name = data["name"] + if "project_id" in data: + reservation.project_id = data["project_id"] + if "start_date" in data: + reservation.start_date = date.fromisoformat(data["start_date"]) + if "end_date" in data: + reservation.end_date = date.fromisoformat(data["end_date"]) if data["end_date"] else None + if "estimated_end_date" in data: + reservation.estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data["estimated_end_date"] else None + if "end_date_tbd" in data: + reservation.end_date_tbd = data["end_date_tbd"] + if "assignment_type" in data: + reservation.assignment_type = data["assignment_type"] + if "quantity_needed" in data: + reservation.quantity_needed = data["quantity_needed"] + if "notes" in data: + reservation.notes = data["notes"] + if "color" in data: + reservation.color = data["color"] + + reservation.updated_at = datetime.utcnow() + + db.commit() + + logger.info(f"Updated reservation: {reservation.name} ({reservation.id})") + + return { + "success": True, + "message": f"Updated reservation: {reservation.name}" + } + + +@router.delete("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse) +async def delete_reservation( + reservation_id: str, + db: Session = Depends(get_db) +): + """Delete a reservation and its unit assignments.""" + reservation = db.query(JobReservation).filter_by(id=reservation_id).first() + if not reservation: + raise HTTPException(status_code=404, detail="Reservation not found") + + # Delete unit assignments first + db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete() + + # Delete the reservation + db.delete(reservation) + db.commit() + + logger.info(f"Deleted reservation: {reservation.name} ({reservation_id})") + + return { + "success": True, + "message": "Reservation deleted" + } + + +# ============================================================================ +# Unit Assignment +# ============================================================================ + +@router.post("/api/fleet-calendar/reservations/{reservation_id}/assign-units", response_class=JSONResponse) +async def assign_units_to_reservation( + reservation_id: str, + request: Request, + db: Session = Depends(get_db) +): + """Assign specific units to a reservation.""" + reservation = db.query(JobReservation).filter_by(id=reservation_id).first() + if not reservation: + raise HTTPException(status_code=404, detail="Reservation not found") + + data = await request.json() + unit_ids = data.get("unit_ids", []) + + if not unit_ids: + raise HTTPException(status_code=400, detail="No units specified") + + # Verify units exist + units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() + found_ids = {u.id for u in units} + missing = set(unit_ids) - found_ids + if missing: + raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}") + + # Check for conflicts (already assigned to overlapping reservations) + conflicts = [] + for unit_id in unit_ids: + # Check if unit is already assigned to this reservation + existing = db.query(JobReservationUnit).filter_by( + reservation_id=reservation_id, + unit_id=unit_id + ).first() + if existing: + continue # Already assigned, skip + + # Check overlapping reservations + overlapping = db.query(JobReservation).join( + JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id + ).filter( + JobReservationUnit.unit_id == unit_id, + JobReservation.id != reservation_id, + JobReservation.start_date <= reservation.end_date, + JobReservation.end_date >= reservation.start_date + ).first() + + if overlapping: + conflicts.append({ + "unit_id": unit_id, + "conflict_reservation": overlapping.name, + "conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}" + }) + continue + + # Add assignment + assignment = JobReservationUnit( + id=str(uuid.uuid4()), + reservation_id=reservation_id, + unit_id=unit_id, + assignment_source="filled" if reservation.assignment_type == "quantity" else "specific" + ) + db.add(assignment) + + db.commit() + + # Check for calibration conflicts + cal_conflicts = check_calibration_conflicts(db, reservation_id) + + assigned_count = db.query(JobReservationUnit).filter_by( + reservation_id=reservation_id + ).count() + + return { + "success": True, + "assigned_count": assigned_count, + "conflicts": conflicts, + "calibration_warnings": cal_conflicts, + "message": f"Assigned {len(unit_ids) - len(conflicts)} units" + } + + +@router.delete("/api/fleet-calendar/reservations/{reservation_id}/units/{unit_id}", response_class=JSONResponse) +async def remove_unit_from_reservation( + reservation_id: str, + unit_id: str, + db: Session = Depends(get_db) +): + """Remove a unit from a reservation.""" + assignment = db.query(JobReservationUnit).filter_by( + reservation_id=reservation_id, + unit_id=unit_id + ).first() + + if not assignment: + raise HTTPException(status_code=404, detail="Unit assignment not found") + + db.delete(assignment) + db.commit() + + return { + "success": True, + "message": f"Removed {unit_id} from reservation" + } + + +# ============================================================================ +# Availability & Conflicts +# ============================================================================ + +@router.get("/api/fleet-calendar/availability", response_class=JSONResponse) +async def check_availability( + start_date: str, + end_date: str, + device_type: str = "seismograph", + exclude_reservation_id: Optional[str] = None, + db: Session = Depends(get_db) +): + """Get units available for a specific date range.""" + try: + start = date.fromisoformat(start_date) + end = date.fromisoformat(end_date) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + + available = get_available_units_for_period( + db, start, end, device_type, exclude_reservation_id + ) + + return { + "start_date": start_date, + "end_date": end_date, + "device_type": device_type, + "available_units": available, + "count": len(available) + } + + +@router.get("/api/fleet-calendar/reservations/{reservation_id}/conflicts", response_class=JSONResponse) +async def get_reservation_conflicts( + reservation_id: str, + db: Session = Depends(get_db) +): + """Check for calibration conflicts in a reservation.""" + reservation = db.query(JobReservation).filter_by(id=reservation_id).first() + if not reservation: + raise HTTPException(status_code=404, detail="Reservation not found") + + conflicts = check_calibration_conflicts(db, reservation_id) + + return { + "reservation_id": reservation_id, + "reservation_name": reservation.name, + "conflicts": conflicts, + "has_conflicts": len(conflicts) > 0 + } + + +# ============================================================================ +# HTMX Partials +# ============================================================================ + +@router.get("/api/fleet-calendar/reservations-list", response_class=HTMLResponse) +async def get_reservations_list( + request: Request, + year: Optional[int] = None, + month: Optional[int] = None, + device_type: str = "seismograph", + db: Session = Depends(get_db) +): + """Get list of reservations as HTMX partial.""" + from sqlalchemy import or_ + + today = date.today() + if year is None: + year = today.year + if month is None: + month = today.month + + # Calculate 12-month window + start_date = date(year, month, 1) + # End date is 12 months later + end_year = year + ((month + 10) // 12) + end_month = ((month + 10) % 12) + 1 + if end_month == 12: + end_date = date(end_year, 12, 31) + else: + end_date = date(end_year, end_month + 1, 1) - timedelta(days=1) + + # Include TBD reservations that started before window end + reservations = db.query(JobReservation).filter( + JobReservation.device_type == device_type, + JobReservation.start_date <= end_date, + or_( + JobReservation.end_date >= start_date, + JobReservation.end_date == None # TBD reservations + ) + ).order_by(JobReservation.start_date).all() + + # Get assignment counts + reservation_data = [] + for res in reservations: + assigned_count = db.query(JobReservationUnit).filter_by( + reservation_id=res.id + ).count() + + # Check for calibration conflicts + conflicts = check_calibration_conflicts(db, res.id) + + reservation_data.append({ + "reservation": res, + "assigned_count": assigned_count, + "has_conflicts": len(conflicts) > 0, + "conflict_count": len(conflicts) + }) + + return templates.TemplateResponse( + "partials/fleet_calendar/reservations_list.html", + { + "request": request, + "reservations": reservation_data, + "year": year, + "device_type": device_type + } + ) + + +@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse) +async def get_available_units_partial( + request: Request, + start_date: str, + end_date: str, + device_type: str = "seismograph", + reservation_id: Optional[str] = None, + db: Session = Depends(get_db) +): + """Get available units as HTMX partial for the assignment modal.""" + try: + start = date.fromisoformat(start_date) + end = date.fromisoformat(end_date) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format") + + available = get_available_units_for_period( + db, start, end, device_type, reservation_id + ) + + return templates.TemplateResponse( + "partials/fleet_calendar/available_units.html", + { + "request": request, + "units": available, + "start_date": start_date, + "end_date": end_date, + "device_type": device_type, + "reservation_id": reservation_id + } + ) + + +@router.get("/api/fleet-calendar/month/{year}/{month}", response_class=HTMLResponse) +async def get_month_partial( + request: Request, + year: int, + month: int, + device_type: str = "seismograph", + db: Session = Depends(get_db) +): + """Get a single month calendar as HTMX partial.""" + calendar_data = get_calendar_year_data(db, year, device_type) + month_data = calendar_data["months"].get(month) + + if not month_data: + raise HTTPException(status_code=404, detail="Invalid month") + + return templates.TemplateResponse( + "partials/fleet_calendar/month_grid.html", + { + "request": request, + "year": year, + "month": month, + "month_data": month_data, + "device_type": device_type, + "today": date.today().isoformat() + } + ) diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 0f939b7..0c2d974 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query from fastapi.exceptions import RequestValidationError from sqlalchemy.orm import Session -from datetime import datetime, date +from datetime import datetime, date, timedelta import csv import io import logging @@ -9,12 +9,20 @@ import httpx import os from backend.database import get_db -from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory +from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences from backend.services.slmm_sync import sync_slm_to_slmm router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) logger = logging.getLogger(__name__) + +def get_calibration_interval(db: Session) -> int: + """Get calibration interval from user preferences, default 365 days.""" + prefs = db.query(UserPreferences).first() + if prefs and prefs.calibration_interval_days: + return prefs.calibration_interval_days + return 365 + # SLMM backend URL for syncing device configs to cache SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") @@ -185,8 +193,13 @@ async def add_roster_unit( except ValueError: raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") + # Auto-calculate next_calibration_due from last_calibrated using calibration interval next_cal_date = None - if next_calibration_due: + if last_cal_date: + cal_interval = get_calibration_interval(db) + next_cal_date = last_cal_date + timedelta(days=cal_interval) + elif next_calibration_due: + # Fallback: allow explicit setting if no last_calibrated try: next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() except ValueError: @@ -517,8 +530,13 @@ async def edit_roster_unit( except ValueError: raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") + # Auto-calculate next_calibration_due from last_calibrated using calibration interval next_cal_date = None - if next_calibration_due: + if last_cal_date: + cal_interval = get_calibration_interval(db) + next_cal_date = last_cal_date + timedelta(days=cal_interval) + elif next_calibration_due: + # Fallback: allow explicit setting if no last_calibrated try: next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() except ValueError: @@ -995,8 +1013,14 @@ async def import_csv( # Seismograph-specific fields if row.get('last_calibrated'): - existing_unit.last_calibrated = _parse_date(row.get('last_calibrated')) - if row.get('next_calibration_due'): + last_cal = _parse_date(row.get('last_calibrated')) + existing_unit.last_calibrated = last_cal + # Auto-calculate next_calibration_due using calibration interval + if last_cal: + cal_interval = get_calibration_interval(db) + existing_unit.next_calibration_due = last_cal + timedelta(days=cal_interval) + elif row.get('next_calibration_due'): + # Only use explicit next_calibration_due if no last_calibrated existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due')) if row.get('deployed_with_modem_id'): existing_unit.deployed_with_modem_id = _get_csv_value(row, 'deployed_with_modem_id') @@ -1033,6 +1057,14 @@ async def import_csv( results["updated"].append(unit_id) else: + # Calculate next_calibration_due from last_calibrated + last_cal = _parse_date(row.get('last_calibrated', '')) + if last_cal: + cal_interval = get_calibration_interval(db) + next_cal = last_cal + timedelta(days=cal_interval) + else: + next_cal = _parse_date(row.get('next_calibration_due', '')) + # Create new unit with all fields new_unit = RosterUnit( id=unit_id, @@ -1046,9 +1078,9 @@ async def import_csv( address=_get_csv_value(row, 'address'), coordinates=_get_csv_value(row, 'coordinates'), last_updated=datetime.utcnow(), - # Seismograph fields - last_calibrated=_parse_date(row.get('last_calibrated', '')), - next_calibration_due=_parse_date(row.get('next_calibration_due', '')), + # Seismograph fields - auto-calc next_calibration_due from last_calibrated + last_calibrated=last_cal, + next_calibration_due=next_cal, deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'), # Modem fields ip_address=_get_csv_value(row, 'ip_address'), diff --git a/backend/routers/seismo_dashboard.py b/backend/routers/seismo_dashboard.py index 6f99d6d..7e48f83 100644 --- a/backend/routers/seismo_dashboard.py +++ b/backend/routers/seismo_dashboard.py @@ -3,6 +3,8 @@ Seismograph Dashboard API Router Provides endpoints for the seismograph-specific dashboard """ +from datetime import date + from fastapi import APIRouter, Request, Depends, Query from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session @@ -49,10 +51,14 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)): async def get_seismo_units( request: Request, db: Session = Depends(get_db), - search: str = Query(None) + search: str = Query(None), + sort: str = Query("id"), + order: str = Query("asc"), + status: str = Query(None), + modem: str = Query(None) ): """ - Returns HTML partial with filterable seismograph unit list + Returns HTML partial with filterable and sortable seismograph unit list """ query = db.query(RosterUnit).filter_by( device_type="seismograph", @@ -61,20 +67,52 @@ async def get_seismo_units( # Apply search filter if search: - search_lower = search.lower() query = query.filter( (RosterUnit.id.ilike(f"%{search}%")) | (RosterUnit.note.ilike(f"%{search}%")) | (RosterUnit.address.ilike(f"%{search}%")) ) - seismos = query.order_by(RosterUnit.id).all() + # Apply status filter + if status == "deployed": + query = query.filter(RosterUnit.deployed == True) + elif status == "benched": + query = query.filter(RosterUnit.deployed == False) + + # Apply modem filter + if modem == "with": + query = query.filter(RosterUnit.deployed_with_modem_id.isnot(None)) + elif modem == "without": + query = query.filter(RosterUnit.deployed_with_modem_id.is_(None)) + + # Apply sorting + sort_column_map = { + "id": RosterUnit.id, + "status": RosterUnit.deployed, + "modem": RosterUnit.deployed_with_modem_id, + "location": RosterUnit.address, + "last_calibrated": RosterUnit.last_calibrated, + "notes": RosterUnit.note + } + sort_column = sort_column_map.get(sort, RosterUnit.id) + + if order == "desc": + query = query.order_by(sort_column.desc()) + else: + query = query.order_by(sort_column.asc()) + + seismos = query.all() return templates.TemplateResponse( "partials/seismo_unit_list.html", { "request": request, "units": seismos, - "search": search or "" + "search": search or "", + "sort": sort, + "order": order, + "status": status or "", + "modem": modem or "", + "today": date.today() } ) diff --git a/backend/services/fleet_calendar_service.py b/backend/services/fleet_calendar_service.py new file mode 100644 index 0000000..33b5ce7 --- /dev/null +++ b/backend/services/fleet_calendar_service.py @@ -0,0 +1,668 @@ +""" +Fleet Calendar Service + +Business logic for: +- Calculating unit availability on any given date +- Calibration status tracking (valid, expiring soon, expired) +- Job reservation management +- Conflict detection (calibration expires mid-job) +""" + +from datetime import date, datetime, timedelta +from typing import Dict, List, Optional, Tuple +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ + +from backend.models import ( + RosterUnit, JobReservation, JobReservationUnit, + UserPreferences, Project +) + + +def get_calibration_status( + unit: RosterUnit, + check_date: date, + warning_days: int = 30 +) -> str: + """ + Determine calibration status for a unit on a specific date. + + Returns: + "valid" - Calibration is good on this date + "expiring_soon" - Within warning_days of expiry + "expired" - Calibration has expired + "needs_calibration" - No calibration date set + """ + if not unit.last_calibrated: + return "needs_calibration" + + # Calculate expiry date (1 year from last calibration) + expiry_date = unit.last_calibrated + timedelta(days=365) + + if check_date >= expiry_date: + return "expired" + elif check_date >= expiry_date - timedelta(days=warning_days): + return "expiring_soon" + else: + return "valid" + + +def get_unit_reservations_on_date( + db: Session, + unit_id: str, + check_date: date +) -> List[JobReservation]: + """Get all reservations that include this unit on the given date.""" + + # Get reservation IDs that have this unit assigned + assigned_reservation_ids = db.query(JobReservationUnit.reservation_id).filter( + JobReservationUnit.unit_id == unit_id + ).subquery() + + # Get reservations that: + # 1. Have this unit assigned AND date is within range + reservations = db.query(JobReservation).filter( + JobReservation.id.in_(assigned_reservation_ids), + JobReservation.start_date <= check_date, + JobReservation.end_date >= check_date + ).all() + + return reservations + + +def is_unit_available_on_date( + db: Session, + unit: RosterUnit, + check_date: date, + warning_days: int = 30 +) -> Tuple[bool, str, Optional[str]]: + """ + Check if a unit is available on a specific date. + + Returns: + (is_available, status, reservation_name) + - is_available: True if unit can be assigned to new work + - status: "available", "reserved", "expired", "retired", "needs_calibration" + - reservation_name: Name of blocking reservation (if any) + """ + # Check if retired + if unit.retired: + return False, "retired", None + + # Check calibration status + cal_status = get_calibration_status(unit, check_date, warning_days) + if cal_status == "expired": + return False, "expired", None + if cal_status == "needs_calibration": + return False, "needs_calibration", None + + # Check if already reserved + reservations = get_unit_reservations_on_date(db, unit.id, check_date) + if reservations: + return False, "reserved", reservations[0].name + + # Unit is available (even if expiring soon - that's just a warning) + return True, "available", None + + +def get_day_summary( + db: Session, + check_date: date, + device_type: str = "seismograph" +) -> Dict: + """ + Get a complete summary of fleet status for a specific day. + + Returns dict with: + - available_units: List of available unit IDs with calibration info + - reserved_units: List of reserved unit IDs with reservation info + - expired_units: List of units with expired calibration + - expiring_soon_units: List of units expiring within warning period + - reservations: List of active reservations on this date + - counts: Summary counts + """ + # Get user preferences for warning days + prefs = db.query(UserPreferences).filter_by(id=1).first() + warning_days = prefs.calibration_warning_days if prefs else 30 + + # Get all non-retired units of the specified device type + units = db.query(RosterUnit).filter( + RosterUnit.device_type == device_type, + RosterUnit.retired == False + ).all() + + available_units = [] + reserved_units = [] + expired_units = [] + expiring_soon_units = [] + needs_calibration_units = [] + cal_expiring_today = [] # Units whose calibration expires ON this day + + for unit in units: + is_avail, status, reservation_name = is_unit_available_on_date( + db, unit, check_date, warning_days + ) + + cal_status = get_calibration_status(unit, check_date, warning_days) + expiry_date = None + if unit.last_calibrated: + expiry_date = (unit.last_calibrated + timedelta(days=365)).isoformat() + + unit_info = { + "id": unit.id, + "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None, + "expiry_date": expiry_date, + "calibration_status": cal_status, + "deployed": unit.deployed, + "note": unit.note or "" + } + + # Check if calibration expires ON this specific day + if unit.last_calibrated: + unit_expiry_date = unit.last_calibrated + timedelta(days=365) + if unit_expiry_date == check_date: + cal_expiring_today.append(unit_info) + + if status == "available": + available_units.append(unit_info) + if cal_status == "expiring_soon": + expiring_soon_units.append(unit_info) + elif status == "reserved": + unit_info["reservation_name"] = reservation_name + reserved_units.append(unit_info) + if cal_status == "expiring_soon": + expiring_soon_units.append(unit_info) + elif status == "expired": + expired_units.append(unit_info) + elif status == "needs_calibration": + needs_calibration_units.append(unit_info) + + # Get active reservations on this date + reservations = db.query(JobReservation).filter( + JobReservation.device_type == device_type, + JobReservation.start_date <= check_date, + JobReservation.end_date >= check_date + ).all() + + reservation_list = [] + for res in reservations: + # Count assigned units for this reservation + assigned_count = db.query(JobReservationUnit).filter( + JobReservationUnit.reservation_id == res.id + ).count() + + reservation_list.append({ + "id": res.id, + "name": res.name, + "start_date": res.start_date.isoformat(), + "end_date": res.end_date.isoformat(), + "assignment_type": res.assignment_type, + "quantity_needed": res.quantity_needed, + "assigned_count": assigned_count, + "color": res.color, + "project_id": res.project_id + }) + + return { + "date": check_date.isoformat(), + "device_type": device_type, + "available_units": available_units, + "reserved_units": reserved_units, + "expired_units": expired_units, + "expiring_soon_units": expiring_soon_units, + "needs_calibration_units": needs_calibration_units, + "cal_expiring_today": cal_expiring_today, + "reservations": reservation_list, + "counts": { + "available": len(available_units), + "reserved": len(reserved_units), + "expired": len(expired_units), + "expiring_soon": len(expiring_soon_units), + "needs_calibration": len(needs_calibration_units), + "cal_expiring_today": len(cal_expiring_today), + "total": len(units) + } + } + + +def get_calendar_year_data( + db: Session, + year: int, + device_type: str = "seismograph" +) -> Dict: + """ + Get calendar data for an entire year. + + For performance, this returns summary counts per day rather than + full unit lists. Use get_day_summary() for detailed day data. + """ + # Get user preferences + prefs = db.query(UserPreferences).filter_by(id=1).first() + warning_days = prefs.calibration_warning_days if prefs else 30 + + # Get all units + units = db.query(RosterUnit).filter( + RosterUnit.device_type == device_type, + RosterUnit.retired == False + ).all() + + # Get all reservations that overlap with this year + # Include TBD reservations (end_date is null) that started before year end + year_start = date(year, 1, 1) + year_end = date(year, 12, 31) + + reservations = db.query(JobReservation).filter( + JobReservation.device_type == device_type, + JobReservation.start_date <= year_end, + or_( + JobReservation.end_date >= year_start, + JobReservation.end_date == None # TBD reservations + ) + ).all() + + # Get all unit assignments for these reservations + reservation_ids = [r.id for r in reservations] + assignments = db.query(JobReservationUnit).filter( + JobReservationUnit.reservation_id.in_(reservation_ids) + ).all() if reservation_ids else [] + + # Build a lookup: unit_id -> list of (start_date, end_date, reservation_name) + # For TBD reservations, use estimated_end_date if available, or a far future date + unit_reservations = {} + for res in reservations: + res_assignments = [a for a in assignments if a.reservation_id == res.id] + for assignment in res_assignments: + unit_id = assignment.unit_id + # Use unit-specific dates if set, otherwise use reservation dates + start_d = assignment.unit_start_date or res.start_date + if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd): + # TBD: use estimated date or far future for availability calculation + end_d = res.estimated_end_date or date(year + 5, 12, 31) + else: + end_d = assignment.unit_end_date or res.end_date or date(year + 5, 12, 31) + + if unit_id not in unit_reservations: + unit_reservations[unit_id] = [] + unit_reservations[unit_id].append((start_d, end_d, res.name)) + + # Generate data for each month + months_data = {} + + for month in range(1, 13): + # Get first and last day of month + first_day = date(year, month, 1) + if month == 12: + last_day = date(year, 12, 31) + else: + last_day = date(year, month + 1, 1) - timedelta(days=1) + + days_data = {} + current_day = first_day + + while current_day <= last_day: + available = 0 + reserved = 0 + expired = 0 + expiring_soon = 0 + needs_cal = 0 + cal_expiring_on_day = 0 # Units whose calibration expires ON this day + cal_expired_on_day = 0 # Units whose calibration expired ON this day + + for unit in units: + # Check calibration + cal_status = get_calibration_status(unit, current_day, warning_days) + + # Check if calibration expires/expired ON this specific day + if unit.last_calibrated: + unit_expiry = unit.last_calibrated + timedelta(days=365) + if unit_expiry == current_day: + cal_expiring_on_day += 1 + # Check if expired yesterday (first day of being expired) + elif unit_expiry == current_day - timedelta(days=1): + cal_expired_on_day += 1 + + if cal_status == "expired": + expired += 1 + continue + if cal_status == "needs_calibration": + needs_cal += 1 + continue + + # Check if reserved + is_reserved = False + if unit.id in unit_reservations: + for start_d, end_d, _ in unit_reservations[unit.id]: + if start_d <= current_day <= end_d: + is_reserved = True + break + + if is_reserved: + reserved += 1 + else: + available += 1 + + if cal_status == "expiring_soon": + expiring_soon += 1 + + days_data[current_day.day] = { + "available": available, + "reserved": reserved, + "expired": expired, + "expiring_soon": expiring_soon, + "needs_calibration": needs_cal, + "cal_expiring_on_day": cal_expiring_on_day, + "cal_expired_on_day": cal_expired_on_day + } + + current_day += timedelta(days=1) + + months_data[month] = { + "name": first_day.strftime("%B"), + "short_name": first_day.strftime("%b"), + "days": days_data, + "first_weekday": first_day.weekday(), # 0=Monday, 6=Sunday + "num_days": last_day.day + } + + # Also include reservation summary for the year + reservation_list = [] + for res in reservations: + assigned_count = len([a for a in assignments if a.reservation_id == res.id]) + reservation_list.append({ + "id": res.id, + "name": res.name, + "start_date": res.start_date.isoformat(), + "end_date": res.end_date.isoformat(), + "quantity_needed": res.quantity_needed, + "assigned_count": assigned_count, + "color": res.color + }) + + return { + "year": year, + "device_type": device_type, + "months": months_data, + "reservations": reservation_list, + "total_units": len(units) + } + + +def get_rolling_calendar_data( + db: Session, + start_year: int, + start_month: int, + device_type: str = "seismograph" +) -> Dict: + """ + Get calendar data for 12 months starting from a specific month/year. + + This supports the rolling calendar view where users can scroll through + months one at a time, viewing any 12-month window. + """ + # Get user preferences + prefs = db.query(UserPreferences).filter_by(id=1).first() + warning_days = prefs.calibration_warning_days if prefs else 30 + + # Get all units + units = db.query(RosterUnit).filter( + RosterUnit.device_type == device_type, + RosterUnit.retired == False + ).all() + + # Calculate the date range for 12 months + first_date = date(start_year, start_month, 1) + # Calculate end date (12 months later) + end_year = start_year + 1 if start_month == 1 else start_year + end_month = 12 if start_month == 1 else start_month - 1 + if start_month == 1: + end_year = start_year + end_month = 12 + else: + # 12 months from start_month means we end at start_month - 1 next year + end_year = start_year + 1 + end_month = start_month - 1 + + # Actually, simpler: go 11 months forward from start + end_year = start_year + ((start_month + 10) // 12) + end_month = ((start_month + 10) % 12) + 1 + if end_month == 12: + last_date = date(end_year, 12, 31) + else: + last_date = date(end_year, end_month + 1, 1) - timedelta(days=1) + + # Get all reservations that overlap with this 12-month range + reservations = db.query(JobReservation).filter( + JobReservation.device_type == device_type, + JobReservation.start_date <= last_date, + or_( + JobReservation.end_date >= first_date, + JobReservation.end_date == None # TBD reservations + ) + ).all() + + # Get all unit assignments for these reservations + reservation_ids = [r.id for r in reservations] + assignments = db.query(JobReservationUnit).filter( + JobReservationUnit.reservation_id.in_(reservation_ids) + ).all() if reservation_ids else [] + + # Build a lookup: unit_id -> list of (start_date, end_date, reservation_name) + unit_reservations = {} + for res in reservations: + res_assignments = [a for a in assignments if a.reservation_id == res.id] + for assignment in res_assignments: + unit_id = assignment.unit_id + start_d = assignment.unit_start_date or res.start_date + if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd): + end_d = res.estimated_end_date or date(start_year + 5, 12, 31) + else: + end_d = assignment.unit_end_date or res.end_date or date(start_year + 5, 12, 31) + + if unit_id not in unit_reservations: + unit_reservations[unit_id] = [] + unit_reservations[unit_id].append((start_d, end_d, res.name)) + + # Generate data for each of the 12 months + months_data = [] + current_year = start_year + current_month = start_month + + for i in range(12): + # Calculate this month's year and month + m_year = start_year + ((start_month - 1 + i) // 12) + m_month = ((start_month - 1 + i) % 12) + 1 + + first_day = date(m_year, m_month, 1) + if m_month == 12: + last_day = date(m_year, 12, 31) + else: + last_day = date(m_year, m_month + 1, 1) - timedelta(days=1) + + days_data = {} + current_day = first_day + + while current_day <= last_day: + available = 0 + reserved = 0 + expired = 0 + expiring_soon = 0 + needs_cal = 0 + cal_expiring_on_day = 0 + cal_expired_on_day = 0 + + for unit in units: + cal_status = get_calibration_status(unit, current_day, warning_days) + + if unit.last_calibrated: + unit_expiry = unit.last_calibrated + timedelta(days=365) + if unit_expiry == current_day: + cal_expiring_on_day += 1 + elif unit_expiry == current_day - timedelta(days=1): + cal_expired_on_day += 1 + + if cal_status == "expired": + expired += 1 + continue + if cal_status == "needs_calibration": + needs_cal += 1 + continue + + is_reserved = False + if unit.id in unit_reservations: + for start_d, end_d, _ in unit_reservations[unit.id]: + if start_d <= current_day <= end_d: + is_reserved = True + break + + if is_reserved: + reserved += 1 + else: + available += 1 + + if cal_status == "expiring_soon": + expiring_soon += 1 + + days_data[current_day.day] = { + "available": available, + "reserved": reserved, + "expired": expired, + "expiring_soon": expiring_soon, + "needs_calibration": needs_cal, + "cal_expiring_on_day": cal_expiring_on_day, + "cal_expired_on_day": cal_expired_on_day + } + + current_day += timedelta(days=1) + + months_data.append({ + "year": m_year, + "month": m_month, + "name": first_day.strftime("%B"), + "short_name": first_day.strftime("%b"), + "year_short": first_day.strftime("%y"), + "days": days_data, + "first_weekday": first_day.weekday(), + "num_days": last_day.day + }) + + return { + "start_year": start_year, + "start_month": start_month, + "device_type": device_type, + "months": months_data, + "total_units": len(units) + } + + +def check_calibration_conflicts( + db: Session, + reservation_id: str +) -> List[Dict]: + """ + Check if any units assigned to a reservation will have their + calibration expire during the reservation period. + + Returns list of conflicts with unit info and expiry date. + """ + reservation = db.query(JobReservation).filter_by(id=reservation_id).first() + if not reservation: + return [] + + # Get assigned units + assigned = db.query(JobReservationUnit).filter_by( + reservation_id=reservation_id + ).all() + + conflicts = [] + for assignment in assigned: + unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first() + if not unit or not unit.last_calibrated: + continue + + expiry_date = unit.last_calibrated + timedelta(days=365) + + # Check if expiry falls within reservation period + if reservation.start_date < expiry_date <= reservation.end_date: + conflicts.append({ + "unit_id": unit.id, + "last_calibrated": unit.last_calibrated.isoformat(), + "expiry_date": expiry_date.isoformat(), + "reservation_name": reservation.name, + "days_into_job": (expiry_date - reservation.start_date).days + }) + + return conflicts + + +def get_available_units_for_period( + db: Session, + start_date: date, + end_date: date, + device_type: str = "seismograph", + exclude_reservation_id: Optional[str] = None +) -> List[Dict]: + """ + Get units that are available for the entire specified period. + + A unit is available if: + - Not retired + - Calibration is valid through the end date + - Not assigned to any other reservation that overlaps the period + """ + prefs = db.query(UserPreferences).filter_by(id=1).first() + warning_days = prefs.calibration_warning_days if prefs else 30 + + units = db.query(RosterUnit).filter( + RosterUnit.device_type == device_type, + RosterUnit.retired == False + ).all() + + # Get reservations that overlap with this period + overlapping_reservations = db.query(JobReservation).filter( + JobReservation.device_type == device_type, + JobReservation.start_date <= end_date, + JobReservation.end_date >= start_date + ) + + if exclude_reservation_id: + overlapping_reservations = overlapping_reservations.filter( + JobReservation.id != exclude_reservation_id + ) + + overlapping_reservations = overlapping_reservations.all() + + # Get all units assigned to overlapping reservations + reserved_unit_ids = set() + for res in overlapping_reservations: + assigned = db.query(JobReservationUnit).filter_by( + reservation_id=res.id + ).all() + for a in assigned: + reserved_unit_ids.add(a.unit_id) + + available_units = [] + for unit in units: + # Check if already reserved + if unit.id in reserved_unit_ids: + continue + + # Check calibration through end of period + if not unit.last_calibrated: + continue # Needs calibration + + expiry_date = unit.last_calibrated + timedelta(days=365) + if expiry_date <= end_date: + continue # Calibration expires during period + + cal_status = get_calibration_status(unit, end_date, warning_days) + + available_units.append({ + "id": unit.id, + "last_calibrated": unit.last_calibrated.isoformat(), + "expiry_date": expiry_date.isoformat(), + "calibration_status": cal_status, + "deployed": unit.deployed, + "note": unit.note or "" + }) + + return available_units diff --git a/backend/services/slm_status_sync.py b/backend/services/slm_status_sync.py index 4ec139b..6666bf7 100644 --- a/backend/services/slm_status_sync.py +++ b/backend/services/slm_status_sync.py @@ -70,6 +70,10 @@ async def sync_slm_status_to_emitters() -> Dict[str, Any]: # Convert to naive UTC for consistency with existing code if last_seen.tzinfo: last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None) + elif is_reachable: + # Device is reachable but no last_success yet (first poll or just started) + # Use current time so it shows as OK, not Missing + last_seen = datetime.utcnow() else: last_seen = None diff --git a/docker-compose.yml b/docker-compose.yml index 1de4897..e97357e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,26 +25,28 @@ services: start_period: 40s # --- TERRA-VIEW DEVELOPMENT --- - # terra-view-dev: - # build: . - # container_name: terra-view-dev - # ports: - # - "1001:8001" - # volumes: - # - ./data-dev:/app/data - # environment: - # - PYTHONUNBUFFERED=1 - # - ENVIRONMENT=development - # - SLMM_BASE_URL=http://slmm:8100 - # restart: unless-stopped - # depends_on: - # - slmm - # healthcheck: - # test: ["CMD", "curl", "-f", "http://localhost:8001/health"] - # interval: 30s - # timeout: 10s - # retries: 3 - # start_period: 40s + terra-view-dev: + build: . + container_name: terra-view-dev + ports: + - "1001:8001" + volumes: + - ./data-dev:/app/data + environment: + - PYTHONUNBUFFERED=1 + - ENVIRONMENT=development + - SLMM_BASE_URL=http://host.docker.internal:8100 + restart: unless-stopped + depends_on: + - slmm + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s # --- SLMM (Sound Level Meter Manager) --- slmm: diff --git a/templates/base.html b/templates/base.html index 8d70e07..8e9b431 100644 --- a/templates/base.html +++ b/templates/base.html @@ -151,6 +151,13 @@ Projects + + + + + Fleet Calendar + + diff --git a/templates/fleet_calendar.html b/templates/fleet_calendar.html new file mode 100644 index 0000000..09879cf --- /dev/null +++ b/templates/fleet_calendar.html @@ -0,0 +1,811 @@ +{% extends "base.html" %} + +{% block title %}Fleet Calendar - Terra-View{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+
+
+

Fleet Calendar

+

Plan unit assignments and track calibrations

+
+ +
+
+ + +
+
+

Total Units

+

{{ calendar_data.total_units }}

+
+
+

Available Today

+

--

+
+
+

Reserved Today

+

--

+
+
+

Expiring Soon

+

--

+
+
+

Cal. Expired

+

--

+
+
+ + +
+
+ + + + + + + +
+ + +
+ + +
+ {% for month_data in calendar_data.months %} +
+

+ {{ month_data.short_name }} '{{ month_data.year_short }} +

+
+ +
S
+
M
+
T
+
W
+
T
+
F
+
S
+ + + {% set first_day_offset = (month_data.first_weekday + 1) % 7 %} + {% for i in range(first_day_offset) %} +
+ {% endfor %} + + + {% for day_num in range(1, month_data.num_days + 1) %} + {% set day_data = month_data.days[day_num] %} + {% set date_str = '%04d-%02d-%02d'|format(month_data.year, month_data.month, day_num) %} + {% set is_today = date_str == today %} + {% set has_cal_expiring = day_data.cal_expiring_on_day is defined and day_data.cal_expiring_on_day > 0 %} +
+ {{ day_num }} + {% if has_cal_expiring %} + + {% endif %} +
+ {% endfor %} +
+
+ {% endfor %} +
+ + +
+ + + + + + + {{ calendar_data.months[0].name }} {{ calendar_data.months[0].year }} - {{ calendar_data.months[11].name }} {{ calendar_data.months[11].year }} + + + + + + + + Today + +
+ + +
+

Active Reservations

+
+

Loading reservations...

+
+
+ + +
+
+
+ +
+
+ + + + + +{% endblock %} diff --git a/templates/partials/devices_table.html b/templates/partials/devices_table.html index ac48044..e0dafe5 100644 --- a/templates/partials/devices_table.html +++ b/templates/partials/devices_table.html @@ -115,10 +115,10 @@ {% endif %} {% else %} - {% if unit.next_calibration_due %} + {% if unit.last_calibrated %}
- Cal Due: - {{ unit.next_calibration_due }} + Last Cal: + {{ unit.last_calibrated }}
{% endif %} {% if unit.deployed_with_modem_id %} diff --git a/templates/partials/fleet_calendar/available_units.html b/templates/partials/fleet_calendar/available_units.html new file mode 100644 index 0000000..9190166 --- /dev/null +++ b/templates/partials/fleet_calendar/available_units.html @@ -0,0 +1,40 @@ + +{% if units %} +
+ {% for unit in units %} + + {% endfor %} +
+{% else %} +

+ No units available for this date range. + {% if start_date and end_date %} +
All units are either reserved, have expired calibrations, or are retired. + {% endif %} +

+{% endif %} diff --git a/templates/partials/fleet_calendar/day_detail.html b/templates/partials/fleet_calendar/day_detail.html new file mode 100644 index 0000000..792406a --- /dev/null +++ b/templates/partials/fleet_calendar/day_detail.html @@ -0,0 +1,186 @@ + +
+

{{ date_display }}

+ +
+ + +
+
+

{{ day_data.counts.available }}

+

Available

+
+
+

{{ day_data.counts.reserved }}

+

Reserved

+
+
+

{{ day_data.counts.expiring_soon }}

+

Expiring Soon

+
+
+

{{ day_data.counts.expired }}

+

Cal. Expired

+
+
+ + +{% if day_data.cal_expiring_today %} +
+

+ + + + Calibration Expires Today ({{ day_data.cal_expiring_today|length }}) +

+
+ {% for unit in day_data.cal_expiring_today %} +
+ + {{ unit.id }} + + + Last cal: {{ unit.last_calibrated }} + +
+ {% endfor %} +
+
+{% endif %} + + +{% if day_data.reservations %} +
+

Reservations

+ {% for res in day_data.reservations %} +
+
+

{{ res.name }}

+

+ {{ res.start_date }} - {{ res.end_date }} +

+
+
+

+ {% if res.assignment_type == 'quantity' %} + {{ res.assigned_count }}/{{ res.quantity_needed or '?' }} + {% else %} + {{ res.assigned_count }} units + {% endif %} +

+
+
+ {% endfor %} +
+{% endif %} + + +{% if day_data.available_units %} +
+

+ Available Units ({{ day_data.available_units|length }}) +

+
+ {% for unit in day_data.available_units %} +
+ + {{ unit.id }} + + + {% if unit.last_calibrated %} + Cal: {{ unit.last_calibrated }} + {% else %} + No cal date + {% endif %} + +
+ {% endfor %} +
+
+{% endif %} + + +{% if day_data.reserved_units %} +
+

+ Reserved Units ({{ day_data.reserved_units|length }}) +

+
+ {% for unit in day_data.reserved_units %} +
+ + {{ unit.id }} + + + {{ unit.reservation_name }} + +
+ {% endfor %} +
+
+{% endif %} + + +{% if day_data.expired_units %} +
+

+ Calibration Expired ({{ day_data.expired_units|length }}) +

+
+ {% for unit in day_data.expired_units %} +
+ + {{ unit.id }} + + + Expired: {{ unit.expiry_date }} + +
+ {% endfor %} +
+
+{% endif %} + + +{% if day_data.needs_calibration_units %} +
+

+ Needs Calibration Date ({{ day_data.needs_calibration_units|length }}) +

+
+ {% for unit in day_data.needs_calibration_units %} +
+ + {{ unit.id }} + + No cal date set +
+ {% endfor %} +
+
+{% endif %} + + +{% if day_data.expiring_soon_units %} +
+

+ Calibration Expiring Soon ({{ day_data.expiring_soon_units|length }}) +

+
+ {% for unit in day_data.expiring_soon_units %} +
+ + {{ unit.id }} + + + Expires: {{ unit.expiry_date }} + +
+ {% endfor %} +
+
+{% endif %} diff --git a/templates/partials/fleet_calendar/reservations_list.html b/templates/partials/fleet_calendar/reservations_list.html new file mode 100644 index 0000000..4de84eb --- /dev/null +++ b/templates/partials/fleet_calendar/reservations_list.html @@ -0,0 +1,103 @@ + +{% if reservations %} +
+ {% for item in reservations %} + {% set res = item.reservation %} +
+
+
+

{{ res.name }}

+ {% if item.has_conflicts %} + + {{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }} + + {% endif %} +
+

+ {{ res.start_date.strftime('%b %d, %Y') }} - + {% if res.end_date %} + {{ res.end_date.strftime('%b %d, %Y') }} + {% elif res.end_date_tbd %} + TBD + {% if res.estimated_end_date %} + (est. {{ res.estimated_end_date.strftime('%b %d, %Y') }}) + {% endif %} + {% else %} + Ongoing + {% endif %} +

+ {% if res.notes %} +

{{ res.notes }}

+ {% endif %} +
+
+

+ {% if res.assignment_type == 'quantity' %} + {{ item.assigned_count }}/{{ res.quantity_needed or '?' }} + {% else %} + {{ item.assigned_count }} + {% endif %} +

+

+ {{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }} +

+
+
+ + +
+
+ {% endfor %} +
+ + +{% else %} +
+ + + +

No reservations for {{ year }}

+

Click "New Reservation" to plan unit assignments

+
+{% endif %} diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 31d1731..51f0d66 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -108,10 +108,10 @@
{{ unit.hardware_model }}
{% endif %} {% else %} - {% if unit.next_calibration_due %} + {% if unit.last_calibrated %}
- Cal Due: - {{ unit.next_calibration_due }} + Last Cal: + {{ unit.last_calibrated }}
{% endif %} {% if unit.deployed_with_modem_id %} diff --git a/templates/partials/seismo_unit_list.html b/templates/partials/seismo_unit_list.html index c1187e8..ac9ba15 100644 --- a/templates/partials/seismo_unit_list.html +++ b/templates/partials/seismo_unit_list.html @@ -1,13 +1,92 @@ -{% if units %} +{% if units is defined %}
- - - - - + {% set next_order = 'desc' if (sort == 'id' and order == 'asc') else 'asc' %} + + {% set next_order = 'desc' if (sort == 'status' and order == 'asc') else 'asc' %} + + {% set next_order = 'desc' if (sort == 'modem' and order == 'asc') else 'asc' %} + + {% set next_order = 'desc' if (sort == 'location' and order == 'asc') else 'asc' %} + + {% set next_order = 'desc' if (sort == 'last_calibrated' and order == 'asc') else 'asc' %} + + {% set next_order = 'desc' if (sort == 'notes' and order == 'asc') else 'asc' %} + @@ -54,6 +133,27 @@ {% endif %} +
Unit IDStatusModemLocationNotes + + Unit ID + {% if sort == 'id' %} + + + + {% endif %} + + + + Status + {% if sort == 'status' %} + + + + {% endif %} + + + + Modem + {% if sort == 'modem' %} + + + + {% endif %} + + + + Location + {% if sort == 'location' %} + + + + {% endif %} + + + + Last Calibrated + {% if sort == 'last_calibrated' %} + + + + {% endif %} + + + + Notes + {% if sort == 'notes' %} + + + + {% endif %} + + Actions
+ {% if unit.last_calibrated %} + + {% if unit.next_calibration_due and today %} + {% set days_until = (unit.next_calibration_due - today).days %} + {% if days_until < 0 %} + + {% elif days_until <= 14 %} + + {% else %} + + {% endif %} + {% else %} + + {% endif %} + {{ unit.last_calibrated.strftime('%Y-%m-%d') }} + + {% else %} + + {% endif %} + {% if unit.note %} {{ unit.note }} @@ -72,9 +172,12 @@
-{% if search %} +{% if search or status or modem %}
- Found {{ units|length }} seismograph(s) matching "{{ search }}" + Found {{ units|length }} seismograph(s) + {% if search %} matching "{{ search }}"{% endif %} + {% if status %} ({{ status }}){% endif %} + {% if modem %} ({{ 'with modem' if modem == 'with' else 'without modem' }}){% endif %}
{% endif %} diff --git a/templates/roster.html b/templates/roster.html index df989b7..5407c98 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -145,16 +145,12 @@

Seismograph Information

- - Date of Last Calibration + +

Next calibration due date will be calculated automatically

-
- - -

Typically 1 year after last calibration

-
+