From d97999e26f1f2ce7ca270a8653e0e319cf07c2e8 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 16 Dec 2025 04:38:06 +0000 Subject: [PATCH] unit history added --- backend/migrate_add_unit_history.py | 78 +++++++++++++++++ backend/models.py | 18 ++++ backend/routers/roster_edit.py | 127 ++++++++++++++++++++++++++- templates/unit_detail.html | 129 ++++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 backend/migrate_add_unit_history.py diff --git a/backend/migrate_add_unit_history.py b/backend/migrate_add_unit_history.py new file mode 100644 index 0000000..15cdaad --- /dev/null +++ b/backend/migrate_add_unit_history.py @@ -0,0 +1,78 @@ +""" +Migration script to add unit history timeline support. + +This creates the unit_history table to track all changes to units: +- Note changes (archived old notes, new notes) +- Deployment status changes (deployed/benched) +- Retired status changes +- Other field changes + +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 unit_history table""" + + 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 unit_history table already exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='unit_history'") + if cursor.fetchone(): + print("Migration already applied - unit_history table exists") + conn.close() + return + + print("Creating unit_history table...") + + try: + cursor.execute(""" + CREATE TABLE unit_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL, + change_type TEXT NOT NULL, + field_name TEXT, + old_value TEXT, + new_value TEXT, + changed_at TIMESTAMP NOT NULL, + source TEXT DEFAULT 'manual', + notes TEXT + ) + """) + print(" ✓ Created unit_history table") + + # Create indexes for better query performance + cursor.execute("CREATE INDEX idx_unit_history_unit_id ON unit_history(unit_id)") + print(" ✓ Created index on unit_id") + + cursor.execute("CREATE INDEX idx_unit_history_changed_at ON unit_history(changed_at)") + print(" ✓ Created index on changed_at") + + conn.commit() + print("\nMigration completed successfully!") + print("Units will now track their complete history of changes.") + + 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/models.py b/backend/models.py index 4b36061..bf3f32d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -59,6 +59,24 @@ class IgnoredUnit(Base): ignored_at = Column(DateTime, default=datetime.utcnow) +class UnitHistory(Base): + """ + Unit history: complete timeline of changes to each unit. + Tracks note changes, status changes, deployment/benched events, and more. + """ + __tablename__ = "unit_history" + + id = Column(Integer, primary_key=True, autoincrement=True) + unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id + change_type = Column(String, nullable=False) # note_change, deployed_change, retired_change, etc. + field_name = Column(String, nullable=True) # Which field changed + old_value = Column(Text, nullable=True) # Previous value + new_value = Column(Text, nullable=True) # New value + changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + source = Column(String, default="manual") # manual, csv_import, telemetry, offline_sync + notes = Column(Text, nullable=True) # Optional reason/context for the change + + class UserPreferences(Base): """ User preferences: persistent storage for application settings. diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index e1ab7ca..a495885 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -5,11 +5,28 @@ import csv import io from backend.database import get_db -from backend.models import RosterUnit, IgnoredUnit, Emitter +from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) +def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None, + old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None): + """Helper function to record a change in unit history""" + history_entry = UnitHistory( + unit_id=unit_id, + change_type=change_type, + field_name=field_name, + old_value=old_value, + new_value=new_value, + changed_at=datetime.utcnow(), + source=source, + notes=notes + ) + db.add(history_entry) + # Note: caller is responsible for db.commit() + + def get_or_create_roster_unit(db: Session, unit_id: str): unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() if not unit: @@ -154,6 +171,11 @@ def edit_roster_unit( except ValueError: raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD") + # Track changes for history + old_note = unit.note + old_deployed = unit.deployed + old_retired = unit.retired + # Update all fields unit.device_type = device_type unit.unit_type = unit_type @@ -176,6 +198,20 @@ def edit_roster_unit( unit.phone_number = phone_number if phone_number else None unit.hardware_model = hardware_model if hardware_model else None + # Record history entries for changed fields + if old_note != note: + record_history(db, unit_id, "note_change", "note", old_note, note, "manual") + + if old_deployed != deployed: + status_text = "deployed" if deployed else "benched" + old_status_text = "deployed" if old_deployed else "benched" + record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual") + + if old_retired != retired: + status_text = "retired" if retired else "active" + old_status_text = "retired" if old_retired else "active" + record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual") + db.commit() return {"message": "Unit updated", "id": unit_id, "device_type": device_type} @@ -183,8 +219,24 @@ def edit_roster_unit( @router.post("/set-deployed/{unit_id}") def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)): unit = get_or_create_roster_unit(db, unit_id) + old_deployed = unit.deployed unit.deployed = deployed unit.last_updated = datetime.utcnow() + + # Record history entry for deployed status change + if old_deployed != deployed: + status_text = "deployed" if deployed else "benched" + old_status_text = "deployed" if old_deployed else "benched" + record_history( + db=db, + unit_id=unit_id, + change_type="deployed_change", + field_name="deployed", + old_value=old_status_text, + new_value=status_text, + source="manual" + ) + db.commit() return {"message": "Updated", "id": unit_id, "deployed": deployed} @@ -192,8 +244,24 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends @router.post("/set-retired/{unit_id}") def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)): unit = get_or_create_roster_unit(db, unit_id) + old_retired = unit.retired unit.retired = retired unit.last_updated = datetime.utcnow() + + # Record history entry for retired status change + if old_retired != retired: + status_text = "retired" if retired else "active" + old_status_text = "retired" if old_retired else "active" + record_history( + db=db, + unit_id=unit_id, + change_type="retired_change", + field_name="retired", + old_value=old_status_text, + new_value=status_text, + source="manual" + ) + db.commit() return {"message": "Updated", "id": unit_id, "retired": retired} @@ -235,8 +303,22 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)): @router.post("/set-note/{unit_id}") def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)): unit = get_or_create_roster_unit(db, unit_id) + old_note = unit.note unit.note = note unit.last_updated = datetime.utcnow() + + # Record history entry for note change + if old_note != note: + record_history( + db=db, + unit_id=unit_id, + change_type="note_change", + field_name="note", + old_value=old_note, + new_value=note, + source="manual" + ) + db.commit() return {"message": "Updated", "id": unit_id, "note": note} @@ -402,3 +484,46 @@ def list_ignored_units(db: Session = Depends(get_db)): for unit in ignored_units ] } + + +@router.get("/history/{unit_id}") +def get_unit_history(unit_id: str, db: Session = Depends(get_db)): + """ + Get complete history timeline for a unit. + Returns all historical changes ordered by most recent first. + """ + history_entries = db.query(UnitHistory).filter( + UnitHistory.unit_id == unit_id + ).order_by(UnitHistory.changed_at.desc()).all() + + return { + "unit_id": unit_id, + "history": [ + { + "id": entry.id, + "change_type": entry.change_type, + "field_name": entry.field_name, + "old_value": entry.old_value, + "new_value": entry.new_value, + "changed_at": entry.changed_at.isoformat(), + "source": entry.source, + "notes": entry.notes + } + for entry in history_entries + ] + } + + +@router.delete("/history/{history_id}") +def delete_history_entry(history_id: int, db: Session = Depends(get_db)): + """ + Delete a specific history entry by ID. + Allows manual cleanup of old history entries. + """ + history_entry = db.query(UnitHistory).filter(UnitHistory.id == history_id).first() + if not history_entry: + raise HTTPException(status_code=404, detail="History entry not found") + + db.delete(history_entry) + db.commit() + return {"message": "History entry deleted", "id": history_id} diff --git a/templates/unit_detail.html b/templates/unit_detail.html index 55ebe6a..ebf4706 100644 --- a/templates/unit_detail.html +++ b/templates/unit_detail.html @@ -178,6 +178,14 @@

--

+ +
+

Timeline

+
+

Loading history...

+
+
+
@@ -762,9 +770,130 @@ async function uploadPhoto(file) { } } +// Load and display unit history timeline +async function loadUnitHistory() { + try { + const response = await fetch(`/api/roster/history/${unitId}`); + if (!response.ok) { + throw new Error('Failed to load history'); + } + + const data = await response.json(); + const timeline = document.getElementById('historyTimeline'); + + if (data.history && data.history.length > 0) { + timeline.innerHTML = ''; + data.history.forEach(entry => { + const timelineEntry = createTimelineEntry(entry); + timeline.appendChild(timelineEntry); + }); + } else { + timeline.innerHTML = '

No history yet. Changes will appear here.

'; + } + } catch (error) { + console.error('Error loading history:', error); + document.getElementById('historyTimeline').innerHTML = '

Failed to load history

'; + } +} + +// Create a timeline entry element +function createTimelineEntry(entry) { + const div = document.createElement('div'); + div.className = 'flex gap-3 p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50'; + + // Icon based on change type + const icons = { + 'note_change': ` + + `, + 'deployed_change': ` + + `, + 'retired_change': ` + + ` + }; + + const icon = icons[entry.change_type] || ` + + `; + + // Format change description + let description = ''; + if (entry.change_type === 'note_change') { + description = `Note changed`; + if (entry.old_value) { + description += `
From: "${entry.old_value}"`; + } + if (entry.new_value) { + description += `
To: "${entry.new_value}"`; + } + } else if (entry.change_type === 'deployed_change') { + description = `Status changed to ${entry.new_value}`; + } else if (entry.change_type === 'retired_change') { + description = `Marked as ${entry.new_value}`; + } else { + description = `${entry.field_name} changed`; + if (entry.old_value && entry.new_value) { + description += `
${entry.old_value} → ${entry.new_value}`; + } + } + + // Format timestamp + const timestamp = new Date(entry.changed_at).toLocaleString(); + + div.innerHTML = ` +
+ ${icon} +
+
+
+ ${description} +
+
+ ${timestamp} + ${entry.source !== 'manual' ? `${entry.source}` : ''} +
+
+
+ +
+ `; + + return div; +} + +// Delete a history entry +async function deleteHistoryEntry(historyId) { + if (!confirm('Are you sure you want to delete this history entry?')) { + return; + } + + try { + const response = await fetch(`/api/roster/history/${historyId}`, { + method: 'DELETE' + }); + + if (response.ok) { + // Reload history + await loadUnitHistory(); + } else { + const result = await response.json(); + alert(`Error: ${result.detail || 'Unknown error'}`); + } + } catch (error) { + alert(`Error: ${error.message}`); + } +} + // Load data when page loads loadUnitData().then(() => { loadPhotos(); + loadUnitHistory(); }); {% endblock %}