unit history added

This commit is contained in:
serversdwn
2025-12-16 04:38:06 +00:00
parent 191dceff2b
commit d97999e26f
4 changed files with 351 additions and 1 deletions

View File

@@ -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()

View File

@@ -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.

View File

@@ -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}

View File

@@ -178,6 +178,14 @@
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
</div>
<!-- Unit History Timeline -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
<div id="historyTimeline" class="space-y-3">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
</div>
</div>
<!-- Photos -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex justify-between items-start mb-4">
@@ -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 = '<p class="text-sm text-gray-500 dark:text-gray-400">No history yet. Changes will appear here.</p>';
}
} catch (error) {
console.error('Error loading history:', error);
document.getElementById('historyTimeline').innerHTML = '<p class="text-sm text-red-500">Failed to load history</p>';
}
}
// 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': `<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>`,
'deployed_change': `<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`,
'retired_change': `<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`
};
const icon = icons[entry.change_type] || `<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`;
// Format change description
let description = '';
if (entry.change_type === 'note_change') {
description = `<strong>Note changed</strong>`;
if (entry.old_value) {
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">From: "${entry.old_value}"</span>`;
}
if (entry.new_value) {
description += `<br><span class="text-xs text-gray-600 dark:text-gray-300">To: "${entry.new_value}"</span>`;
}
} else if (entry.change_type === 'deployed_change') {
description = `<strong>Status changed to ${entry.new_value}</strong>`;
} else if (entry.change_type === 'retired_change') {
description = `<strong>Marked as ${entry.new_value}</strong>`;
} else {
description = `<strong>${entry.field_name} changed</strong>`;
if (entry.old_value && entry.new_value) {
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">${entry.old_value}${entry.new_value}</span>`;
}
}
// Format timestamp
const timestamp = new Date(entry.changed_at).toLocaleString();
div.innerHTML = `
<div class="flex-shrink-0">
${icon}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-900 dark:text-white">
${description}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
${timestamp}
${entry.source !== 'manual' ? `<span class="ml-2 px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">${entry.source}</span>` : ''}
</div>
</div>
<div class="flex-shrink-0">
<button onclick="deleteHistoryEntry(${entry.id})" class="text-gray-400 hover:text-red-500 transition-colors" title="Delete this history entry">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
`;
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();
});
</script>
{% endblock %}