ux: activity logging for all CRUD ops, batch status management, dashboard now shows recent activity

This commit is contained in:
Leo
2026-05-30 21:20:49 -04:00
parent 2cafe6879a
commit a3328c765d
2 changed files with 160 additions and 12 deletions
+104 -9
View File
@@ -756,6 +756,9 @@ def update_user(user_id: int, body: UserUpdate):
if body.role is not None:
role = validate_roles(body.role)
conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
_log_activity(conn, "updated", "user", user_id,
f"User '{existing['username']}' role changed to '{role}'")
conn.commit()
if body.password is not None:
pw = body.password.strip()
@@ -764,6 +767,9 @@ def update_user(user_id: int, body: UserUpdate):
raise HTTPException(status_code=422, detail="Password must not be empty")
password_hash = _hash_password(pw)
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
_log_activity(conn, "updated", "user", user_id,
f"User '{existing['username']}' password changed")
conn.commit()
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
@@ -774,12 +780,15 @@ def update_user(user_id: int, body: UserUpdate):
@app.delete("/api/users/{user_id}", status_code=204)
def delete_user(user_id: int):
conn = get_db()
existing = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
existing = conn.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
if existing is None:
conn.close()
raise HTTPException(status_code=404, detail="User not found")
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
_log_activity(conn, "deleted", "user", user_id,
f"User '{existing['username']}' deleted")
conn.commit()
conn.close()
@@ -829,6 +838,9 @@ def login(body: dict):
(row["id"], token),
)
conn.commit()
_log_activity(conn, "login", "user", row["id"],
f"User '{username}' logged in")
conn.commit()
conn.close()
result = _user_to_dict(row)
@@ -992,6 +1004,9 @@ def create_location(body: LocationCreate):
)
conn.commit()
loc_id = cursor.lastrowid
_log_activity(conn, "created", "location", loc_id,
f"Location '{name}' created")
conn.commit()
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
result = row_to_dict(row)
result["rooms"] = []
@@ -1041,7 +1056,7 @@ _LOCATION_FIELDS = [
@app.put("/api/locations/{loc_id}")
def update_location(loc_id: int, body: LocationUpdate):
conn = get_db()
existing = conn.execute("SELECT id FROM locations WHERE id = ?", (loc_id,)).fetchone()
existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
if existing is None:
conn.close()
raise HTTPException(status_code=404, detail="Location not found")
@@ -1077,6 +1092,9 @@ def update_location(loc_id: int, body: LocationUpdate):
values + [loc_id],
)
conn.commit()
_log_activity(conn, "updated", "location", loc_id,
f"Location '{existing['name']}' updated")
conn.commit()
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
result = row_to_dict(row)
@@ -1088,12 +1106,15 @@ def update_location(loc_id: int, body: LocationUpdate):
@app.delete("/api/locations/{loc_id}", status_code=204)
def delete_location(loc_id: int):
conn = get_db()
existing = conn.execute("SELECT id FROM locations WHERE id = ?", (loc_id,)).fetchone()
existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
if existing is None:
conn.close()
raise HTTPException(status_code=404, detail="Location not found")
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
conn.commit()
_log_activity(conn, "deleted", "location", loc_id,
f"Location '{existing['name']}' deleted")
conn.commit()
conn.close()
@@ -1135,6 +1156,9 @@ def create_room(body: RoomCreate):
)
conn.commit()
room_id = cursor.lastrowid
_log_activity(conn, "created", "room", room_id,
f"Room '{body.name.strip()}' created in location {body.location_id}")
conn.commit()
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
conn.close()
return row_to_dict(row)
@@ -1143,7 +1167,7 @@ def create_room(body: RoomCreate):
@app.put("/api/rooms/{room_id}")
def update_room(room_id: int, body: RoomUpdate):
conn = get_db()
existing = conn.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone()
existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
if existing is None:
conn.close()
raise HTTPException(status_code=404, detail="Room not found")
@@ -1169,6 +1193,9 @@ def update_room(room_id: int, body: RoomUpdate):
values + [room_id],
)
conn.commit()
_log_activity(conn, "updated", "room", room_id,
f"Room '{existing['name']}' updated")
conn.commit()
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
conn.close()
@@ -1178,12 +1205,15 @@ def update_room(room_id: int, body: RoomUpdate):
@app.delete("/api/rooms/{room_id}", status_code=204)
def delete_room(room_id: int):
conn = get_db()
existing = conn.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone()
existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
if existing is None:
conn.close()
raise HTTPException(status_code=404, detail="Room not found")
conn.execute("DELETE FROM rooms WHERE id = ?", (room_id,))
conn.commit()
_log_activity(conn, "deleted", "room", room_id,
f"Room '{existing['name']}' deleted")
conn.commit()
conn.close()
@@ -1226,6 +1256,9 @@ def _settings_create(conn: sqlite3.Connection, entity: str, body: dict):
)
conn.commit()
eid = cursor.lastrowid
_log_activity(conn, "created", entity, eid,
f"{entity[:-1].title()} '{body.get('name', '')}' created")
conn.commit()
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
return row_to_dict(row)
except sqlite3.IntegrityError:
@@ -1237,7 +1270,7 @@ def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict
table = schema["table"]
columns = schema["columns"]
existing = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (eid,)).fetchone()
existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
if existing is None:
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
@@ -1253,6 +1286,9 @@ def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict
f"UPDATE {table} SET {set_clause} WHERE id = ?", values + [eid]
)
conn.commit()
_log_activity(conn, "updated", entity, eid,
f"{entity[:-1].title()} '{existing['name']}' updated")
conn.commit()
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
return row_to_dict(row)
@@ -1270,11 +1306,14 @@ def _settings_get(conn: sqlite3.Connection, entity: str, eid: int):
def _settings_delete(conn: sqlite3.Connection, entity: str, eid: int):
schema = _SETTINGS_SCHEMAS[entity]
table = schema["table"]
existing = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (eid,)).fetchone()
existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
if existing is None:
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
conn.execute(f"DELETE FROM {table} WHERE id = ?", (eid,))
conn.commit()
_log_activity(conn, "deleted", entity, eid,
f"{entity[:-1].title()} '{existing['name']}' deleted")
conn.commit()
_SETTINGS_ENTITIES = ["categories", "makes", "models", "key_names", "key_types", "badge_types"]
@@ -1376,7 +1415,7 @@ def list_geofences():
def update_geofence(geofence_id: int, body: GeofenceUpdate):
conn = get_db()
existing = conn.execute(
"SELECT id FROM geofences WHERE id = ?", (geofence_id,)
"SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
).fetchone()
if existing is None:
conn.close()
@@ -1407,6 +1446,10 @@ def update_geofence(geofence_id: int, body: GeofenceUpdate):
_sync_geofence_users(conn, geofence_id, body.user_ids)
conn.commit()
_log_activity(conn, "updated", "geofence", geofence_id,
f"Geofence '{existing['name']}' updated")
conn.commit()
row = conn.execute(
"SELECT * FROM geofences WHERE id = ?", (geofence_id,)
).fetchone()
@@ -1419,13 +1462,16 @@ def update_geofence(geofence_id: int, body: GeofenceUpdate):
def delete_geofence(geofence_id: int):
conn = get_db()
existing = conn.execute(
"SELECT id FROM geofences WHERE id = ?", (geofence_id,)
"SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
).fetchone()
if existing is None:
conn.close()
raise HTTPException(status_code=404, detail="Geofence not found")
conn.execute("DELETE FROM geofences WHERE id = ?", (geofence_id,))
conn.commit()
_log_activity(conn, "deleted", "geofence", geofence_id,
f"Geofence '{existing['name']}' deleted")
conn.commit()
conn.close()
@@ -1761,6 +1807,51 @@ def clear_asset_gps(asset_id: int):
conn.close()
# ─── Batch Status Update API ─────────────────────────────────────────────────
class BatchStatusUpdate(BaseModel):
ids: list[int]
status: str
@app.post("/api/assets/batch-status")
def batch_update_status(body: BatchStatusUpdate):
"""Update the status of multiple assets at once."""
if not body.ids:
raise HTTPException(status_code=422, detail="No asset IDs provided")
if body.status not in VALID_STATUSES:
raise HTTPException(
status_code=422,
detail=f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}",
)
conn = get_db()
placeholders = ",".join(["?"] * len(body.ids))
# Verify all assets exist
existing = conn.execute(
f"SELECT id, name FROM assets WHERE id IN ({placeholders})", body.ids
).fetchall()
if len(existing) != len(body.ids):
conn.close()
raise HTTPException(status_code=404, detail="One or more asset IDs not found")
# Update all assets
conn.execute(
f"UPDATE assets SET status = ?, updated_at = datetime('now') WHERE id IN ({placeholders})",
[body.status] + body.ids,
)
_log_activity(conn, "updated", "asset", 0,
f"Batch status change: {len(body.ids)} assets set to '{body.status}'")
conn.commit()
conn.close()
return {"updated": len(body.ids), "status": body.status}
# ─── Icon Upload API ────────────────────────────────────────────────────────
@@ -1795,6 +1886,10 @@ def reset_database(request: Request):
try:
# Disable FK checks for clean drop
conn.execute("PRAGMA foreign_keys=OFF")
username = user.get("username", "Unknown") if user else "Unknown"
_log_activity(conn, "reset", "database", 0,
f"Database reset by '{username}'")
conn.commit()
tables = [
"visits", "activity_log", "geofence_users", "geofences",
"asset_badges", "asset_keys", "checkins", "sessions",
+56 -3
View File
@@ -1110,6 +1110,7 @@
</select>
<button class="btn btn-sm btn-outline" onclick="loadAssets()">🔄 Refresh</button>
<button class="btn btn-sm btn-accent" onclick="batchLookupAll()">🔍 Batch Lookup</button>
<button class="btn btn-sm btn-outline" onclick="showBatchStatusModal()">🏷️ Batch Status</button>
</div>
</div>
<div id="assetsContent">
@@ -1569,7 +1570,7 @@ function renderDashStats(stats) {
function renderBar(elId, items, labelKey, valueKey, max) {
const el = document.getElementById(elId);
if (!items || items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No data</div>';
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No data available — try different filters or check back later</div>';
return;
}
const mx = max || Math.max(...items.map(i => i[valueKey]));
@@ -1599,7 +1600,7 @@ function renderDashStatusBars(stats) {
function renderDashActivity(items) {
const el = document.getElementById('dashActivity');
if (!items || items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No recent activity</div>';
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No recent activity — check your filters</div>';
return;
}
el.innerHTML = items.map(a => {
@@ -2069,6 +2070,58 @@ async function applyAllFoundNames() {
}
showToast(`Applied ${count} names`);
}
// ── Batch Status Update ──
async function showBatchStatusModal() {
const confirmed = await showModal(
'🏷️ Batch Status Update',
`<div style="font-size:13px;color:var(--text2);margin-bottom:12px;">
Select assets by ID (from the current search results) and choose a new status.
</div>
<div style="margin-bottom:12px;">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;color:var(--text);">Asset IDs (comma-separated)</label>
<input id="batchStatusIds" type="text" class="input-field" placeholder="e.g. 1, 5, 23, 44" style="width:100%;box-sizing:border-box;">
</div>
<div style="margin-bottom:12px;">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;color:var(--text);">New Status</label>
<select id="batchStatusValue" class="input-field" style="width:100%;box-sizing:border-box;">
<option value="active">✅ Active</option>
<option value="maintenance" selected>🔄 Maintenance</option>
<option value="retired">⛔ Retired</option>
</select>
</div>
<div id="batchStatusResult" style="font-size:13px;"></div>`,
'Apply',
'btn-accent'
);
if (!confirmed) return;
const idsInput = document.getElementById('batchStatusIds');
const statusSelect = document.getElementById('batchStatusValue');
const resultEl = document.getElementById('batchStatusResult');
const ids = (idsInput.value || '').split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0);
if (ids.length === 0) {
resultEl.innerHTML = '<span style="color:var(--red);">❌ No valid asset IDs entered</span>';
return;
}
const status = statusSelect.value;
resultEl.innerHTML = '<span style="color:var(--text2);">⏳ Updating...</span>';
try {
const resp = await api('/api/assets/batch-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, status }),
});
resultEl.innerHTML = `<span style="color:var(--green);">✅ ${resp.updated} asset(s) set to "${status}"</span>`;
loadAssets(); // Refresh the asset table
} catch (e) {
resultEl.innerHTML = `<span style="color:var(--red);">❌ ${esc(e.message)}</span>`;
}
}
// ═════════════════════════════════════════════════════════════════════════════
async function loadSettingsCache() {
try {
@@ -2912,7 +2965,7 @@ async function _loadActivity() {
function renderActivity(items) {
const el = document.getElementById('activityList');
if (items.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No activity found</div>';
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No activity logged yet. Activity appears here when admins make changes.</div>';
document.getElementById('activityPagination').innerHTML = '';
return;
}