From 08240750488cbbc78d9da54c025ffe1915eb6887 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 21 May 2026 17:27:06 -0400 Subject: [PATCH] t_68f4f6a2: Strip admin API routes from main server.py --- server.py | 1059 ----------------------------------------------------- 1 file changed, 1059 deletions(-) diff --git a/server.py b/server.py index 880e18f..6cadbdd 100644 --- a/server.py +++ b/server.py @@ -622,68 +622,6 @@ class VisitCreate(BaseModel): duration_minutes: Optional[int] = None -# ─── Phase B: Customer / Location / Room / Settings Models ────────────────── - - -class CustomerContact(BaseModel): - name: Optional[str] = "" - phone: Optional[str] = "" - email: Optional[str] = "" - - -class CustomerCreate(BaseModel): - name: str - contacts: Optional[List[CustomerContact]] = [] - - -class CustomerUpdate(BaseModel): - name: Optional[str] = None - contacts: Optional[List[CustomerContact]] = None - - -class LocationCreate(BaseModel): - customer_id: Optional[int] = None - name: str - address: Optional[str] = "" - building_name: Optional[str] = "" - building_number: Optional[str] = "" - floor: Optional[str] = "" - trailer_number: Optional[str] = "" - site_hours: Optional[str] = "" - access_notes: Optional[str] = "" - walking_directions: Optional[str] = "" - map_link: Optional[str] = "" - latitude: Optional[float] = None - longitude: Optional[float] = None - - -class LocationUpdate(BaseModel): - customer_id: Optional[int] = None - name: Optional[str] = None - address: Optional[str] = None - building_name: Optional[str] = None - building_number: Optional[str] = None - floor: Optional[str] = None - trailer_number: Optional[str] = None - site_hours: Optional[str] = None - access_notes: Optional[str] = None - walking_directions: Optional[str] = None - map_link: Optional[str] = None - latitude: Optional[float] = None - longitude: Optional[float] = None - - -class RoomCreate(BaseModel): - location_id: int - name: str - floor: Optional[str] = "" - - -class RoomUpdate(BaseModel): - name: Optional[str] = None - floor: Optional[str] = None - location_id: Optional[int] = None - # ─── Helpers ──────────────────────────────────────────────────────────────── @@ -1308,631 +1246,14 @@ def delete_checkin(checkin_id: int): # ─── Task 10: GET /api/stats ──────────────────────────────────────────────── -@app.get("/api/stats") -def get_stats(): - conn = get_db() - total_assets = conn.execute("SELECT COUNT(*) FROM assets").fetchone()[0] - total_checkins = conn.execute("SELECT COUNT(*) FROM checkins").fetchone()[0] - cats = conn.execute( - "SELECT category, COUNT(*) AS cnt FROM assets GROUP BY category ORDER BY cnt DESC" - ).fetchall() - by_category = {r["category"]: r["cnt"] for r in cats} - statuses = conn.execute( - "SELECT status, COUNT(*) AS cnt FROM assets GROUP BY status ORDER BY cnt DESC" - ).fetchall() - by_status = {r["status"]: r["cnt"] for r in statuses} - # Enhanced: top visited assets - top_visited = conn.execute( - """SELECT a.name, a.machine_id, COUNT(*) AS visit_count, - MAX(v.checkin_time) AS last_visit_date - FROM visits v JOIN assets a ON v.asset_id = a.id - GROUP BY v.asset_id ORDER BY visit_count DESC LIMIT 10""" - ).fetchall() - top_visited_list = [ - {"name": r["name"], "machine_id": r["machine_id"], - "visit_count": r["visit_count"], "last_visit_date": r["last_visit_date"]} - for r in top_visited - ] - # Enhanced: time on site per technician - time_on_site_rows = conn.execute( - """SELECT u.username, COUNT(v.id) AS visit_count, - SUM(CASE WHEN v.checkout_time IS NOT NULL AND v.checkin_time IS NOT NULL - THEN (julianday(v.checkout_time) - julianday(v.checkin_time)) * 1440 - ELSE 0 END) AS total_minutes - FROM visits v JOIN users u ON v.user_id = u.id - WHERE u.role = 'technician' - GROUP BY v.user_id ORDER BY total_minutes DESC""" - ).fetchall() - time_on_site = [ - {"username": r["username"], "visit_count": r["visit_count"], - "total_minutes": round(r["total_minutes"] or 0, 1)} - for r in time_on_site_rows - ] - # Enhanced: assets by make/manufacturer - makes = conn.execute( - "SELECT make, COUNT(*) AS cnt FROM assets WHERE make != '' GROUP BY make ORDER BY cnt DESC" - ).fetchall() - by_make = {r["make"]: r["cnt"] for r in makes} - conn.close() - return { - "total_assets": total_assets, - "total_checkins": total_checkins, - "by_category": by_category, - "by_status": by_status, - "top_visited": top_visited_list, - "time_on_site": time_on_site, - "by_make": by_make, - } -# ─── Task 11: CSV Export ──────────────────────────────────────────────────── - - -def _generate_csv(rows, fieldnames): - """Yield CSV rows as a string generator for StreamingResponse.""" - output = io.StringIO() - writer = csv.DictWriter(output, fieldnames=fieldnames) - writer.writeheader() - yield output.getvalue() - output.seek(0) - output.truncate(0) - for row in rows: - writer.writerow(row_to_dict(row)) - yield output.getvalue() - output.seek(0) - output.truncate(0) - - -_ASSET_CSV_FIELDS = [ - "id", "machine_id", "serial_number", "name", "description", "category", - "status", "make", "model", "address", "building_name", "building_number", - "floor", "room", "trailer_number", "walking_directions", "map_link", - "parking_location", "photo_path", "customer_id", "location_id", - "assigned_to", "latitude", "longitude", "geofence_radius_meters", - "created_at", "updated_at", -] - -_CHECKIN_CSV_FIELDS = [ - "id", "asset_id", "user_id", "latitude", "longitude", "accuracy", - "photo_path", "notes", "created_at", -] - - -@app.get("/api/export/assets") -def export_assets_csv(): - conn = get_db() - rows = conn.execute("SELECT * FROM assets ORDER BY created_at DESC").fetchall() - conn.close() - return StreamingResponse( - _generate_csv(rows, _ASSET_CSV_FIELDS), - media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=assets.csv"}, - ) - - -@app.get("/api/export/checkins") -def export_checkins_csv(asset_id: Optional[int] = Query(None)): - conn = get_db() - if asset_id is not None: - rows = conn.execute( - "SELECT * FROM checkins WHERE asset_id = ? ORDER BY created_at DESC, id DESC", - (asset_id,), - ).fetchall() - else: - rows = conn.execute("SELECT * FROM checkins ORDER BY created_at DESC").fetchall() - conn.close() - return StreamingResponse( - _generate_csv(rows, _CHECKIN_CSV_FIELDS), - media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=checkins.csv"}, - ) - - -# ─── Phase B: Customers API ────────────────────────────────────────────────── - - -@app.post("/api/customers", status_code=201) -def create_customer(body: CustomerCreate): - name = body.name.strip() - if not name: - raise HTTPException(status_code=422, detail="Customer name must not be empty") - conn = get_db() - try: - cursor = conn.execute("INSERT INTO customers (name) VALUES (?)", (name,)) - cust_id = cursor.lastrowid - if body.contacts: - for c in body.contacts: - conn.execute( - "INSERT INTO customer_contacts (customer_id, name, phone, email) VALUES (?, ?, ?, ?)", - (cust_id, c.name or "", c.phone or "", c.email or ""), - ) - conn.commit() - _log_activity(conn, "created", "customer", cust_id, - f"Customer '{name}' created") - conn.commit() - except sqlite3.IntegrityError: - conn.close() - raise HTTPException(status_code=409, detail=f"Customer '{name}' already exists") - - row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone() - result = row_to_dict(row) - result["contacts"] = _get_customer_contacts(conn, cust_id) - conn.close() - return result - - -def _get_customer_contacts(conn: sqlite3.Connection, cust_id: int) -> list: - rows = conn.execute( - "SELECT id, customer_id, name, phone, email FROM customer_contacts WHERE customer_id = ?", - (cust_id,), - ).fetchall() - return [row_to_dict(r) for r in rows] - - -@app.get("/api/customers") -def list_customers(): - conn = get_db() - rows = conn.execute("SELECT * FROM customers ORDER BY name").fetchall() - result = [] - for r in rows: - d = row_to_dict(r) - d["contacts"] = _get_customer_contacts(conn, r["id"]) - result.append(d) - conn.close() - return result - - -@app.get("/api/customers/{cust_id}") -def get_customer(cust_id: int): - conn = get_db() - row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone() - if row is None: - conn.close() - raise HTTPException(status_code=404, detail="Customer not found") - result = row_to_dict(row) - result["contacts"] = _get_customer_contacts(conn, cust_id) - conn.close() - return result - - -@app.put("/api/customers/{cust_id}") -def update_customer(cust_id: int, body: CustomerUpdate): - conn = get_db() - existing = conn.execute("SELECT id FROM customers WHERE id = ?", (cust_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="Customer not found") - - if body.name is not None: - name = body.name.strip() - if not name: - conn.close() - raise HTTPException(status_code=422, detail="Customer name must not be empty") - conn.execute("UPDATE customers SET name = ?, updated_at = datetime('now') WHERE id = ?", (name, cust_id)) - - if body.contacts is not None: - # Replace all contacts - conn.execute("DELETE FROM customer_contacts WHERE customer_id = ?", (cust_id,)) - for c in body.contacts: - conn.execute( - "INSERT INTO customer_contacts (customer_id, name, phone, email) VALUES (?, ?, ?, ?)", - (cust_id, c.name or "", c.phone or "", c.email or ""), - ) - - conn.commit() - row = conn.execute("SELECT * FROM customers WHERE id = ?", (cust_id,)).fetchone() - result = row_to_dict(row) - result["contacts"] = _get_customer_contacts(conn, cust_id) - _log_activity(conn, "updated", "customer", cust_id, - f"Customer '{result['name']}' updated") - conn.commit() - conn.close() - return result - - -@app.delete("/api/customers/{cust_id}", status_code=204) -def delete_customer(cust_id: int): - conn = get_db() - existing = conn.execute("SELECT id FROM customers WHERE id = ?", (cust_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="Customer not found") - conn.execute("DELETE FROM customers WHERE id = ?", (cust_id,)) - _log_activity(conn, "deleted", "customer", cust_id, - f"Customer {cust_id} deleted") - conn.commit() - conn.close() - - -# ─── Phase B: Locations API ────────────────────────────────────────────────── - - -def _get_location_rooms(conn: sqlite3.Connection, loc_id: int) -> list: - rows = conn.execute( - "SELECT id, location_id, name, floor, created_at, updated_at FROM rooms WHERE location_id = ? ORDER BY name", - (loc_id,), - ).fetchall() - return [row_to_dict(r) for r in rows] - - -@app.post("/api/locations", status_code=201) -def create_location(body: LocationCreate): - name = body.name.strip() - if not name: - raise HTTPException(status_code=422, detail="Location name must not be empty") - conn = get_db() - if body.customer_id is not None: - _validate_ref(conn, "customers", body.customer_id, "Customer") - - cursor = conn.execute( - """INSERT INTO locations (customer_id, name, address, building_name, building_number, - floor, trailer_number, site_hours, access_notes, walking_directions, map_link, - latitude, longitude) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (body.customer_id, name, body.address or "", body.building_name or "", - body.building_number or "", body.floor or "", body.trailer_number or "", - body.site_hours or "", body.access_notes or "", - body.walking_directions or "", body.map_link or "", - body.latitude, body.longitude), - ) - conn.commit() - loc_id = cursor.lastrowid - row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone() - result = row_to_dict(row) - result["rooms"] = [] - conn.close() - return result - - -@app.get("/api/locations") -def list_locations(customer_id: Optional[int] = Query(None)): - conn = get_db() - if customer_id is not None: - rows = conn.execute( - "SELECT * FROM locations WHERE customer_id = ? ORDER BY name", - (customer_id,), - ).fetchall() - else: - rows = conn.execute("SELECT * FROM locations ORDER BY name").fetchall() - result = [] - for r in rows: - d = row_to_dict(r) - d["rooms"] = _get_location_rooms(conn, r["id"]) - result.append(d) - conn.close() - return result - - -@app.get("/api/locations/{loc_id}") -def get_location(loc_id: int): - conn = get_db() - row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone() - if row is None: - conn.close() - raise HTTPException(status_code=404, detail="Location not found") - result = row_to_dict(row) - result["rooms"] = _get_location_rooms(conn, loc_id) - conn.close() - return result - - -_LOCATION_FIELDS = [ - "customer_id", "name", "address", "building_name", "building_number", - "floor", "trailer_number", "site_hours", "access_notes", - "walking_directions", "map_link", "latitude", "longitude", -] - - -@app.put("/api/locations/{loc_id}") -def update_location(loc_id: int, body: LocationUpdate): - conn = get_db() - existing = conn.execute("SELECT id FROM locations WHERE id = ?", (loc_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="Location not found") - - updates = {} - for field in _LOCATION_FIELDS: - val = getattr(body, field, None) - if val is not None: - if field == "name": - name = val.strip() - if not name: - conn.close() - raise HTTPException(status_code=422, detail="Location name must not be empty") - updates[field] = name - elif field == "customer_id": - if val is not None: - _validate_ref(conn, "customers", val, "Customer") - updates[field] = val - elif field in ("latitude", "longitude"): - updates[field] = val - else: - updates[field] = val or "" - - if updates: - updates["updated_at"] = "datetime('now')" - set_clause = ", ".join( - f"{k} = {v}" if k == "updated_at" else f"{k} = ?" - for k, v in updates.items() - ) - values = [v for k, v in updates.items() if k != "updated_at"] - conn.execute( - f"UPDATE locations SET {set_clause} WHERE id = ?", - values + [loc_id], - ) - conn.commit() - - row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone() - result = row_to_dict(row) - result["rooms"] = _get_location_rooms(conn, loc_id) - conn.close() - return result - - -@app.delete("/api/locations/{loc_id}", status_code=204) -def delete_location(loc_id: int): - conn = get_db() - existing = conn.execute("SELECT id FROM locations WHERE id = ?", (loc_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="Location not found") - conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,)) - conn.commit() - conn.close() - - -# ─── Phase B: Rooms API ────────────────────────────────────────────────────── - - -@app.get("/api/rooms") -def list_rooms(location_id: Optional[int] = Query(None)): - """List rooms, optionally filtered by location_id.""" - conn = get_db() - if location_id is not None: - rows = conn.execute( - "SELECT * FROM rooms WHERE location_id = ? ORDER BY name", - (location_id,), - ).fetchall() - else: - rows = conn.execute("SELECT * FROM rooms ORDER BY name").fetchall() - conn.close() - return [row_to_dict(r) for r in rows] - - -@app.get("/api/rooms/{room_id}") -def get_room(room_id: int): - """Get a single room by id.""" - conn = get_db() - row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone() - conn.close() - if row is None: - raise HTTPException(status_code=404, detail="Room not found") - return row_to_dict(row) - - -@app.post("/api/rooms", status_code=201) -def create_room(body: RoomCreate): - conn = get_db() - _validate_ref(conn, "locations", body.location_id, "Location") - - cursor = conn.execute( - "INSERT INTO rooms (location_id, name, floor) VALUES (?, ?, ?)", - (body.location_id, body.name.strip(), body.floor or ""), - ) - conn.commit() - room_id = cursor.lastrowid - row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone() - conn.close() - return row_to_dict(row) - - -@app.put("/api/rooms/{room_id}") -def update_room(room_id: int, body: RoomUpdate): - conn = get_db() - existing = conn.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="Room not found") - - updates = {} - if body.name is not None: - updates["name"] = body.name.strip() - if body.floor is not None: - updates["floor"] = body.floor - if body.location_id is not None: - _validate_ref(conn, "locations", body.location_id, "Location") - updates["location_id"] = body.location_id - - if updates: - updates["updated_at"] = "datetime('now')" - set_clause = ", ".join( - f"{k} = {v}" if k == "updated_at" else f"{k} = ?" - for k, v in updates.items() - ) - values = [v for k, v in updates.items() if k != "updated_at"] - conn.execute( - f"UPDATE rooms SET {set_clause} WHERE id = ?", - values + [room_id], - ) - conn.commit() - - row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone() - conn.close() - return row_to_dict(row) - - -@app.delete("/api/rooms/{room_id}", status_code=204) -def delete_room(room_id: int): - conn = get_db() - existing = conn.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="Room not found") - conn.execute("DELETE FROM rooms WHERE id = ?", (room_id,)) - conn.commit() - conn.close() - - -# ─── Phase B: Settings API ─────────────────────────────────────────────────── - - -_SETTINGS_SCHEMAS = { - "categories": {"table": "categories", "columns": ["name", "icon"]}, - "makes": {"table": "makes", "columns": ["name"]}, - "models": {"table": "models", "columns": ["make_id", "name", "icon_path"]}, - "key_names": {"table": "key_names", "columns": ["name"]}, - "key_types": {"table": "key_types", "columns": ["name"]}, - "badge_types": {"table": "badge_types", "columns": ["name"]}, -} - - -def _settings_list(conn: sqlite3.Connection, table: str): - if table == "models": - rows = conn.execute("SELECT * FROM models ORDER BY name").fetchall() - else: - rows = conn.execute(f"SELECT * FROM {table} ORDER BY name").fetchall() - return [row_to_dict(r) for r in rows] - - -def _settings_create(conn: sqlite3.Connection, entity: str, body: dict): - schema = _SETTINGS_SCHEMAS[entity] - table = schema["table"] - columns = schema["columns"] - values = [] - for col in columns: - val = body.get(col, "" if col != "make_id" else None) - if col == "make_id" and val is None: - raise HTTPException(status_code=422, detail="make_id is required for models") - values.append(val if val is not None else "") - placeholders = ", ".join(["?"] * len(columns)) - col_names = ", ".join(columns) - try: - cursor = conn.execute( - f"INSERT INTO {table} ({col_names}) VALUES ({placeholders})", values - ) - conn.commit() - eid = cursor.lastrowid - row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone() - return row_to_dict(row) - except sqlite3.IntegrityError: - raise HTTPException(status_code=409, detail=f"Entry already exists in {entity}") - - -def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict): - schema = _SETTINGS_SCHEMAS[entity] - table = schema["table"] - columns = schema["columns"] - - existing = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (eid,)).fetchone() - if existing is None: - raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found") - - updates = {} - for col in columns: - if col in body: - updates[col] = body[col] - - if updates: - set_clause = ", ".join(f"{k} = ?" for k in updates) - values = list(updates.values()) - conn.execute( - f"UPDATE {table} SET {set_clause} WHERE id = ?", values + [eid] - ) - conn.commit() - - row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone() - return row_to_dict(row) - - -def _settings_get(conn: sqlite3.Connection, entity: str, eid: int): - """Get a single settings item by id. Returns dict or raises 404.""" - schema = _SETTINGS_SCHEMAS[entity] - table = schema["table"] - row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone() - if row is None: - raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found") - return row_to_dict(row) - - -def _settings_delete(conn: sqlite3.Connection, entity: str, eid: int): - schema = _SETTINGS_SCHEMAS[entity] - table = schema["table"] - existing = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (eid,)).fetchone() - if existing is None: - raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found") - conn.execute(f"DELETE FROM {table} WHERE id = ?", (eid,)) - conn.commit() - - -# ─── Dynamic settings routes ───────────────────────────────────────────────── -# Register CRUD for each settings entity. Note: these use raw request body -# to avoid Pydantic model explosion — the schemas are validated by -# _SETTINGS_SCHEMAS above. - -_SETTINGS_ENTITIES = ["categories", "makes", "models", "key_names", "key_types", "badge_types"] - - -for _ent in _SETTINGS_ENTITIES: - - @app.get(f"/api/settings/{_ent}", name=f"list_{_ent}") - def _list(entity=_ent): - conn = get_db() - result = _settings_list(conn, entity) - conn.close() - return result - - @app.get(f"/api/settings/{_ent}/{{eid}}", name=f"get_{_ent}") - def _get(eid: int, entity=_ent): - conn = get_db() - try: - result = _settings_get(conn, entity, eid) - conn.close() - return result - except HTTPException: - conn.close() - raise - - @app.post(f"/api/settings/{_ent}", status_code=201, name=f"create_{_ent}") - async def _create(request: Request, entity=_ent): - body = await request.json() - conn = get_db() - try: - result = _settings_create(conn, entity, body) - conn.close() - return result - except HTTPException: - conn.close() - raise - - @app.put(f"/api/settings/{_ent}/{{eid}}", name=f"update_{_ent}") - async def _update(eid: int, request: Request, entity=_ent): - body = await request.json() - conn = get_db() - try: - result = _settings_update(conn, entity, eid, body) - conn.close() - return result - except HTTPException: - conn.close() - raise - - @app.delete(f"/api/settings/{_ent}/{{eid}}", status_code=204, name=f"delete_{_ent}") - def _delete(eid: int, entity=_ent): - conn = get_db() - try: - _settings_delete(conn, entity, eid) - conn.close() - except HTTPException: - conn.close() - raise - # ─── Phase C: Helpers ──────────────────────────────────────────────────────── @@ -1966,166 +1287,15 @@ def _user_to_dict(row: sqlite3.Row) -> dict: return d -# ─── Phase C: Pydantic Models ──────────────────────────────────────────────── - - -class UserCreate(BaseModel): - username: str - password: str - role: Optional[str] = "technician" - - -class UserUpdate(BaseModel): - role: Optional[str] = None - password: Optional[str] = None - - class LoginRequest(BaseModel): username: str password: str - -class GeofenceCreate(BaseModel): - name: str - points: list - color: Optional[str] = "#3388ff" - user_ids: Optional[list[int]] = None - - -class GeofenceUpdate(BaseModel): - name: Optional[str] = None - points: Optional[list] = None - color: Optional[str] = None - user_ids: Optional[list[int]] = None - - class GeofencePointCheck(BaseModel): lat: float lng: float -# ─── Phase C: Users API ────────────────────────────────────────────────────── - - -@app.post("/api/users", status_code=201) -def create_user(body: UserCreate): - username = body.username.strip() - if not username: - raise HTTPException(status_code=422, detail="Username must not be empty") - password = body.password - if not password: - raise HTTPException(status_code=422, detail="Password must not be empty") - role = body.role or "technician" - if role not in VALID_ROLES: - raise HTTPException( - status_code=422, - detail=f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}", - ) - - conn = get_db() - password_hash = _hash_password(password) - try: - cursor = conn.execute( - "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)", - (username, password_hash, role), - ) - conn.commit() - uid = cursor.lastrowid - _log_activity(conn, "created", "user", uid, f"User '{username}' created") - conn.commit() - except sqlite3.IntegrityError: - conn.close() - raise HTTPException(status_code=409, detail=f"User '{username}' already exists") - - row = conn.execute("SELECT * FROM users WHERE id = ?", (uid,)).fetchone() - conn.close() - return _user_to_dict(row) - - -@app.get("/api/users") -def list_users(): - conn = get_db() - rows = conn.execute("SELECT * FROM users ORDER BY username").fetchall() - conn.close() - return [_user_to_dict(r) for r in rows] - - -@app.get("/api/users/{user_id}") -def get_user(user_id: int): - conn = get_db() - row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() - if row is None: - conn.close() - raise HTTPException(status_code=404, detail="User not found") - conn.close() - return _user_to_dict(row) - - -@app.put("/api/users/{user_id}") -def update_user(user_id: int, body: UserUpdate): - conn = get_db() - existing = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="User not found") - - if body.role is not None: - if body.role not in VALID_ROLES: - conn.close() - raise HTTPException( - status_code=422, - detail=f"Invalid role '{body.role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}", - ) - conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, user_id)) - - if body.password is not None: - pw = body.password.strip() - if not pw: - conn.close() - raise HTTPException(status_code=422, detail="Password must not be empty") - password_hash = _hash_password(pw) - conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id)) - - conn.commit() - row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() - conn.close() - return _user_to_dict(row) - - -@app.delete("/api/users/{user_id}", status_code=204) -def delete_user(user_id: int): - conn = get_db() - existing = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="User not found") - conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) - conn.commit() - conn.close() - - -# ─── User's assigned geofences (service areas) ────────────────────────── - - -@app.get("/api/users/{user_id}/geofences") -def list_user_geofences(user_id: int): - """List geofences assigned to a user (their service areas).""" - conn = get_db() - existing = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="User not found") - rows = conn.execute( - """SELECT g.* FROM geofences g - JOIN geofence_users gu ON gu.geofence_id = g.id - WHERE gu.user_id = ? ORDER BY g.name""", - (user_id,), - ).fetchall() - result = [_geofence_row(r, conn) for r in rows] - conn.close() - return result - - # ─── Phase C: Auth API ─────────────────────────────────────────────────────── @@ -2175,101 +1345,6 @@ def auth_me(request: Request): return _user_to_dict(row) -# ─── Phase C: Geofences API ────────────────────────────────────────────────── - - -@app.post("/api/geofences", status_code=201) -def create_geofence(body: GeofenceCreate): - conn = get_db() - points_json = _json.dumps(body.points) - cursor = conn.execute( - "INSERT INTO geofences (name, points, color) VALUES (?, ?, ?)", - (body.name, points_json, body.color or "#3388ff"), - ) - gid = cursor.lastrowid - - # Assign users if provided - if body.user_ids: - try: - _sync_geofence_users(conn, gid, body.user_ids) - except Exception: - conn.close() - raise HTTPException(status_code=422, detail="One or more user IDs are invalid") - - _log_activity(conn, "created", "geofence", gid, f"Geofence '{body.name}' created") - conn.commit() - - row = conn.execute("SELECT * FROM geofences WHERE id = ?", (gid,)).fetchone() - result = _geofence_row(row, conn) - conn.close() - return result - - -@app.get("/api/geofences") -def list_geofences(): - conn = get_db() - rows = conn.execute("SELECT * FROM geofences ORDER BY name").fetchall() - result = [_geofence_row(r, conn) for r in rows] - conn.close() - return result - - -@app.put("/api/geofences/{geofence_id}") -def update_geofence(geofence_id: int, body: GeofenceUpdate): - conn = get_db() - existing = conn.execute( - "SELECT id FROM geofences WHERE id = ?", (geofence_id,) - ).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="Geofence not found") - - updates = {} - if body.name is not None: - updates["name"] = body.name - if body.points is not None: - updates["points"] = _json.dumps(body.points) - if body.color is not None: - updates["color"] = body.color - - if updates: - updates["updated_at"] = "datetime('now')" - set_clause = ", ".join( - f"{k} = {v}" if k == "updated_at" else f"{k} = ?" - for k, v in updates.items() - ) - values = [v for k, v in updates.items() if k != "updated_at"] - conn.execute( - f"UPDATE geofences SET {set_clause} WHERE id = ?", - values + [geofence_id], - ) - conn.commit() - - # Sync assigned users if provided - if body.user_ids is not None: - _sync_geofence_users(conn, geofence_id, body.user_ids) - conn.commit() - - row = conn.execute( - "SELECT * FROM geofences WHERE id = ?", (geofence_id,) - ).fetchone() - result = _geofence_row(row, conn) - conn.close() - return result - - -@app.delete("/api/geofences/{geofence_id}", status_code=204) -def delete_geofence(geofence_id: int): - conn = get_db() - existing = conn.execute( - "SELECT id FROM geofences WHERE id = ?", (geofence_id,) - ).fetchone() - if existing is None: - conn.close() - raise HTTPException(status_code=404, detail="Geofence not found") - conn.execute("DELETE FROM geofences WHERE id = ?", (geofence_id,)) - conn.commit() - conn.close() # ─── Phase 0: Proximity & Geofence Check ───────────────────────────────────── @@ -2491,88 +1566,8 @@ def get_visit_stats(): } -# ─── Phase C: Activity Feed API ────────────────────────────────────────────── -@app.get("/api/activity") -def list_activity( - user_id: Optional[int] = Query(None), - entity_type: Optional[str] = Query(None), - date_from: Optional[str] = Query(None), - date_to: Optional[str] = Query(None), - limit: int = Query(100, ge=1, le=1000), - offset: int = Query(0, ge=0), -): - conn = get_db() - conditions = [] - params = [] - - if user_id is not None: - conditions.append("a.user_id = ?") - params.append(user_id) - if entity_type is not None: - conditions.append("a.entity_type = ?") - params.append(entity_type) - if date_from: - conditions.append("a.created_at >= ?") - params.append(date_from) - if date_to: - conditions.append("a.created_at <= ?") - params.append(date_to + " 23:59:59") - - where = " AND ".join(conditions) - sql = """SELECT a.*, u.username AS user_name - FROM activity_log a - LEFT JOIN users u ON a.user_id = u.id""" - if where: - sql += f" WHERE {where}" - sql += " ORDER BY a.created_at DESC, a.id DESC LIMIT ? OFFSET ?" - params.extend([limit, offset]) - - rows = conn.execute(sql, params).fetchall() - conn.close() - return [row_to_dict(r) for r in rows] - - -# ─── Phase C: Export Endpoints Extension ───────────────────────────────────── - - -@app.get("/api/export/service-summary") -def export_service_summary_csv(): - conn = get_db() - rows = conn.execute( - """SELECT c.name AS customer_name, l.name AS location_name, - COUNT(a.id) AS asset_count, - MAX(ck.created_at) AS last_checkin - FROM customers c - LEFT JOIN locations l ON l.customer_id = c.id - LEFT JOIN assets a ON a.customer_id = c.id - LEFT JOIN checkins ck ON ck.asset_id = a.id - GROUP BY c.id, l.id - ORDER BY c.name, l.name""" - ).fetchall() - conn.close() - - def _gen(): - output = io.StringIO() - writer = csv.DictWriter(output, fieldnames=["customer_name", "location_name", "asset_count", "last_checkin"]) - writer.writeheader() - yield output.getvalue() - output.seek(0) - output.truncate(0) - for row in rows: - d = row_to_dict(row) - d["last_checkin"] = d["last_checkin"] or "" - writer.writerow(d) - yield output.getvalue() - output.seek(0) - output.truncate(0) - - return StreamingResponse( - _gen(), - media_type="text/csv", - headers={"Content-Disposition": "attachment; filename=service_summary.csv"}, - ) @@ -2683,10 +1678,6 @@ def _save_upload(upload: UploadFile, subdir: str, allowed_exts: set, max_size: i return f"/uploads/{subdir}/{fname}" -@app.post("/api/upload/icon", status_code=201) -async def upload_icon(file: UploadFile = File(...)): - path = _save_upload(file, "icons", ICON_ALLOWED_EXTS, ICON_MAX_SIZE) - return {"path": path} @app.post("/api/upload/photo", status_code=201) @@ -2947,56 +1938,6 @@ def geocode(lat: float = Query(...), lng: float = Query(...)): return result -# ─── Admin endpoints ──────────────────────────────────────────────────────── - - -class AdminQuery(BaseModel): - sql: str - - -@app.post("/api/admin/reset", status_code=200) -def admin_reset(): - """Drop and recreate all tables, reseed defaults. Destructive.""" - conn = get_db() - # Drop all tables (order matters for foreign keys) - tables = [ - "sessions", "geofence_users", "geofences", "visits", - "asset_badges", "asset_keys", "checkins", "assets", - "models", "makes", "badge_types", "key_types", "key_names", - "categories", "rooms", "locations", "customer_contacts", - "customers", "activity_log", "users", "settings", - ] - for table in tables: - conn.execute(f"DROP TABLE IF EXISTS {table}") - conn.commit() - _create_v2_tables(conn) - _seed_data(conn) - _ensure_unique_machine_id(conn) - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_assets_category ON assets(category)" - ) - conn.commit() - conn.close() - return {"message": "Database reset complete — all tables reinitialized with defaults."} - - -@app.post("/api/admin/query") -def admin_query(body: AdminQuery): - """Run an arbitrary SQL query (SELECT only). Returns rows as JSON.""" - sql = body.sql.strip().upper() - # Only allow SELECT queries - if not sql.startswith("SELECT"): - raise HTTPException(status_code=400, detail="Only SELECT queries are allowed") - conn = get_db() - try: - rows = conn.execute(body.sql).fetchall() - conn.close() - return [dict(r) for r in rows] - except Exception as e: - conn.close() - raise HTTPException(status_code=400, detail=str(e)) - - # ─── Static Files (mounted last to not shadow routes) ────────────────────── app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")