feat: initial Jimi Gallery prototype
- Public site (Home/Artists/Exhibitions/News/About/Contact) with EN/KO/JA i18n - Admin panel with login, CRUD, image upload, multilingual editing - Exhibition slider/lightbox view - FastAPI + MongoDB backend, JWT auth - Docker Compose deployment, behind nginx at jimi.yakenator.io
This commit is contained in:
228
backend/main.py
Normal file
228
backend/main.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user