ux: activity logging for all CRUD ops, batch status management, dashboard now shows recent activity
This commit is contained in:
+104
-9
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user