510bafc919
- Added validate_roles() and roles_contain() helpers for comma-separated roles
- Updated create_user/update_user to accept role combinations (e.g. 'admin,technician')
- Changed all exact role queries (='technician', IN('technician','admin')) to LIKE
- Replaced single <select> with checkboxes in add/edit user modals
- Added renderRoleBadges() to display multiple role badges per user
- Added checkbox-group/checkbox-label CSS styles
- Updated tech-photo-upload technician query to use LIKE
- Updated db reset auth check to use roles_contain()
- Set shawn's role to 'admin,technician'
- Cleaned up unused imports (hashlib, io, etc.)
Applies to canteen-admin-server, canteen-admin-server-dev, and tech-photo-upload
1297 lines
49 KiB
Python
1297 lines
49 KiB
Python
"""
|
|
EXIF Scanner — Admin server integration module.
|
|
|
|
Ports the exif-test server functionality into the admin server as an APIRouter.
|
|
Shares the same assets.db (via CANTEEN_DB_PATH) and uses the exif-test photos.db
|
|
(via EXIF_PHOTOS_DB) so both servers can access the same photo data.
|
|
|
|
Mounted at /api/admin/exif and automatically protected by the admin server's auth middleware.
|
|
"""
|
|
import base64
|
|
import csv
|
|
import datetime
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import logging
|
|
import math
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import urllib.request
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
from PIL import Image as PILImage
|
|
|
|
# Optional HEIC/HEIF support for iPhone photos
|
|
try:
|
|
from pillow_heif import register_heif_opener
|
|
register_heif_opener()
|
|
HAS_HEIF = True
|
|
except ImportError:
|
|
HAS_HEIF = False
|
|
|
|
try:
|
|
import pytesseract
|
|
HAS_TESSERACT = True
|
|
except ImportError:
|
|
HAS_TESSERACT = False
|
|
|
|
logger = logging.getLogger("exif_module")
|
|
|
|
# ─── Config ─────────────────────────────────────────────────────────────────
|
|
|
|
# Path to the exif-test photos database (shared between exif-test and admin)
|
|
EXIF_PHOTOS_DB = Path(os.environ.get(
|
|
"EXIF_PHOTOS_DB",
|
|
str(Path(__file__).parent.parent / "exif-test" / "photos.db")
|
|
))
|
|
|
|
# Path to the exif-test uploads directory
|
|
EXIF_UPLOADS_DIR = Path(os.environ.get(
|
|
"EXIF_UPLOADS_DIR",
|
|
str(Path(__file__).parent.parent / "exif-test" / "uploads")
|
|
))
|
|
|
|
# Canteen asset DB (shared — same as admin_server)
|
|
CANTEEN_DB_PATH = os.environ.get(
|
|
"CANTEEN_DB_PATH",
|
|
str(Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db")
|
|
)
|
|
|
|
# LLM OCR config
|
|
OPENCODE_GO_KEY = (os.environ.get("OPENCODE_GO_API_KEY") or "").strip()
|
|
OPENCODE_GO_BASE = (os.environ.get("OPENCODE_GO_BASE_URL") or "https://opencode.ai/zen/go/v1").strip()
|
|
LLM_OCR_MODEL = (os.environ.get("LLM_OCR_MODEL") or "mimo-v2-omni").strip()
|
|
|
|
GOOGLE_API_KEY = (os.environ.get("GOOGLE_API_KEY") or "").strip()
|
|
GOOGLE_API_BASE = (os.environ.get("GOOGLE_API_BASE_URL") or "https://generativelanguage.googleapis.com/v1beta").strip()
|
|
GOOGLE_OCR_MODEL = (os.environ.get("GOOGLE_OCR_MODEL") or "gemini-2.5-flash").strip()
|
|
|
|
# Max images per batch API call
|
|
BATCH_SIZE_LIMIT = 20
|
|
# Downscale images to this max dimension before LLM OCR
|
|
LLM_IMAGE_MAX_DIM = 1600
|
|
|
|
# OCR prompts
|
|
DEFAULT_OCR_PROMPT = (
|
|
"Read ALL text and numbers visible in this photo. "
|
|
"Return the exact text shown, nothing else."
|
|
)
|
|
|
|
STICKER_OCR_PROMPT = (
|
|
"This photo shows a colored equipment sticker (green, orange, or yellow background) "
|
|
"with a 2D barcode and a machine ID number printed below the barcode. "
|
|
"Read ONLY the machine ID number that appears below the barcode. "
|
|
"It is typically a 5-digit number followed by a dash and 6 more digits "
|
|
"(e.g., 12345-678901). Return ONLY the machine ID number, nothing else."
|
|
)
|
|
|
|
DEFAULT_BATCH_PROMPT = (
|
|
"I have {n} photos. For EACH photo, read ALL visible text and numbers.\n"
|
|
'Respond with a JSON array of objects, one per photo in order:\n'
|
|
'[{{"i":0,"text":"all text and digits found","digits":"e.g. 12345-678901 or null if none"}}, ...]\n'
|
|
'Return ONLY the JSON array, no markdown, no explanation.'
|
|
)
|
|
|
|
STICKER_BATCH_PROMPT = (
|
|
"I have {n} photos of colored equipment stickers (green, orange, or yellow). "
|
|
"Each sticker has a 2D barcode and a machine ID printed below it.\n"
|
|
'Respond with a JSON array of objects, one per photo in order:\n'
|
|
'[{{"i":0,"sticker_color":"green/orange/yellow/unknown","machine_id":"12345-678901 or null if not found"}}, ...]\n'
|
|
'Return ONLY the JSON array, no markdown, no explanation.'
|
|
)
|
|
|
|
# ─── Router ──────────────────────────────────────────────────────────────────
|
|
|
|
router = APIRouter(prefix="/api/admin/exif", tags=["exif"])
|
|
|
|
# Ensure upload dir exists
|
|
EXIF_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
# ─── DB init (called from admin_server lifespan) ────────────────────────────
|
|
|
|
def create_exif_tables():
|
|
"""Create photos and sessions tables if they don't exist."""
|
|
conn = sqlite3.connect(str(EXIF_PHOTOS_DB))
|
|
try:
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS photos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
orig_filename TEXT NOT NULL,
|
|
file_hash TEXT NOT NULL UNIQUE,
|
|
saved_as TEXT NOT NULL,
|
|
file_size INTEGER,
|
|
exif_json TEXT,
|
|
gps_lat REAL,
|
|
gps_lng REAL,
|
|
ocr_engine TEXT,
|
|
ocr_model TEXT,
|
|
ocr_raw_text TEXT,
|
|
ocr_match_5dash6 TEXT,
|
|
ocr_match_5plus TEXT,
|
|
machine_id TEXT,
|
|
sticker_color TEXT,
|
|
has_barcode INTEGER DEFAULT 0,
|
|
session_id INTEGER,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
)""")
|
|
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'))
|
|
)""")
|
|
# Session column migration
|
|
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()
|
|
except Exception:
|
|
with sqlite3.connect(str(EXIF_PHOTOS_DB)) as c2:
|
|
c2.execute("""CREATE TABLE IF NOT EXISTS photos (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
orig_filename TEXT NOT NULL,
|
|
file_hash TEXT NOT NULL UNIQUE,
|
|
saved_as TEXT NOT NULL,
|
|
file_size INTEGER DEFAULT NULL,
|
|
exif_json TEXT DEFAULT NULL,
|
|
gps_lat REAL DEFAULT NULL,
|
|
gps_lng REAL DEFAULT NULL,
|
|
ocr_engine TEXT DEFAULT NULL,
|
|
ocr_model TEXT DEFAULT NULL,
|
|
ocr_raw_text TEXT DEFAULT NULL,
|
|
ocr_match_5dash6 TEXT DEFAULT NULL,
|
|
ocr_match_5plus TEXT DEFAULT NULL,
|
|
machine_id TEXT DEFAULT NULL,
|
|
sticker_color TEXT DEFAULT NULL,
|
|
has_barcode INTEGER DEFAULT 0,
|
|
session_id INTEGER DEFAULT NULL,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
)""")
|
|
c2.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'))
|
|
)""")
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
# ─── DB helpers ──────────────────────────────────────────────────────────────
|
|
|
|
def _get_photos_db() -> sqlite3.Connection:
|
|
conn = sqlite3.connect(str(EXIF_PHOTOS_DB))
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def _get_canteen_db():
|
|
"""Get a read/write connection to the canteen assets database."""
|
|
path = Path(CANTEEN_DB_PATH)
|
|
if not path.exists():
|
|
return None
|
|
conn = sqlite3.connect(str(path))
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def _file_hash(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def _row_to_dict(row: sqlite3.Row) -> dict:
|
|
d = dict(row)
|
|
d["exif_data"] = json.loads(d.pop("exif_json", "{}") or "{}")
|
|
return d
|
|
|
|
|
|
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:
|
|
conn = _get_photos_db()
|
|
try:
|
|
cur = conn.execute(
|
|
"""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, session_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
orig_filename,
|
|
file_hash,
|
|
saved_as,
|
|
file_size,
|
|
json.dumps(exif_result) if exif_result else None,
|
|
exif_result.get("gps", {}).get("lat") if exif_result and exif_result.get("gps") else None,
|
|
exif_result.get("gps", {}).get("lng") if exif_result and exif_result.get("gps") else None,
|
|
ocr_result.get("engine") if ocr_result else None,
|
|
ocr_result.get("llm_model") if ocr_result else None,
|
|
ocr_result.get("raw_text") if ocr_result else None,
|
|
ocr_result.get("match_5dash6") if ocr_result else None,
|
|
ocr_result.get("match_5plus") if ocr_result else None,
|
|
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()
|
|
return cur.lastrowid or 0
|
|
except sqlite3.IntegrityError:
|
|
return 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def lookup_machine_id(machine_id: str) -> dict | None:
|
|
"""Look up an asset by machine_id. Returns asset dict or None."""
|
|
conn = _get_canteen_db()
|
|
if not conn:
|
|
return None
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT id, machine_id, name, category, status, address, building_name, "
|
|
"floor, room, latitude, longitude, make, model, description, photo_path "
|
|
"FROM assets WHERE machine_id = ?",
|
|
(machine_id.strip(),),
|
|
).fetchone()
|
|
if row:
|
|
return {
|
|
"id": row["id"],
|
|
"machine_id": row["machine_id"],
|
|
"name": row["name"],
|
|
"category": row["category"],
|
|
"status": row["status"],
|
|
"address": row["address"],
|
|
"building_name": row["building_name"],
|
|
"floor": row["floor"],
|
|
"room": row["room"],
|
|
"latitude": row["latitude"],
|
|
"longitude": row["longitude"],
|
|
"make": row["make"],
|
|
"model": row["model"],
|
|
"description": row["description"],
|
|
"photo_path": row["photo_path"],
|
|
}
|
|
finally:
|
|
conn.close()
|
|
return None
|
|
|
|
|
|
# ─── EXIF extraction ─────────────────────────────────────────────────────────
|
|
|
|
def _dms_to_decimal(dms, ref):
|
|
try:
|
|
deg, minutes, sec = float(dms[0]), float(dms[1]), float(dms[2])
|
|
decimal = deg + minutes / 60.0 + sec / 3600.0
|
|
if ref in ("S", "W"):
|
|
decimal = -decimal
|
|
return round(decimal, 7)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def extract_exif(image_bytes: bytes) -> dict:
|
|
result = {"has_exif": False, "tags": {}, "gps": None}
|
|
try:
|
|
img = PILImage.open(io.BytesIO(image_bytes))
|
|
exif = img.getexif()
|
|
if not exif:
|
|
return result
|
|
for tag_id, value in exif.items():
|
|
tag_name = PILImage.ExifTags.TAGS.get(tag_id, f"0x{tag_id:04x}")
|
|
if tag_name in ("MakerNote", "UserComment", "PrintImageMatching"):
|
|
continue
|
|
result["tags"][tag_name] = str(value)[:300]
|
|
if result["tags"]:
|
|
result["has_exif"] = True
|
|
gps_ifd = exif.get_ifd(0x8825)
|
|
if gps_ifd:
|
|
lat_ref = gps_ifd.get(1, "N")
|
|
lat_dms = gps_ifd.get(2)
|
|
lng_ref = gps_ifd.get(3, "E")
|
|
lng_dms = gps_ifd.get(4)
|
|
if lat_dms and lng_dms:
|
|
lat = _dms_to_decimal(lat_dms, lat_ref)
|
|
lng = _dms_to_decimal(lng_dms, lng_ref)
|
|
if lat is not None and lng is not None:
|
|
result["gps"] = {
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"lat_ref": str(lat_ref),
|
|
"lng_ref": str(lng_ref),
|
|
"raw_lat_dms": [float(v) for v in lat_dms],
|
|
"raw_lng_dms": [float(v) for v in lng_dms],
|
|
}
|
|
except Exception as e:
|
|
result["error"] = str(e)
|
|
return result
|
|
|
|
|
|
# ─── Tesseract OCR ───────────────────────────────────────────────────────────
|
|
|
|
def run_ocr(image_bytes: bytes) -> dict:
|
|
if not HAS_TESSERACT:
|
|
return {"available": False, "text": "", "error": "pytesseract not installed"}
|
|
tmp_path = EXIF_UPLOADS_DIR / f"ocr_{uuid.uuid4().hex}.jpg"
|
|
tmp_path.write_bytes(image_bytes)
|
|
try:
|
|
img = PILImage.open(tmp_path)
|
|
img_gray = img.convert("L")
|
|
text = pytesseract.image_to_string(img_gray, config="--psm 6")
|
|
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
|
|
match_5plus = re.search(r"(\d{5,})", text)
|
|
return {
|
|
"available": True,
|
|
"raw_text": text.strip()[:500],
|
|
"match_5dash6": match_5dash.group(0) if match_5dash else None,
|
|
"match_5plus": match_5plus.group(0) if match_5plus else None,
|
|
}
|
|
finally:
|
|
tmp_path.unlink(missing_ok=True)
|
|
|
|
|
|
# ─── LLM OCR helpers ─────────────────────────────────────────────────────────
|
|
|
|
def _resize_for_llm(image_bytes: bytes, max_dim: int = LLM_IMAGE_MAX_DIM) -> bytes:
|
|
img = PILImage.open(io.BytesIO(image_bytes))
|
|
w, h = img.size
|
|
if max(w, h) <= max_dim:
|
|
return image_bytes
|
|
ratio = max_dim / max(w, h)
|
|
new_size = (int(w * ratio), int(h * ratio))
|
|
img_resized = img.resize(new_size, PILImage.LANCZOS)
|
|
buf = io.BytesIO()
|
|
ext = img.format or "JPEG"
|
|
if ext.upper() in ("PNG", "WEBP", "TIFF", "BMP"):
|
|
img_resized = img_resized.convert("RGB")
|
|
ext = "JPEG"
|
|
img_resized.save(buf, format=ext, quality=85)
|
|
return buf.getvalue()
|
|
|
|
|
|
def run_ocr_llm(
|
|
image_bytes: bytes, model: str | None = None,
|
|
sticker_mode: bool = False,
|
|
) -> dict:
|
|
if not OPENCODE_GO_KEY:
|
|
result = run_ocr(image_bytes)
|
|
result["engine"] = "tesseract"
|
|
result["llm_fallback_reason"] = "no_api_key"
|
|
return result
|
|
model = model or LLM_OCR_MODEL
|
|
image_bytes = _resize_for_llm(image_bytes)
|
|
b64_img = base64.b64encode(image_bytes).decode()
|
|
prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT
|
|
body = json.dumps({
|
|
"model": model,
|
|
"messages": [{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": prompt},
|
|
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64_img}"}}
|
|
]
|
|
}],
|
|
"max_tokens": 200,
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"{OPENCODE_GO_BASE}/chat/completions",
|
|
data=body,
|
|
headers={
|
|
"Authorization": f"Bearer {OPENCODE_GO_KEY}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Hermes-Agent/1.0",
|
|
},
|
|
)
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=60)
|
|
result = json.loads(resp.read())
|
|
text = result["choices"][0]["message"]["content"].strip()
|
|
except Exception as exc:
|
|
result = run_ocr(image_bytes)
|
|
result["engine"] = "tesseract"
|
|
result["llm_fallback_reason"] = str(exc)[:200]
|
|
return result
|
|
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
|
|
match_5plus = re.search(r"(\d{5,})", text)
|
|
out = {
|
|
"available": True,
|
|
"engine": "llm",
|
|
"llm_model": model,
|
|
"raw_text": text[:500],
|
|
"match_5dash6": match_5dash.group(0) if match_5dash else None,
|
|
"match_5plus": match_5plus.group(0) if match_5plus else None,
|
|
}
|
|
if sticker_mode:
|
|
color_match = re.search(r"\b(green|orange|yellow)\b", text, re.I)
|
|
out["sticker_color"] = color_match.group(1).lower() if color_match else "unknown"
|
|
return out
|
|
|
|
|
|
def run_ocr_llm_batch(
|
|
images: list[tuple[str, bytes]],
|
|
model: str | None = None,
|
|
max_per_batch: int = BATCH_SIZE_LIMIT,
|
|
sticker_mode: bool = False,
|
|
) -> list[dict]:
|
|
if not OPENCODE_GO_KEY:
|
|
return [run_ocr(b) for _, b in images]
|
|
model = model or LLM_OCR_MODEL
|
|
all_results: list[dict] = []
|
|
for batch_start in range(0, len(images), max_per_batch):
|
|
batch = images[batch_start:batch_start + max_per_batch]
|
|
batch_results = _run_ocr_llm_batch_inner(batch, model, sticker_mode)
|
|
all_results.extend(batch_results)
|
|
return all_results
|
|
|
|
|
|
def _run_ocr_llm_batch_inner(
|
|
batch: list[tuple[str, bytes]], model: str,
|
|
sticker_mode: bool = False,
|
|
) -> list[dict]:
|
|
resized = [(_resize_for_llm(b), n) for n, b in batch]
|
|
batch_prompt = (STICKER_BATCH_PROMPT if sticker_mode else DEFAULT_BATCH_PROMPT).format(n=len(batch))
|
|
content: list[dict] = [{"type": "text", "text": batch_prompt}]
|
|
for idx, (img_bytes, _) in enumerate(resized):
|
|
b64_img = base64.b64encode(img_bytes).decode()
|
|
content.append({
|
|
"type": "image_url",
|
|
"image_url": {"url": f"data:image/jpeg;base64,{b64_img}"}
|
|
})
|
|
body = json.dumps({
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": content}],
|
|
"max_tokens": min(300 * len(batch), 8000),
|
|
"temperature": 0.1,
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"{OPENCODE_GO_BASE}/chat/completions",
|
|
data=body,
|
|
headers={
|
|
"Authorization": f"Bearer {OPENCODE_GO_KEY}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Hermes-Agent/1.0",
|
|
},
|
|
)
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=120)
|
|
result = json.loads(resp.read())
|
|
raw = result["choices"][0]["message"]["content"].strip()
|
|
except Exception:
|
|
return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch]
|
|
raw_clean = raw
|
|
if raw_clean.startswith("```"):
|
|
raw_clean = raw_clean.split("\n", 1)[-1]
|
|
raw_clean = raw_clean.rsplit("```", 1)[0].strip()
|
|
try:
|
|
parsed = json.loads(raw_clean)
|
|
results: list[dict] = []
|
|
for item in parsed:
|
|
if sticker_mode:
|
|
raw_text = str(item.get("machine_id", "") or "")
|
|
else:
|
|
raw_text = str(item.get("text", "") or "")
|
|
digit_str = str(item.get("digits") or "")
|
|
raw_text = raw_text + " " + digit_str
|
|
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", raw_text)
|
|
match_5plus = re.search(r"(\d{5,})", raw_text)
|
|
entry: dict = {
|
|
"available": True,
|
|
"engine": "llm_batch",
|
|
"llm_model": model,
|
|
"raw_text": raw_text[:500],
|
|
"match_5dash6": match_5dash.group(0) if match_5dash else None,
|
|
"match_5plus": match_5plus.group(0) if match_5plus else None,
|
|
}
|
|
if sticker_mode:
|
|
entry["sticker_color"] = str(item.get("sticker_color", "unknown"))
|
|
results.append(entry)
|
|
return results
|
|
except (json.JSONDecodeError, KeyError, TypeError):
|
|
return [run_ocr_llm(b, model, sticker_mode=sticker_mode) for n, b in batch]
|
|
|
|
|
|
# ─── Google Gemini OCR ───────────────────────────────────────────────────────
|
|
|
|
def run_ocr_google(image_bytes: bytes, model: str | None = None, sticker_mode: bool = False) -> dict:
|
|
if not GOOGLE_API_KEY:
|
|
result = run_ocr(image_bytes)
|
|
result["engine"] = "tesseract"
|
|
result["llm_fallback_reason"] = "no_google_api_key"
|
|
return result
|
|
model = model or GOOGLE_OCR_MODEL
|
|
image_bytes = _resize_for_llm(image_bytes)
|
|
b64_img = base64.b64encode(image_bytes).decode()
|
|
prompt = STICKER_OCR_PROMPT if sticker_mode else DEFAULT_OCR_PROMPT
|
|
body = json.dumps({
|
|
"contents": [{
|
|
"parts": [
|
|
{"text": prompt},
|
|
{"inline_data": {"mime_type": "image/jpeg", "data": b64_img}}
|
|
]
|
|
}],
|
|
"generationConfig": {"maxOutputTokens": 200, "temperature": 0.1},
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"{GOOGLE_API_BASE}/models/{model}:generateContent?key={GOOGLE_API_KEY}",
|
|
data=body,
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=60)
|
|
result = json.loads(resp.read())
|
|
text = result["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
except Exception as exc:
|
|
result = run_ocr(image_bytes)
|
|
result["engine"] = "tesseract"
|
|
result["llm_fallback_reason"] = str(exc)[:200]
|
|
return result
|
|
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
|
|
match_5plus = re.search(r"(\d{5,})", text)
|
|
out: dict = {
|
|
"available": True,
|
|
"engine": "google",
|
|
"llm_model": model,
|
|
"raw_text": text[:500],
|
|
"match_5dash6": match_5dash.group(0) if match_5dash else None,
|
|
"match_5plus": match_5plus.group(0) if match_5plus else None,
|
|
}
|
|
if sticker_mode:
|
|
cm = re.search(r"\b(green|orange|yellow)\b", text, re.I)
|
|
out["sticker_color"] = cm.group(1).lower() if cm else "unknown"
|
|
return out
|
|
|
|
|
|
# ─── Process helpers ─────────────────────────────────────────────────────────
|
|
|
|
def _extract_machine_id(ocr_result: dict) -> str | None:
|
|
match_5dash6 = ocr_result.get("match_5dash6")
|
|
match_5plus = ocr_result.get("match_5plus")
|
|
if match_5dash6:
|
|
clean = re.sub(r"\D", "", match_5dash6)
|
|
if len(clean) >= 5:
|
|
return clean[:5]
|
|
if match_5plus:
|
|
clean = re.sub(r"\D", "", match_5plus)
|
|
if len(clean) >= 5:
|
|
return clean[:5]
|
|
return None
|
|
|
|
|
|
def _process_one(orig_filename: str, contents: bytes, ocr_engine: str,
|
|
ocr_model: str | None, sticker_mode: bool) -> dict:
|
|
file_size = len(contents)
|
|
fhash = _file_hash(contents)
|
|
dup_row = None
|
|
db_conn = _get_photos_db()
|
|
if db_conn:
|
|
try:
|
|
dup_row = db_conn.execute(
|
|
"SELECT id, saved_as FROM photos WHERE file_hash = ?", (fhash,)
|
|
).fetchone()
|
|
finally:
|
|
db_conn.close()
|
|
if dup_row:
|
|
saved_name = dup_row["saved_as"]
|
|
is_dup = True
|
|
photo_id = dup_row["id"]
|
|
else:
|
|
ext = Path(orig_filename or "photo.jpg").suffix.lower()
|
|
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
|
|
ext = ".jpg"
|
|
saved_name = f"{uuid.uuid4().hex}{ext}"
|
|
(EXIF_UPLOADS_DIR / saved_name).write_bytes(contents)
|
|
is_dup = False
|
|
photo_id = None
|
|
exif_result = extract_exif(contents)
|
|
if ocr_engine == "llm":
|
|
ocr_result = run_ocr_llm(contents, ocr_model, sticker_mode=sticker_mode)
|
|
elif ocr_engine == "google":
|
|
ocr_result = run_ocr_google(contents, ocr_model, sticker_mode=sticker_mode)
|
|
elif HAS_TESSERACT:
|
|
ocr_result = run_ocr(contents)
|
|
ocr_result["engine"] = "tesseract"
|
|
else:
|
|
ocr_result = {"available": False, "text": "", "engine": "none"}
|
|
machine_id = None
|
|
asset = None
|
|
mid = _extract_machine_id(ocr_result)
|
|
if mid:
|
|
machine_id = mid
|
|
asset = lookup_machine_id(machine_id)
|
|
if not is_dup and not dup_row:
|
|
photo_id = _save_photo_to_db(
|
|
orig_filename, fhash, saved_name, file_size,
|
|
exif_result, ocr_result, machine_id,
|
|
)
|
|
return {
|
|
"filename": orig_filename,
|
|
"saved_as": saved_as if is_dup and not dup_row else saved_name,
|
|
"photo_id": photo_id or (dup_row["id"] if dup_row else None),
|
|
"file_size": file_size,
|
|
"file_size_kb": round(file_size / 1024, 1),
|
|
"duplicate": is_dup,
|
|
"exif": exif_result,
|
|
"ocr": ocr_result,
|
|
"machine_id": machine_id,
|
|
"asset": asset,
|
|
}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# API Endpoints
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/analyze")
|
|
async def analyze_photo(
|
|
file: UploadFile = File(...),
|
|
ocr_engine: str = Query(default="tesseract"),
|
|
ocr_model: str = Query(default=""),
|
|
sticker_mode: bool = Query(default=False),
|
|
):
|
|
"""Upload a photo, get back EXIF + OCR results."""
|
|
contents = await file.read()
|
|
result = _process_one(
|
|
file.filename or "photo.jpg", contents,
|
|
ocr_engine, ocr_model or None, sticker_mode,
|
|
)
|
|
return result
|
|
|
|
|
|
@router.post("/bulk-process")
|
|
async def bulk_process(
|
|
files: list[UploadFile] = File(...),
|
|
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."""
|
|
if not files:
|
|
return {"results": [], "summary": {"total": 0, "has_gps": 0, "matched": 0, "needs_gps": 0}}
|
|
|
|
all_files: list[tuple[str, bytes]] = []
|
|
for file in files:
|
|
contents = await file.read()
|
|
all_files.append((file.filename or "photo.jpg", contents))
|
|
|
|
ocr_use_llm = ocr_engine == "llm"
|
|
ocr_use_google = ocr_engine == "google"
|
|
|
|
if ocr_use_llm:
|
|
llm_ocr_results = run_ocr_llm_batch(
|
|
[(f, b) for f, b in all_files],
|
|
ocr_model or None,
|
|
sticker_mode=sticker_mode,
|
|
)
|
|
else:
|
|
llm_ocr_results = None
|
|
|
|
results = []
|
|
summary = {"total": len(all_files), "has_gps": 0, "matched": 0, "needs_gps": 0, "duplicates": 0}
|
|
|
|
for i, (orig_fname, contents) in enumerate(all_files):
|
|
file_size = len(contents)
|
|
fhash = _file_hash(contents)
|
|
dup_row = None
|
|
db_conn = _get_photos_db()
|
|
if db_conn:
|
|
try:
|
|
dup_row = db_conn.execute(
|
|
"SELECT id, saved_as FROM photos WHERE file_hash = ?", (fhash,)
|
|
).fetchone()
|
|
finally:
|
|
db_conn.close()
|
|
if dup_row:
|
|
saved_name = dup_row["saved_as"]
|
|
is_dup = True
|
|
photo_id = dup_row["id"]
|
|
else:
|
|
ext = Path(orig_fname or "photo.jpg").suffix.lower()
|
|
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng", ".heic", ".heif"}:
|
|
ext = ".jpg"
|
|
saved_name = f"{uuid.uuid4().hex}{ext}"
|
|
(EXIF_UPLOADS_DIR / saved_name).write_bytes(contents)
|
|
is_dup = False
|
|
photo_id = None
|
|
exif_result = extract_exif(contents)
|
|
if ocr_use_llm:
|
|
ocr_result = llm_ocr_results[i] if i < len(llm_ocr_results) else {"available": False, "engine": "none", "error": "missing batch result"}
|
|
elif ocr_use_google:
|
|
ocr_result = run_ocr_google(contents, ocr_model or None, sticker_mode=sticker_mode)
|
|
elif HAS_TESSERACT:
|
|
ocr_result = run_ocr(contents)
|
|
ocr_result["engine"] = "tesseract"
|
|
else:
|
|
ocr_result = {"available": False, "text": "", "engine": "none"}
|
|
machine_id = None
|
|
asset = None
|
|
mid = _extract_machine_id(ocr_result)
|
|
if mid:
|
|
machine_id = mid
|
|
asset = lookup_machine_id(machine_id)
|
|
needs_gps = False
|
|
if asset:
|
|
summary["matched"] += 1
|
|
has_photo_gps = exif_result.get("gps") is not None
|
|
needs_gps = has_photo_gps and (asset.get("latitude") is None or asset.get("longitude") is None)
|
|
if needs_gps:
|
|
summary["needs_gps"] += 1
|
|
if exif_result.get("gps"):
|
|
summary["has_gps"] += 1
|
|
if is_dup:
|
|
summary["duplicates"] += 1
|
|
if not is_dup and not dup_row:
|
|
photo_id = _save_photo_to_db(
|
|
orig_fname, fhash, saved_name, file_size,
|
|
exif_result, ocr_result, machine_id,
|
|
session_id=session_id,
|
|
)
|
|
results.append({
|
|
"filename": orig_fname,
|
|
"saved_as": saved_name,
|
|
"photo_id": photo_id or (dup_row["id"] if dup_row else None),
|
|
"file_size": file_size,
|
|
"file_size_kb": round(file_size / 1024, 1),
|
|
"duplicate": is_dup,
|
|
"exif": exif_result,
|
|
"ocr": ocr_result,
|
|
"machine_id": machine_id,
|
|
"asset": asset,
|
|
"needs_gps": needs_gps,
|
|
})
|
|
|
|
return {"results": results, "summary": summary}
|
|
|
|
|
|
@router.get("/photos")
|
|
async def list_photos(limit: int = Query(default=50, le=200)):
|
|
"""List previously processed photos from DB (newest first)."""
|
|
conn = _get_photos_db()
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT * FROM photos ORDER BY created_at DESC LIMIT ?", (limit,)
|
|
).fetchall()
|
|
return {"photos": [_row_to_dict(r) for r in rows]}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/photos/{photo_id}")
|
|
async def get_photo(photo_id: int):
|
|
"""Get a single photo record from DB."""
|
|
conn = _get_photos_db()
|
|
try:
|
|
row = conn.execute("SELECT * FROM photos WHERE id = ?", (photo_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Photo not found")
|
|
return _row_to_dict(row)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/photos/{photo_id}/file")
|
|
async def get_photo_file(photo_id: int):
|
|
"""Serve the saved image file for a photo record."""
|
|
conn = _get_photos_db()
|
|
try:
|
|
row = conn.execute("SELECT saved_as FROM photos WHERE id = ?", (photo_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Photo not found")
|
|
finally:
|
|
conn.close()
|
|
filepath = EXIF_UPLOADS_DIR / row["saved_as"]
|
|
if not filepath.exists():
|
|
raise HTTPException(404, "File not found on disk")
|
|
return FileResponse(str(filepath))
|
|
|
|
|
|
@router.post("/photos/{photo_id}/reprocess")
|
|
async def reprocess_photo(
|
|
photo_id: int,
|
|
ocr_engine: str = Query(default="tesseract"),
|
|
ocr_model: str = Query(default=""),
|
|
sticker_mode: bool = Query(default=False),
|
|
):
|
|
"""Re-run OCR on a previously saved photo with different engine/model."""
|
|
conn = _get_photos_db()
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT saved_as, orig_filename FROM photos WHERE id = ?", (photo_id,)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Photo not found")
|
|
finally:
|
|
conn.close()
|
|
filepath = EXIF_UPLOADS_DIR / row["saved_as"]
|
|
if not filepath.exists():
|
|
raise HTTPException(404, "Uploaded file not found on disk")
|
|
contents = filepath.read_bytes()
|
|
if ocr_engine == "llm":
|
|
ocr_result = run_ocr_llm(contents, ocr_model or None, sticker_mode=sticker_mode)
|
|
elif ocr_engine == "google":
|
|
ocr_result = run_ocr_google(contents, ocr_model or None, sticker_mode=sticker_mode)
|
|
elif HAS_TESSERACT:
|
|
ocr_result = run_ocr(contents)
|
|
ocr_result["engine"] = "tesseract"
|
|
else:
|
|
ocr_result = {"available": False, "text": "", "engine": "none"}
|
|
machine_id = None
|
|
asset = None
|
|
mid = _extract_machine_id(ocr_result)
|
|
if mid:
|
|
machine_id = mid
|
|
asset = lookup_machine_id(machine_id)
|
|
db2 = _get_photos_db()
|
|
try:
|
|
db2.execute(
|
|
"""UPDATE photos SET ocr_engine=?, ocr_model=?, ocr_raw_text=?,
|
|
ocr_match_5dash6=?, ocr_match_5plus=?, machine_id=?,
|
|
sticker_color=?
|
|
WHERE id=?""",
|
|
(
|
|
ocr_result.get("engine"),
|
|
ocr_result.get("llm_model"),
|
|
ocr_result.get("raw_text"),
|
|
ocr_result.get("match_5dash6"),
|
|
ocr_result.get("match_5plus"),
|
|
machine_id,
|
|
ocr_result.get("sticker_color"),
|
|
photo_id,
|
|
),
|
|
)
|
|
db2.commit()
|
|
finally:
|
|
db2.close()
|
|
return {
|
|
"photo_id": photo_id,
|
|
"ocr_engine": ocr_result.get("engine"),
|
|
"llm_model": ocr_result.get("llm_model"),
|
|
"ocr": ocr_result,
|
|
"machine_id": machine_id,
|
|
"asset": asset,
|
|
}
|
|
|
|
|
|
@router.post("/photos/{photo_id}/reset")
|
|
async def reset_photo(photo_id: int):
|
|
"""Delete a photo record and its file, allowing re-upload as new."""
|
|
conn = _get_photos_db()
|
|
try:
|
|
row = conn.execute("SELECT saved_as FROM photos WHERE id = ?", (photo_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Photo not found")
|
|
filepath = EXIF_UPLOADS_DIR / row["saved_as"]
|
|
if filepath.exists():
|
|
filepath.unlink()
|
|
conn.execute("DELETE FROM photos WHERE id = ?", (photo_id,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return {"ok": True, "reset": photo_id, "message": "Photo entry deleted. Re-upload to process as new."}
|
|
|
|
|
|
@router.delete("/photos/{photo_id}")
|
|
async def delete_photo(photo_id: int):
|
|
"""Delete a photo record and its file from disk."""
|
|
conn = _get_photos_db()
|
|
try:
|
|
row = conn.execute("SELECT saved_as FROM photos WHERE id = ?", (photo_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "Photo not found")
|
|
filepath = EXIF_UPLOADS_DIR / row["saved_as"]
|
|
if filepath.exists():
|
|
filepath.unlink()
|
|
conn.execute("DELETE FROM photos WHERE id = ?", (photo_id,))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return {"ok": True, "deleted": photo_id}
|
|
|
|
|
|
@router.get("/lookup")
|
|
async def lookup_asset(machine_id: str = ""):
|
|
"""Look up an asset by machine_id in the canteen assets database."""
|
|
if not machine_id or not machine_id.strip():
|
|
return {"found": False, "reason": "No machine_id provided"}
|
|
asset = lookup_machine_id(machine_id.strip())
|
|
if asset:
|
|
return {"found": True, "asset": asset}
|
|
return {"found": False, "reason": f"No asset with machine_id '{machine_id}'"}
|
|
|
|
|
|
@router.post("/push-gps")
|
|
async def push_gps(request: dict):
|
|
"""Update an asset's GPS coordinates from photo EXIF data."""
|
|
asset_id = request.get("asset_id")
|
|
latitude = request.get("latitude")
|
|
longitude = request.get("longitude")
|
|
if not asset_id or latitude is None or longitude is None:
|
|
raise HTTPException(400, "asset_id, latitude, and longitude are required")
|
|
conn = _get_canteen_db()
|
|
if not conn:
|
|
raise HTTPException(500, "Database not available")
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT latitude, longitude FROM assets WHERE id = ?", (int(asset_id),)
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, f"Asset {asset_id} not found")
|
|
if row["latitude"] is not None and row["longitude"] is not None:
|
|
return {"updated": False, "reason": "Asset already has GPS coordinates", "asset_id": asset_id}
|
|
conn.execute(
|
|
"UPDATE assets SET latitude = ?, longitude = ?, updated_at = datetime('now') WHERE id = ?",
|
|
(float(latitude), float(longitude), int(asset_id)),
|
|
)
|
|
conn.commit()
|
|
return {"updated": True, "asset_id": asset_id, "latitude": float(latitude), "longitude": float(longitude)}
|
|
except Exception as e:
|
|
raise HTTPException(500, str(e))
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@router.get("/uploads")
|
|
async def list_uploads():
|
|
"""List previously uploaded files."""
|
|
files = sorted(EXIF_UPLOADS_DIR.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
return [
|
|
{"name": f.name, "size_kb": round(f.stat().st_size / 1024, 1)}
|
|
for f in files[:20]
|
|
]
|
|
|
|
|
|
@router.get("/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."""
|
|
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", ""),
|
|
}
|
|
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 = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
'<kml xmlns="http://www.opengis.net/kml/2.2">',
|
|
" <Document>",
|
|
f" <name>EXIF Export ({len(records)} photos)</name>",
|
|
]
|
|
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(" <Placemark>")
|
|
kml_parts.append(f" <name>{name}</name>")
|
|
kml_parts.append(f" <description>{chr(10).join(desc_lines)}</description>")
|
|
kml_parts.append(" <Point>")
|
|
kml_parts.append(f" <coordinates>{rec['lng']},{rec['lat']},0</coordinates>")
|
|
kml_parts.append(" </Point>")
|
|
kml_parts.append(" </Placemark>")
|
|
kml_parts.append(" </Document>")
|
|
kml_parts.append("</kml>")
|
|
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)}
|
|
|
|
|
|
@router.post("/assign-machine-id")
|
|
async def assign_machine_id(request: dict):
|
|
"""Manually assign a machine_id to a photo record."""
|
|
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,
|
|
}
|
|
|
|
|
|
@router.get("/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."""
|
|
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()
|
|
|
|
|
|
@router.get("/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()
|
|
|
|
|
|
@router.post("/sessions")
|
|
async def create_session(request: dict):
|
|
"""Create a new session."""
|
|
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()
|
|
|
|
|
|
@router.get("/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()
|
|
|
|
|
|
@router.post("/sessions/{session_id}/close")
|
|
async def close_session(session_id: int):
|
|
"""Finalize a session."""
|
|
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()
|
|
|
|
|
|
@router.post("/bulk-assign")
|
|
async def bulk_assign_machine_id(request: dict):
|
|
"""Assign the same machine_id to multiple photos at once."""
|
|
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,
|
|
}
|