Add Disney park classification + service entrance API

- Auto-classifies 1,155 Disney assets into parks/resorts/offices
- New columns/table: assets.disney_park, service_entrances
- API: /api/disney/stats, /api/disney/classify, /api/assets/{id}/disney-park
- API: /api/service-entrances CRUD, /api/locations/{id}/service-entrances
- OCR strategy: ConnectID last-5, resort keyword detection, address patterns
This commit is contained in:
2026-05-22 17:41:11 -04:00
parent 2e2fd50f70
commit 6aa5d3d35c
2 changed files with 539 additions and 1 deletions
+204 -1
View File
@@ -164,10 +164,22 @@ def _create_v2_tables(conn: sqlite3.Connection):
latitude REAL DEFAULT NULL,
longitude REAL DEFAULT NULL,
geofence_radius_meters INTEGER DEFAULT 50,
disney_park TEXT DEFAULT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS service_entrances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
notes TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
@@ -428,8 +440,28 @@ def init_db(conn: sqlite3.Connection):
conn.execute("ALTER TABLE locations ADD COLUMN longitude REAL DEFAULT NULL")
conn.commit()
# v4 migration: disney_park column + service_entrances table
if "disney_park" not in asset_cols:
conn.execute("ALTER TABLE assets ADD COLUMN disney_park TEXT DEFAULT NULL")
conn.commit()
# ─── App / Middleware ───────────────────────────────────────────────────────
# Ensure service_entrances table exists for existing DBs
conn.execute("""
CREATE TABLE IF NOT EXISTS service_entrances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL REFERENCES locations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
notes TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.commit()
# --- App / Middleware
@asynccontextmanager
@@ -2114,6 +2146,177 @@ def geocode(lat: float = Query(...), lng: float = Query(...)):
return result
# ─── Disney Park Classification ──────────────────────────────────────────────
@app.get("/api/disney/stats")
def disney_stats():
"""Get Disney classification statistics."""
conn = get_db()
rows = conn.execute(
"SELECT disney_park, COUNT(*) as count FROM assets WHERE disney_park IS NOT NULL GROUP BY disney_park ORDER BY count DESC"
).fetchall()
total = conn.execute("SELECT COUNT(*) as c FROM assets").fetchone()['c']
classified = conn.execute("SELECT COUNT(*) as c FROM assets WHERE disney_park IS NOT NULL").fetchone()['c']
conn.close()
from disney_classify import DISNEY_PARK_NAMES, DISNEY_PARK_ICONS, DISNEY_PARK_COLORS
return {
"total_assets": total,
"classified": classified,
"by_park": {
row['disney_park']: {
"count": row['count'],
"name": DISNEY_PARK_NAMES.get(row['disney_park'], row['disney_park']),
"icon": DISNEY_PARK_ICONS.get(row['disney_park'], "📍"),
"color": DISNEY_PARK_COLORS.get(row['disney_park'], "#95a5a6"),
}
for row in rows
},
}
@app.post("/api/disney/classify")
def classify_disney_assets():
"""Run auto-classification on all assets. Returns report."""
from disney_classify import classify_all_assets
result = classify_all_assets(DB_PATH)
return result
@app.put("/api/assets/{asset_id}/disney-park")
def set_asset_disney_park(asset_id: int, body: dict):
"""Manually set the disney_park classification for an asset."""
park = body.get("disney_park")
valid = {"magic-kingdom", "epcot", "hollywood-studios", "animal-kingdom",
"disney-springs", "resort", "office", "other", None}
if park not in valid and park is not None:
raise HTTPException(400, f"Invalid park. Valid: {', '.join(v for v in valid if v)}")
conn = get_db()
conn.execute(
"UPDATE assets SET disney_park = ?, updated_at = datetime('now') WHERE id = ?",
(park, asset_id)
)
conn.commit()
conn.close()
return {"status": "ok", "asset_id": asset_id, "disney_park": park}
# ─── Service Entrances ────────────────────────────────────────────────────────
class ServiceEntranceCreate(BaseModel):
location_id: int
name: str = ""
latitude: float
longitude: float
notes: str = ""
class ServiceEntranceUpdate(BaseModel):
name: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
notes: Optional[str] = None
@app.get("/api/locations/{location_id}/service-entrances")
def list_service_entrances(location_id: int):
"""List service entrances for a location."""
conn = get_db()
rows = conn.execute(
"SELECT se.*, l.name as location_name FROM service_entrances se "
"JOIN locations l ON se.location_id = l.id "
"WHERE se.location_id = ? ORDER BY se.name",
(location_id,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
@app.post("/api/service-entrances", status_code=201)
def create_service_entrance(body: ServiceEntranceCreate):
"""Create a new service entrance."""
conn = get_db()
# Verify location exists
loc = conn.execute("SELECT id FROM locations WHERE id = ?", (body.location_id,)).fetchone()
if not loc:
conn.close()
raise HTTPException(404, "Location not found")
cursor = conn.execute(
"INSERT INTO service_entrances (location_id, name, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?)",
(body.location_id, body.name, body.latitude, body.longitude, body.notes)
)
conn.commit()
row = conn.execute("SELECT * FROM service_entrances WHERE id = ?", (cursor.lastrowid,)).fetchone()
conn.close()
return dict(row)
@app.put("/api/service-entrances/{entrance_id}")
def update_service_entrance(entrance_id: int, body: ServiceEntranceUpdate):
"""Update a service entrance."""
conn = get_db()
existing = conn.execute("SELECT * FROM service_entrances WHERE id = ?", (entrance_id,)).fetchone()
if not existing:
conn.close()
raise HTTPException(404, "Service entrance not found")
updates = {}
if body.name is not None:
updates['name'] = body.name
if body.latitude is not None:
updates['latitude'] = body.latitude
if body.longitude is not None:
updates['longitude'] = body.longitude
if body.notes is not None:
updates['notes'] = body.notes
if updates:
updates['updated_at'] = "datetime('now')"
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values())
# Handle updated_at specially
set_clause = ", ".join(
f"{k} = datetime('now')" if k == 'updated_at' else f"{k} = ?"
for k in updates
)
values = [v for k, v in updates.items() if k != 'updated_at']
conn.execute(
f"UPDATE service_entrances SET {set_clause} WHERE id = ?",
(*values, entrance_id)
)
conn.commit()
row = conn.execute("SELECT se.*, l.name as location_name FROM service_entrances se "
"JOIN locations l ON se.location_id = l.id "
"WHERE se.id = ?", (entrance_id,)).fetchone()
conn.close()
return dict(row)
@app.delete("/api/service-entrances/{entrance_id}", status_code=204)
def delete_service_entrance(entrance_id: int):
"""Delete a service entrance."""
conn = get_db()
cursor = conn.execute("DELETE FROM service_entrances WHERE id = ?", (entrance_id,))
conn.commit()
conn.close()
if cursor.rowcount == 0:
raise HTTPException(404, "Service entrance not found")
return None
@app.get("/api/service-entrances")
def list_all_service_entrances():
"""List all service entrances."""
conn = get_db()
rows = conn.execute(
"SELECT se.*, l.name as location_name, l.address as location_address "
"FROM service_entrances se "
"LEFT JOIN locations l ON se.location_id = l.id "
"ORDER BY l.name, se.name"
).fetchall()
conn.close()
return [dict(r) for r in rows]
# ─── Static Files (mounted last to not shadow routes) ──────────────────────
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")