Files
Random-Christmas-Bot/webapp/app.py
T
jeanGaston 5393feefc5 Add web GUI and Docker deployment for managing the Secret Santa draw
- webapp/: FastAPI dashboard to add/remove participants and exclusions,
  view draw history, and trigger the draw (reuses main.run_draw()).
  Optional HTTP basic auth via WEBAPP_USERNAME/WEBAPP_PASSWORD.
- file_io.py: add add/remove_participant, add/remove_exclusion, load_year
  helpers backing the web GUI's CRUD actions.
- Dockerfile + docker-compose.yml: containerize the web GUI, with CSV_PATH
  bind-mounted to a host folder so participant data persists outside the
  container. Verified with a local docker compose build/up smoke test.
- README: document the web GUI, Docker usage, and the exclusions CSV file
  (existing feature that wasn't documented yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:36:02 +02:00

115 lines
3.8 KiB
Python

import secrets
import sys
from pathlib import Path
from typing import Annotated
SRC_DIR = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(SRC_DIR))
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from env import CSV_PREFIX, HISTORY_YEARS, WEBAPP_PASSWORD, WEBAPP_USERNAME
from file_io import (
add_exclusion,
add_participant,
load_exclusions,
load_participants,
load_year,
remove_exclusion,
remove_participant,
)
from main import run_draw
from utils import get_last_n_years
app = FastAPI(title="Random Christmas Bot")
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
security = HTTPBasic(auto_error=False)
def require_auth(credentials: Annotated[HTTPBasicCredentials | None, Depends(security)]):
if not WEBAPP_USERNAME or not WEBAPP_PASSWORD:
return # auth disabled — no credentials configured
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Basic"},
)
valid_user = secrets.compare_digest(credentials.username, WEBAPP_USERNAME)
valid_pass = secrets.compare_digest(credentials.password, WEBAPP_PASSWORD)
if not (valid_user and valid_pass):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
@app.get("/")
def dashboard(request: Request, _: None = Depends(require_auth)):
return templates.TemplateResponse(
request,
"index.html",
{
"participants": load_participants(),
"exclusions": sorted(load_exclusions()),
"csv_prefix": CSV_PREFIX,
},
)
@app.post("/participants/add")
def participants_add(name: str = Form(...), email: str = Form(...), _: None = Depends(require_auth)):
add_participant(name.strip(), email.strip())
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/participants/remove")
def participants_remove(name: str = Form(...), _: None = Depends(require_auth)):
remove_participant(name)
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/exclusions/add")
def exclusions_add(giver: str = Form(...), receiver: str = Form(...), _: None = Depends(require_auth)):
add_exclusion(giver.strip(), receiver.strip())
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/exclusions/remove")
def exclusions_remove(giver: str = Form(...), receiver: str = Form(...), _: None = Depends(require_auth)):
remove_exclusion(giver, receiver)
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/history")
def history(request: Request, _: None = Depends(require_auth)):
years = get_last_n_years(HISTORY_YEARS)
year_data = [(year, load_year(year)) for year in years]
return templates.TemplateResponse(
request,
"history.html",
{"year_data": year_data},
)
@app.post("/draw/run")
def draw_run(request: Request, _: None = Depends(require_auth)):
try:
new_draw = run_draw()
error = None
except Exception as exc: # draw can legitimately fail (not enough participants, etc.)
new_draw = []
error = str(exc)
return templates.TemplateResponse(
request,
"draw_result.html",
{"new_draw": new_draw, "error": error},
)