From e11ac3c6f26341b562fe54e52cdcd1e3718a3950 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 25 May 2026 19:08:07 -0400 Subject: [PATCH] Backend: Export CSV/KML/clipboard, Assign Machine ID, GPS Proximity, Session Management, Batch Assign --- __pycache__/server.cpython-311.pyc | Bin 45689 -> 71586 bytes new_endpoints.py | 0 server.py | 419 ++++++++++++++++++++++++++++- 3 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 new_endpoints.py diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc index e20301d523d62efd295b679e134180c53f60c5d2..f750783b5113d524329088491c8f75a45d4612ee 100644 GIT binary patch delta 29298 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 delta 10444 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 diff --git a/new_endpoints.py b/new_endpoints.py new file mode 100644 index 0000000..e69de29 diff --git a/server.py b/server.py index 945fd5a..809affd 100644 --- a/server.py +++ b/server.py @@ -1,9 +1,9 @@ """EXIF + OCR test backend — validate that GPS survives upload pipeline.""" -import hashlib, io, json, os, re, uuid, sqlite3, urllib.request +import csv, datetime, hashlib, io, json, math, os, re, uuid, sqlite3, urllib.request from pathlib import Path from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from PIL import Image as PILImage @@ -141,6 +141,7 @@ def _file_hash(data: bytes) -> str: def _save_photo_to_db( orig_filename: str, file_hash: str, saved_as: str, file_size: int, exif_result: dict, ocr_result: dict, machine_id: str | None, + session_id: int | None = None, ) -> int: """Insert a photo record. Returns the row id.""" conn = _get_photos_db() @@ -149,8 +150,8 @@ def _save_photo_to_db( """INSERT OR IGNORE INTO photos (orig_filename, file_hash, saved_as, file_size, exif_json, gps_lat, gps_lng, ocr_engine, ocr_model, ocr_raw_text, - ocr_match_5dash6, ocr_match_5plus, machine_id, sticker_color, has_barcode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ocr_match_5dash6, ocr_match_5plus, machine_id, sticker_color, has_barcode, session_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( orig_filename, file_hash, @@ -167,6 +168,7 @@ def _save_photo_to_db( machine_id, ocr_result.get("sticker_color") if ocr_result else None, 1 if ocr_result and ocr_result.get("has_barcode") else 0, + session_id, ), ) conn.commit() @@ -693,6 +695,7 @@ async def bulk_process( ocr_engine: str = Query(default="tesseract"), ocr_model: str = Query(default=""), sticker_mode: bool = Query(default=False), + session_id: int | None = Query(default=None), ): """Process multiple photos: OCR each, extract EXIF GPS, look up matching assets. @@ -793,6 +796,7 @@ async def bulk_process( photo_id = _save_photo_to_db( orig_fname, fhash, saved_name, file_size, exif_result, ocr_result, machine_id, + session_id=session_id, ) if is_dup: @@ -1051,9 +1055,416 @@ async def list_uploads(): ] +# Mount static LAST + +@app.get("/api/export") +async def export_photos( + format: str = Query(default="csv", pattern="^(csv|kml|clipboard)$"), + session_id: int | None = Query(default=None), + limit: int = Query(default=500, le=5000), +): + """Export photos as CSV, KML, or clipboard-ready text. + + Query params: + - format: 'csv' (default), 'kml', or 'clipboard' + - session_id: limit export to a single session + - limit: max rows (default 500, max 5000) + """ + conn = _get_photos_db() + try: + if session_id: + rows = conn.execute( + "SELECT * FROM photos WHERE session_id = ? ORDER BY created_at DESC LIMIT ?", + (session_id, limit), + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM photos ORDER BY created_at DESC LIMIT ?", (limit,) + ).fetchall() + finally: + conn.close() + + records = [] + for r in rows: + d = _row_to_dict(r) + rec = { + "filename": d.get("orig_filename", ""), + "lat": d.get("gps_lat"), + "lng": d.get("gps_lng"), + "machine_id": d.get("machine_id", ""), + "ocr_engine": d.get("ocr_engine", ""), + "sticker_color": d.get("sticker_color", ""), + "timestamp": d.get("created_at", ""), + } + # Look up asset details + if d.get("machine_id"): + asset = lookup_machine_id(d["machine_id"]) + if asset: + rec.update({ + "asset_name": asset.get("name", ""), + "building": asset.get("building_name", ""), + "floor": asset.get("floor", ""), + "room": asset.get("room", ""), + "make": asset.get("make", ""), + "model": asset.get("model", ""), + }) + records.append(rec) + + if format == "csv": + output = io.StringIO() + writer = csv.writer(output) + header = ["filename", "lat", "lng", "machine_id", "asset_name", + "building", "floor", "room", "make", "model", + "ocr_engine", "sticker_color", "timestamp"] + bom = "\ufeff" + output.write(bom) + writer.writerow(header) + for rec in records: + writer.writerow([rec.get(h.replace("-", "_"), "") for h in header]) + csv_content = output.getvalue() + return StreamingResponse( + iter([csv_content]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename=exif-export.csv"}, + ) + + elif format == "kml": + kml_parts = [ + '', + '', + " ", + f" EXIF Export ({len(records)} photos)", + ] + for rec in records: + if rec["lat"] is None or rec["lng"] is None: + continue + name = rec.get("asset_name") or rec.get("machine_id") or rec.get("filename", "Photo") + desc_lines = [ + f"Filename: {rec['filename']}", + f"Machine ID: {rec.get('machine_id', 'N/A')}", + f"OCR Engine: {rec.get('ocr_engine', 'N/A')}", + f"Timestamp: {rec.get('timestamp', 'N/A')}", + ] + kml_parts.append(" ") + kml_parts.append(f" {name}") + kml_parts.append(f" {chr(10).join(desc_lines)}") + kml_parts.append(" ") + kml_parts.append(f" {rec['lng']},{rec['lat']},0") + kml_parts.append(" ") + kml_parts.append(" ") + kml_parts.append(" ") + kml_parts.append("") + kml_content = "\n".join(kml_parts) + return StreamingResponse( + iter([kml_content]), + media_type="application/vnd.google-earth.kml+xml", + headers={"Content-Disposition": f"attachment; filename=exif-export.kml"}, + ) + + else: # clipboard + lines = [] + for rec in records: + if rec["lat"] is not None and rec["lng"] is not None: + label = rec.get("asset_name") or rec.get("machine_id") or rec.get("filename", "?") + lines.append(f"{rec['lat']:.6f},{rec['lng']:.6f} # {label}") + return {"count": len(lines), "text": "\n".join(lines)} + + +# ---------------------------------------------------------------------------# +# Inline Machine ID Edit — Feature #5 # +# ---------------------------------------------------------------------------# +@app.post("/api/assign-machine-id") +async def assign_machine_id(request: dict): + """Manually assign a machine_id to a photo record. + + Body: { "photo_id": int, "machine_id": str } + Returns: { "photo_id": int, "machine_id": str, "asset": dict|null, "needs_gps": bool } + """ + photo_id = request.get("photo_id") + machine_id = (request.get("machine_id") or "").strip() + if not photo_id or not machine_id: + raise HTTPException(400, "photo_id and machine_id are required") + + conn = _get_photos_db() + try: + row = conn.execute("SELECT id, gps_lat, gps_lng FROM photos WHERE id = ?", (photo_id,)).fetchone() + if not row: + raise HTTPException(404, f"Photo {photo_id} not found") + + conn.execute( + "UPDATE photos SET machine_id = ? WHERE id = ?", + (machine_id, photo_id), + ) + conn.commit() + finally: + conn.close() + + asset = lookup_machine_id(machine_id) + has_photo_gps = row["gps_lat"] is not None and row["gps_lng"] is not None + needs_gps = bool(asset and (asset["latitude"] is None or asset["longitude"] is None) and has_photo_gps) + + return { + "photo_id": photo_id, + "machine_id": machine_id, + "asset": asset, + "needs_gps": needs_gps, + } + + +# ---------------------------------------------------------------------------# +# GPS Proximity — Feature #6 # +# ---------------------------------------------------------------------------# +@app.get("/api/nearby") +async def nearby_assets( + lat: float = Query(...), + lng: float = Query(...), + radius: float = Query(default=10.0, ge=1, le=1000), + exclude_photo_id: int | None = Query(default=None), +): + """Find canteen assets near a GPS point. + + Uses Euclidean approximation over lat/lng (accurate at small radii <1km). + 1 deg lat ~ 111,320m, 1 deg lng ~ 111,320 * cos(lat). + """ + conn = _get_canteen_db() + if not conn: + return {"nearby": [], "count": 0, "radius_m": radius} + + try: + lat_rad = lat * 3.14159265 / 180.0 + deg_per_m_lat = 1.0 / 111320.0 + deg_per_m_lng = 1.0 / (111320.0 * math.cos(lat_rad)) + rad_deg = radius * deg_per_m_lat + + rows = conn.execute( + """SELECT id, machine_id, name, latitude, longitude, + building_name, floor, room, category, status + FROM assets + WHERE latitude IS NOT NULL AND longitude IS NOT NULL + AND (latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?) < ? + ORDER BY (latitude - ?) * (latitude - ?) + (longitude - ?) * (longitude - ?) + LIMIT 50""", + (lat, lat, lng, lng, rad_deg * rad_deg, + lat, lat, lng, lng), + ).fetchall() + + nearby = [] + for row in rows: + dlat = row["latitude"] - lat + dlng = (row["longitude"] - lng) * math.cos(lat_rad) + distance_m = (dlat * dlat + dlng * dlng) ** 0.5 * 111320.0 + if distance_m >= radius: + continue + nearby.append({ + "asset_id": row["id"], + "machine_id": row["machine_id"], + "name": row["name"], + "distance_m": round(distance_m, 1), + "latitude": row["latitude"], + "longitude": row["longitude"], + "has_gps": True, + "building": row.get("building_name"), + "floor": row.get("floor"), + "room": row.get("room"), + "category": row.get("category"), + "status": row.get("status"), + }) + nearby.sort(key=lambda x: x["distance_m"]) + return {"nearby": nearby, "count": len(nearby), "radius_m": radius} + finally: + conn.close() + + +# ---------------------------------------------------------------------------# +# Session Management — Feature #9 # +# ---------------------------------------------------------------------------# +def _init_sessions_table(): + """Migrate photos.db: create sessions table, add session_id column.""" + conn = sqlite3.connect(str(PHOTOS_DB)) + try: + conn.execute("""CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', + building TEXT DEFAULT '', + floor TEXT DEFAULT '', + photo_count INTEGER DEFAULT 0, + matched_count INTEGER DEFAULT 0, + needs_gps_count INTEGER DEFAULT 0, + closed_at TEXT, + created_at TEXT DEFAULT (datetime('now')) + )""") + cols = [r[1] for r in conn.execute("PRAGMA table_info(photos)").fetchall()] + if "session_id" not in cols: + conn.execute("ALTER TABLE photos ADD COLUMN session_id INTEGER REFERENCES sessions(id)") + conn.commit() + finally: + conn.close() + + +_init_sessions_table() + + +@app.get("/api/sessions") +async def list_sessions(): + """List all sessions, newest first.""" + conn = _get_photos_db() + try: + rows = conn.execute( + "SELECT * FROM sessions ORDER BY created_at DESC LIMIT 100" + ).fetchall() + return {"sessions": [_row_to_dict(r) for r in rows]} + finally: + conn.close() + + +@app.post("/api/sessions") +async def create_session(request: dict): + """Create a new session. + + Body: { name?, building?, floor? } + Returns: { id, ...session_fields } + """ + name = (request.get("name") or "").strip() or f"Session {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}" + building = (request.get("building") or "").strip() + floor = (request.get("floor") or "").strip() + conn = _get_photos_db() + try: + cur = conn.execute( + "INSERT INTO sessions (name, building, floor) VALUES (?, ?, ?)", + (name, building, floor), + ) + conn.commit() + session_id = cur.lastrowid + row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + return _row_to_dict(row) + finally: + conn.close() + + +@app.get("/api/sessions/{session_id}") +async def get_session(session_id: int): + """Get a session with all its photos.""" + conn = _get_photos_db() + try: + srow = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not srow: + raise HTTPException(404, "Session not found") + session = _row_to_dict(srow) + photos = conn.execute( + "SELECT * FROM photos WHERE session_id = ? ORDER BY created_at ASC", + (session_id,), + ).fetchall() + session["photos"] = [_row_to_dict(r) for r in photos] + total = len(session["photos"]) + matched = sum(1 for p in session["photos"] if p.get("machine_id")) + needs_gps = sum(1 for p in session["photos"] if p.get("machine_id") and (p.get("gps_lat") is None)) + conn.execute( + "UPDATE sessions SET photo_count=?, matched_count=?, needs_gps_count=? WHERE id=?", + (total, matched, needs_gps, session_id), + ) + conn.commit() + session["photo_count"] = total + session["matched_count"] = matched + session["needs_gps_count"] = needs_gps + return session + finally: + conn.close() + + +@app.post("/api/sessions/{session_id}/close") +async def close_session(session_id: int): + """Finalize a session — no more photos can be added.""" + conn = _get_photos_db() + try: + row = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone() + if not row: + raise HTTPException(404, "Session not found") + conn.execute( + "UPDATE sessions SET closed_at = datetime('now') WHERE id = ?", + (session_id,), + ) + conn.commit() + return {"ok": True, "session_id": session_id, "closed_at": "now"} + finally: + conn.close() + + +# ---------------------------------------------------------------------------# +# Batch Machine ID Assign — Feature #8 # +# ---------------------------------------------------------------------------# +@app.post("/api/bulk-assign") +async def bulk_assign_machine_id(request: dict): + """Assign the same machine_id to multiple photos at once. + + Body: { "photo_ids": [int], "machine_id": str } + Returns: { "updated": N, "gps_pushed": M, "no_gps": [...filenames] } + """ + photo_ids = request.get("photo_ids", []) + machine_id = (request.get("machine_id") or "").strip() + + if not photo_ids or not isinstance(photo_ids, list): + raise HTTPException(400, "photo_ids must be a non-empty list") + if not machine_id: + raise HTTPException(400, "machine_id is required") + + asset = lookup_machine_id(machine_id) + conn = _get_photos_db() + no_gps_files = [] + gps_pushed = 0 + + try: + for pid in photo_ids: + row = conn.execute( + "SELECT id, orig_filename, gps_lat, gps_lng, machine_id FROM photos WHERE id = ?", + (pid,), + ).fetchone() + if not row: + continue + + conn.execute( + "UPDATE photos SET machine_id = ? WHERE id = ?", + (machine_id, pid), + ) + + if asset and (asset["latitude"] is None or asset["longitude"] is None): + gps_lat = row["gps_lat"] + gps_lng = row["gps_lng"] + if gps_lat is not None and gps_lng is not None: + canteen = _get_canteen_db() + if canteen: + try: + canteen.execute( + "UPDATE assets SET latitude=?, longitude=?, updated_at=datetime('now') WHERE id=?", + (float(gps_lat), float(gps_lng), asset["id"]), + ) + canteen.commit() + gps_pushed += 1 + finally: + canteen.close() + else: + no_gps_files.append(row.get("orig_filename", f"photo_{pid}")) + + conn.commit() + finally: + conn.close() + + return { + "updated": len(photo_ids), + "gps_pushed": gps_pushed, + "no_gps": no_gps_files, + } + + # Mount static LAST app.mount("/", StaticFiles(directory="static", html=True), name="static") + +# ---------------------------------------------------------------------------# +# Export (CSV / KML / Clipboard) — Feature #4 # +# ---------------------------------------------------------------------------# + + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8903)