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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user