Add GPS-only backup/restore and database wipe

- Fix backup.py PRESERVED_FIELDS column names (lat/lng -> latitude/longitude)
  to match actual DB schema (pre-existing bug)
- Add backup_gps() and restore_gps() dedicated GPS-only functions
- Add /api/gps/backup and /api/gps/restore API endpoints
- Add /api/gps/restore/upload for restoring from uploaded backup files
- Add /api/db/wipe endpoint to clear all asset records
- Update UI with GPS-only Backup button, Restore GPS from File picker,
  and Wipe All Assets with double-confirmation dialog
- Fix backup listing to scan both seed-data/ and project root
This commit is contained in:
2026-05-24 23:07:12 -04:00
parent 724104b750
commit be9c34f51d
3 changed files with 337 additions and 8 deletions
+103 -2
View File
@@ -43,8 +43,16 @@ from backup import backup as do_backup, compare as compare_backup
def _list_backup_files() -> list[dict]:
"""List backup files in the seed-data directory."""
files = sorted(glob.glob(str(PROJECT_ROOT / "seed-data" / "backup_*.json.gz")), reverse=True)
"""List backup files in the seed-data directory OR project root."""
patterns = [
str(PROJECT_ROOT / "seed-data" / "backup_*.json.gz"),
str(PROJECT_ROOT / "backup_*.json.gz"),
]
files = set()
for pat in patterns:
for f in glob.glob(pat):
files.add(f)
files = sorted(files, reverse=True)
result = []
for f in files:
try:
@@ -446,6 +454,99 @@ async def api_restore(
raise HTTPException(500, f"Restore error: {e}")
# ─── GPS-only endpoints ───────────────────────────────────────────────────────
@app.post("/api/gps/backup")
async def api_gps_backup(db_path: str = Query(DEFAULT_DB_PATH)):
"""Backup GPS data only (latitude, longitude) — ideal before wiping."""
if not os.path.exists(db_path):
raise HTTPException(404, f"Database not found: {db_path}")
try:
from backup import backup_gps as do_backup_gps
path = do_backup_gps(db_path)
_session_state["last_backup"] = path
return {"backup_path": path, "status": "ok", "type": "gps_only"}
except Exception as e:
traceback.print_exc()
raise HTTPException(500, f"GPS backup error: {e}")
@app.post("/api/gps/restore")
async def api_gps_restore(
backup_path: str = Query(...),
db_path: str = Query(DEFAULT_DB_PATH),
):
"""Restore GPS data only (latitude, longitude) from a GPS-only backup."""
if not os.path.exists(backup_path):
raise HTTPException(404, f"Backup not found: {backup_path}")
try:
from backup import restore_gps as do_restore_gps
do_restore_gps(backup_path, db_path)
return {"status": "ok", "restored_from": os.path.basename(backup_path), "db": db_path}
except Exception as e:
traceback.print_exc()
raise HTTPException(500, f"GPS restore error: {e}")
@app.post("/api/gps/restore/upload")
async def api_gps_restore_upload(
backup_file: UploadFile = File(...),
db_path: str = Query(DEFAULT_DB_PATH),
):
"""Upload a GPS backup file and restore it into the database."""
if not backup_file.filename:
raise HTTPException(400, "No file provided")
suffix = Path(backup_file.filename).suffix.lower()
if suffix not in (".gz", ".json"):
raise HTTPException(400, f"Unsupported file type: {suffix}. Upload .json.gz or .json")
try:
# Save to temp
import tempfile
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".json.gz")
content = await backup_file.read()
tmp.write(content)
tmp_path = tmp.name
tmp.close()
from backup import restore_gps as do_restore_gps
do_restore_gps(tmp_path, db_path)
os.unlink(tmp_path)
return {"status": "ok", "restored_from": backup_file.filename, "db": db_path}
except Exception as e:
traceback.print_exc()
raise HTTPException(500, f"GPS restore error: {e}")
# ─── Database management ───────────────────────────────────────────────────────
@app.post("/api/db/wipe")
async def api_db_wipe(db_path: str = Query(DEFAULT_DB_PATH)):
"""Wipe all asset records from the database. Does NOT delete the file."""
if not os.path.exists(db_path):
raise HTTPException(404, f"Database not found: {db_path}")
try:
import sqlite3
conn = sqlite3.connect(db_path)
cur = conn.execute("SELECT COUNT(*) FROM assets")
before = cur.fetchone()[0]
conn.execute("DELETE FROM assets")
conn.commit()
conn.close()
return {
"status": "ok",
"records_deleted": before,
"db_path": db_path,
"note": "All asset records wiped. Database file preserved.",
}
except Exception as e:
traceback.print_exc()
raise HTTPException(500, f"Wipe error: {e}")
# ─── Static files ─────────────────────────────────────────────────────────────
STATIC_DIR = Path(__file__).parent / "static"
+81 -2
View File
@@ -575,8 +575,9 @@
<!-- Backup Management ───────────────────────────────────────────────── -->
<div class="card" id="cardBackup" style="margin-top:20px">
<h2>💾 Backup Management</h2>
<div class="btn-row" style="margin-bottom:12px">
<button class="btn btn-primary btn-sm" onclick="createBackup()">💾 Create Backup</button>
<div class="btn-row" style="margin-bottom:8px">
<button class="btn btn-primary btn-sm" onclick="createBackup()">💾 Full Backup</button>
<button class="btn btn-sm" style="background:var(--accent2);color:#fff;border:none;cursor:pointer;border-radius:6px;padding:6px 14px;font-size:13px" onclick="createGpsBackup()">📍 GPS-Only Backup</button>
<span id="backupStatus" style="font-size:13px;color:var(--text2)"></span>
</div>
<div id="backupList">
@@ -584,6 +585,20 @@
</div>
</div>
<!-- Database Management ────────────────────────────────────────────── -->
<div class="card" style="margin-top:20px;border-color:rgba(255,80,80,0.2);">
<h2 style="color:var(--red);">⚠️ Database Management</h2>
<div style="font-size:13px;color:var(--text3);margin-bottom:12px;">
These operations modify the underlying asset database directly.
Always <strong>backup GPS first</strong> to avoid losing coordinate data.
</div>
<div class="btn-row" style="gap:8px;flex-wrap:wrap;">
<button class="btn btn-danger btn-sm" onclick="restoreFromFile()">📍 Restore GPS from File</button>
<button class="btn btn-danger btn-sm" onclick="wipeDatabase()">🗑️ Wipe All Assets</button>
<span id="dbManageStatus" style="font-size:13px;color:var(--text2)"></span>
</div>
</div>
</div>
<script>
@@ -999,6 +1014,70 @@
setTimeout(loadBackupList, 2000);
}
}
// ─── GPS-only Backup ──────────────────────────────────────────────
async function createGpsBackup() {
const statusEl = document.getElementById('backupStatus');
statusEl.innerHTML = '<span class="spinner"></span> Backing up GPS...';
try {
const res = await fetch('/api/gps/backup', { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).detail || 'GPS backup failed');
const d = await res.json();
statusEl.innerHTML = `<span style="color:var(--green)">✅ GPS backup: ${esc(d.backup_path)}</span>`;
loadBackupList();
refreshDashboard();
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
}
}
// ─── Restore GPS from File (file picker) ──────────────────────────
function restoreFromFile() {
// Create hidden file input for .json.gz files
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json.gz,.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const statusEl = document.getElementById('dbManageStatus');
statusEl.innerHTML = `<span class="spinner"></span> Uploading & restoring GPS from ${esc(file.name)}...`;
// Upload to temp location then restore
const form = new FormData();
form.append('backup_file', file);
try {
const res = await fetch('/api/gps/restore/upload', { method: 'POST', body: form });
if (!res.ok) throw new Error((await res.json()).detail || 'Restore failed');
const d = await res.json();
statusEl.innerHTML = `<span style="color:var(--green)">✅ GPS restored from: ${esc(d.restored_from)}</span>`;
refreshDashboard();
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
}
};
input.click();
}
// ─── Wipe Database ────────────────────────────────────────────────
async function wipeDatabase() {
if (!confirm('⚠️ This will DELETE ALL ASSET RECORDS from the database. This cannot be undone without a backup.\n\nHave you backed up GPS coordinates first?')) return;
if (!confirm('Final confirmation: Are you sure you want to delete ALL assets?')) return;
const statusEl = document.getElementById('dbManageStatus');
statusEl.innerHTML = '<span class="spinner"></span> Wiping database...';
try {
const res = await fetch('/api/db/wipe', { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).detail || 'Wipe failed');
const d = await res.json();
statusEl.innerHTML = `<span style="color:var(--red)">🗑️ Deleted ${d.records_deleted} records. Database is now empty.</span>`;
refreshDashboard();
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
}
}
</script>
</body>
</html>