"""FastAPI app — Jimi Gallery API + static file serving.""" import os from pathlib import Path from fastapi import Body, Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from auth import ADMIN_PASSWORD, issue_token, require_auth from db import artists, exhibitions, news, settings_col, strip_id from seed import SEED app = FastAPI(title="Jimi Gallery API") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) # -------------------- bootstrap -------------------- @app.on_event("startup") async def bootstrap(): """Seed an empty DB on first run so the app has something to show.""" if await settings_col.find_one({"_id": "singleton"}) is None: await settings_col.insert_one({"_id": "singleton", **SEED["settings"]}) if await artists.estimated_document_count() == 0 and SEED["artists"]: await artists.insert_many([dict(a) for a in SEED["artists"]]) if await exhibitions.estimated_document_count() == 0 and SEED["exhibitions"]: await exhibitions.insert_many([dict(e) for e in SEED["exhibitions"]]) if await news.estimated_document_count() == 0 and SEED["news"]: await news.insert_many([dict(n) for n in SEED["news"]]) # -------------------- auth -------------------- @app.post("/api/auth/login") async def login(body: dict = Body(...)): if body.get("password") != ADMIN_PASSWORD: raise HTTPException(status_code=401, detail="Incorrect password") return {"token": issue_token()} @app.get("/api/auth/me") async def me(_: bool = Depends(require_auth)): return {"ok": True} # -------------------- settings -------------------- @app.get("/api/settings") async def get_settings(): doc = await settings_col.find_one({"_id": "singleton"}) if doc is None: doc = {"_id": "singleton", **SEED["settings"]} await settings_col.insert_one(dict(doc)) return strip_id(doc) @app.put("/api/settings") async def put_settings(body: dict = Body(...), _: bool = Depends(require_auth)): body.pop("_id", None) await settings_col.update_one( {"_id": "singleton"}, {"$set": body}, upsert=True ) return strip_id(await settings_col.find_one({"_id": "singleton"})) # -------------------- helpers for collections -------------------- def _require_id(body): if not body.get("id"): raise HTTPException(status_code=400, detail="id is required") async def _list(coll): return [strip_id(d) async for d in coll.find({})] async def _get(coll, id): d = await coll.find_one({"id": id}) if d is None: raise HTTPException(status_code=404, detail="not found") return strip_id(d) async def _upsert(coll, id, body): body.pop("_id", None) body["id"] = id await coll.update_one({"id": id}, {"$set": body}, upsert=True) return strip_id(await coll.find_one({"id": id})) async def _delete(coll, id): await coll.delete_one({"id": id}) return {"ok": True} # -------------------- artists -------------------- @app.get("/api/artists") async def list_artists(): return await _list(artists) @app.get("/api/artists/{id}") async def get_artist(id: str): return await _get(artists, id) @app.put("/api/artists/{id}") async def put_artist(id: str, body: dict = Body(...), _: bool = Depends(require_auth)): return await _upsert(artists, id, body) @app.delete("/api/artists/{id}") async def delete_artist(id: str, _: bool = Depends(require_auth)): return await _delete(artists, id) # -------------------- exhibitions -------------------- @app.get("/api/exhibitions") async def list_exhibitions(): return await _list(exhibitions) @app.get("/api/exhibitions/{id}") async def get_exhibition(id: str): return await _get(exhibitions, id) @app.put("/api/exhibitions/{id}") async def put_exhibition(id: str, body: dict = Body(...), _: bool = Depends(require_auth)): return await _upsert(exhibitions, id, body) @app.delete("/api/exhibitions/{id}") async def delete_exhibition(id: str, _: bool = Depends(require_auth)): return await _delete(exhibitions, id) # -------------------- news -------------------- @app.get("/api/news") async def list_news(): return await _list(news) @app.get("/api/news/{id}") async def get_news_item(id: str): return await _get(news, id) @app.put("/api/news/{id}") async def put_news_item(id: str, body: dict = Body(...), _: bool = Depends(require_auth)): return await _upsert(news, id, body) @app.delete("/api/news/{id}") async def delete_news_item(id: str, _: bool = Depends(require_auth)): return await _delete(news, id) # -------------------- admin: seed / export / import -------------------- async def _apply_full_set(settings_doc, artists_list, exhibitions_list, news_list): await settings_col.delete_many({}) await artists.delete_many({}) await exhibitions.delete_many({}) await news.delete_many({}) s = dict(settings_doc) s.pop("_id", None) await settings_col.insert_one({"_id": "singleton", **s}) if artists_list: await artists.insert_many([dict(a) for a in artists_list]) if exhibitions_list: await exhibitions.insert_many([dict(e) for e in exhibitions_list]) if news_list: await news.insert_many([dict(n) for n in news_list]) @app.post("/api/admin/seed") async def apply_seed(_: bool = Depends(require_auth)): await _apply_full_set( SEED["settings"], SEED["artists"], SEED["exhibitions"], SEED["news"] ) return {"ok": True} @app.get("/api/admin/export") async def export_all(): settings_doc = await settings_col.find_one({"_id": "singleton"}) or {} return { "settings": strip_id(settings_doc) or dict(SEED["settings"]), "artists": [strip_id(d) async for d in artists.find({})], "exhibitions": [strip_id(d) async for d in exhibitions.find({})], "news": [strip_id(d) async for d in news.find({})], } @app.post("/api/admin/import") async def import_all(body: dict = Body(...), _: bool = Depends(require_auth)): missing = [ k for k in ("settings", "artists", "exhibitions", "news") if k not in body ] if missing: raise HTTPException(status_code=400, detail=f"missing keys: {missing}") await _apply_full_set( body["settings"], body["artists"], body["exhibitions"], body["news"] ) return {"ok": True} # -------------------- static file serving -------------------- STATIC_DIR = Path( os.environ.get("STATIC_DIR", Path(__file__).resolve().parent.parent) ) # Mounted last so /api/* routes defined above take precedence. app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")