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:
@@ -32,8 +32,8 @@ from typing import Any, Optional
|
||||
# ─── Fields to preserve ───────────────────────────────────────────────────────
|
||||
|
||||
PRESERVED_FIELDS = [
|
||||
"lat",
|
||||
"lng",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"photo_path",
|
||||
]
|
||||
|
||||
@@ -137,6 +137,10 @@ def _discover_asset_table(
|
||||
return asset_table["name"], asset_table["id_column"] or "rowid", existing
|
||||
|
||||
|
||||
# ─── GPS-only fields ──────────────────────────────────────────────────────────
|
||||
|
||||
GPS_ONLY_FIELDS = ["latitude", "longitude"]
|
||||
|
||||
# ─── Core operations ──────────────────────────────────────────────────────────
|
||||
|
||||
def backup(db_path: str) -> str:
|
||||
@@ -278,6 +282,137 @@ def restore(backup_path: str, target_db: Optional[str] = None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def backup_gps(db_path: str) -> str:
|
||||
"""
|
||||
Back up only GPS fields (latitude, longitude) from the database.
|
||||
Returns the path to the created backup file.
|
||||
"""
|
||||
db_path = _resolve_path(db_path)
|
||||
conn = _conn(db_path)
|
||||
table, id_col, fields = _discover_asset_table(conn)
|
||||
|
||||
gps_fields = [f for f in GPS_ONLY_FIELDS if f.lower() in [c.lower() for c in fields]]
|
||||
if not gps_fields:
|
||||
print(
|
||||
"⚠️ No GPS fields (latitude, longitude) found in table "
|
||||
f"'{table}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
# Build query
|
||||
col_exprs = ", ".join(f'"{c}"' for c in [id_col] + gps_fields)
|
||||
rows = conn.execute(f'SELECT {col_exprs} FROM "{table}"').fetchall()
|
||||
|
||||
backup_data: dict[str, Any] = {
|
||||
"version": BACKUP_VERSION,
|
||||
"source_db": db_path,
|
||||
"source_table": table,
|
||||
"id_column": id_col,
|
||||
"fields": gps_fields,
|
||||
"created": date.today().isoformat(),
|
||||
"type": "gps_only",
|
||||
"records": [],
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
rec: dict[str, Any] = {"id": row[0]}
|
||||
for i, field in enumerate(gps_fields):
|
||||
rec[field] = row[i + 1]
|
||||
backup_data["records"].append(rec)
|
||||
|
||||
conn.close()
|
||||
|
||||
out_path = _backup_filename().replace(".json.gz", "_gps.json.gz")
|
||||
json_bytes = json.dumps(backup_data, indent=2, default=str).encode("utf-8")
|
||||
with gzip.open(out_path, "wb") as f:
|
||||
f.write(json_bytes)
|
||||
|
||||
record_count = len(backup_data["records"])
|
||||
non_null = sum(
|
||||
1
|
||||
for r in backup_data["records"]
|
||||
if r.get("latitude") is not None or r.get("longitude") is not None
|
||||
)
|
||||
print(
|
||||
f"✅ GPS backup saved: {out_path} "
|
||||
f"({record_count} records, {non_null} with GPS data)"
|
||||
)
|
||||
return out_path
|
||||
|
||||
|
||||
def restore_gps(backup_path: str, target_db: Optional[str] = None) -> None:
|
||||
"""
|
||||
Restore only GPS fields (latitude, longitude) from a backup file.
|
||||
"""
|
||||
backup_path = _resolve_path(backup_path)
|
||||
if not os.path.isfile(backup_path):
|
||||
print(f"❌ Backup file not found: {backup_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with gzip.open(backup_path, "rb") as f:
|
||||
backup_data = json.loads(f.read().decode("utf-8"))
|
||||
|
||||
db_path = target_db or backup_data.get("source_db")
|
||||
if not db_path:
|
||||
print(
|
||||
"❌ No target database specified and no source_db in backup.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
db_path = _resolve_path(db_path)
|
||||
if not os.path.isfile(db_path):
|
||||
print(f"❌ Target database not found: {db_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
table = backup_data["source_table"]
|
||||
id_col = backup_data["id_column"]
|
||||
fields = backup_data["fields"]
|
||||
records = backup_data["records"]
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
# Verify table and columns
|
||||
existing_cols = [
|
||||
r[1]
|
||||
for r in conn.execute(f"PRAGMA table_info({table!r})").fetchall()
|
||||
]
|
||||
existing_lower = [c.lower() for c in existing_cols]
|
||||
|
||||
gps_fields = [f for f in fields if f.lower() in existing_lower]
|
||||
if not gps_fields:
|
||||
print(
|
||||
f"❌ No GPS columns found in target table '{table}'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
|
||||
set_clause = ", ".join(f'"{c}" = ?' for c in gps_fields)
|
||||
sql = f'UPDATE "{table}" SET {set_clause} WHERE "{id_col}" = ?'
|
||||
|
||||
restored = 0
|
||||
skipped = 0
|
||||
for rec in records:
|
||||
values = [rec.get(f) for f in gps_fields] + [rec["id"]]
|
||||
cursor = conn.execute(sql, values)
|
||||
if cursor.rowcount > 0:
|
||||
restored += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print(
|
||||
f"✅ GPS restored {restored} records from {backup_path}\n"
|
||||
f" Fields: {', '.join(gps_fields)}\n"
|
||||
f" Skipped (no matching row): {skipped}"
|
||||
)
|
||||
|
||||
|
||||
def compare(backup_path: str, target_db: Optional[str] = None) -> int:
|
||||
"""
|
||||
Compare backed-up values against current database values.
|
||||
@@ -397,11 +532,21 @@ def main():
|
||||
metavar="DB_PATH",
|
||||
help="Backup GPS (lat, lng) and photo_path from database.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backup-gps",
|
||||
metavar="DB_PATH",
|
||||
help="Backup GPS only (latitude, longitude) from database.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore",
|
||||
metavar="BACKUP_FILE",
|
||||
help="Restore preserved fields from a backup .json.gz file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--restore-gps",
|
||||
metavar="BACKUP_FILE",
|
||||
help="Restore GPS only from a backup .json.gz file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compare",
|
||||
metavar="BACKUP_FILE",
|
||||
@@ -419,20 +564,24 @@ def main():
|
||||
|
||||
# Exactly one mode
|
||||
modes = sum(
|
||||
1 for m in [args.backup, args.restore, args.compare] if m
|
||||
1 for m in [args.backup, args.backup_gps, args.restore, args.restore_gps, args.compare] if m
|
||||
)
|
||||
if modes != 1:
|
||||
parser.print_help()
|
||||
print(
|
||||
"\n❌ Specify exactly one of: --backup, --restore, --compare",
|
||||
"\n❌ Specify exactly one of: --backup, --backup-gps, --restore, --restore-gps, --compare",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if args.backup:
|
||||
backup(args.backup)
|
||||
elif args.backup_gps:
|
||||
backup_gps(args.backup_gps)
|
||||
elif args.restore:
|
||||
restore(args.restore, args.target)
|
||||
elif args.restore_gps:
|
||||
restore_gps(args.restore_gps, args.target)
|
||||
elif args.compare:
|
||||
compare(args.compare, args.target)
|
||||
|
||||
|
||||
+103
-2
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user