252 lines
7.3 KiB
Python
252 lines
7.3 KiB
Python
"""FastAPI app: routes for puzzle generation and theme management."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from fastapi import FastAPI, Form, HTTPException, Request
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from . import themes as theme_store
|
|
from .generator import generate
|
|
from .normaliser import normalise_all
|
|
from .pdf import render_puzzle
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
STATIC_DIR = ROOT / "static"
|
|
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
|
|
|
app = FastAPI(title="Word Search Puzzle Generator")
|
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|
|
|
|
|
def _checkbox(value: str | None) -> bool:
|
|
return value is not None and value != "" and value.lower() not in {"0", "false", "off"}
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index(request: Request):
|
|
available = theme_store.list_themes()
|
|
return templates.TemplateResponse(
|
|
"index.html",
|
|
{"request": request, "themes": available, "errors": [], "warnings": []},
|
|
)
|
|
|
|
|
|
@app.post("/generate")
|
|
async def generate_puzzle(
|
|
request: Request,
|
|
theme: str = Form(...),
|
|
grid_size: int = Form(...),
|
|
word_count: int = Form(...),
|
|
min_length: int = Form(...),
|
|
max_length: int = Form(...),
|
|
title: str = Form(""),
|
|
diagonal: str | None = Form(None),
|
|
reversed_: str | None = Form(None, alias="reversed"),
|
|
overlap: str | None = Form(None),
|
|
):
|
|
available = theme_store.list_themes()
|
|
errors: list[str] = []
|
|
|
|
try:
|
|
loaded = theme_store.load_theme(theme)
|
|
except theme_store.ThemeError as e:
|
|
errors.append(str(e))
|
|
return templates.TemplateResponse(
|
|
"index.html",
|
|
{"request": request, "themes": available, "errors": errors,
|
|
"warnings": []},
|
|
status_code=400,
|
|
)
|
|
|
|
if max_length < min_length:
|
|
errors.append("max length must be greater than or equal to min length")
|
|
|
|
if errors:
|
|
return templates.TemplateResponse(
|
|
"index.html",
|
|
{"request": request, "themes": available, "errors": errors,
|
|
"warnings": []},
|
|
status_code=400,
|
|
)
|
|
|
|
try:
|
|
puzzle = generate(
|
|
words=loaded.words,
|
|
grid_size=grid_size,
|
|
word_count=word_count,
|
|
min_length=min_length,
|
|
max_length=max_length,
|
|
diagonal=_checkbox(diagonal),
|
|
reversed_=_checkbox(reversed_),
|
|
allow_overlap=_checkbox(overlap),
|
|
)
|
|
except ValueError as e:
|
|
return templates.TemplateResponse(
|
|
"index.html",
|
|
{"request": request, "themes": available, "errors": [str(e)],
|
|
"warnings": []},
|
|
status_code=400,
|
|
)
|
|
|
|
display_title = title.strip() or loaded.name
|
|
pdf_bytes = render_puzzle(puzzle, display_title)
|
|
|
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
filename = f"{loaded.slug}_{timestamp}.pdf"
|
|
return Response(
|
|
content=pdf_bytes,
|
|
media_type="application/pdf",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@app.get("/themes", response_class=HTMLResponse)
|
|
def themes_index(request: Request):
|
|
return templates.TemplateResponse(
|
|
"themes.html",
|
|
{"request": request, "themes": theme_store.list_themes()},
|
|
)
|
|
|
|
|
|
@app.get("/themes/new", response_class=HTMLResponse)
|
|
def themes_new(request: Request):
|
|
return templates.TemplateResponse(
|
|
"theme_edit.html",
|
|
{
|
|
"request": request,
|
|
"is_new": True,
|
|
"name": "",
|
|
"slug": "",
|
|
"words_text": "",
|
|
"errors": [],
|
|
},
|
|
)
|
|
|
|
|
|
@app.get("/themes/{slug}/edit", response_class=HTMLResponse)
|
|
def themes_edit(request: Request, slug: str):
|
|
try:
|
|
t = theme_store.load_theme(slug)
|
|
except theme_store.ThemeError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
return templates.TemplateResponse(
|
|
"theme_edit.html",
|
|
{
|
|
"request": request,
|
|
"is_new": False,
|
|
"name": t.name,
|
|
"slug": t.slug,
|
|
"words_text": "\n".join(t.words),
|
|
"errors": [],
|
|
},
|
|
)
|
|
|
|
|
|
@app.post("/themes")
|
|
def themes_create(
|
|
request: Request,
|
|
name: str = Form(...),
|
|
slug: str = Form(""),
|
|
words: str = Form(""),
|
|
):
|
|
proposed_slug = (slug.strip() or theme_store.slugify(name))
|
|
parsed = theme_store.parse_words_textarea(words)
|
|
errors: list[str] = []
|
|
if not theme_store.is_valid_slug(proposed_slug):
|
|
errors.append("slug must be lowercase letters, digits, and hyphens only")
|
|
if errors:
|
|
return templates.TemplateResponse(
|
|
"theme_edit.html",
|
|
{
|
|
"request": request,
|
|
"is_new": True,
|
|
"name": name,
|
|
"slug": proposed_slug,
|
|
"words_text": words,
|
|
"errors": errors,
|
|
},
|
|
status_code=400,
|
|
)
|
|
try:
|
|
theme_store.save_theme(proposed_slug, name, parsed)
|
|
except theme_store.ThemeError as e:
|
|
return templates.TemplateResponse(
|
|
"theme_edit.html",
|
|
{
|
|
"request": request,
|
|
"is_new": True,
|
|
"name": name,
|
|
"slug": proposed_slug,
|
|
"words_text": words,
|
|
"errors": [str(e)],
|
|
},
|
|
status_code=400,
|
|
)
|
|
return RedirectResponse(url="/themes", status_code=303)
|
|
|
|
|
|
@app.post("/themes/{slug}")
|
|
def themes_update(
|
|
request: Request,
|
|
slug: str,
|
|
name: str = Form(...),
|
|
words: str = Form(""),
|
|
):
|
|
parsed = theme_store.parse_words_textarea(words)
|
|
try:
|
|
theme_store.save_theme(slug, name, parsed)
|
|
except theme_store.ThemeError as e:
|
|
return templates.TemplateResponse(
|
|
"theme_edit.html",
|
|
{
|
|
"request": request,
|
|
"is_new": False,
|
|
"name": name,
|
|
"slug": slug,
|
|
"words_text": words,
|
|
"errors": [str(e)],
|
|
},
|
|
status_code=400,
|
|
)
|
|
return RedirectResponse(url="/themes", status_code=303)
|
|
|
|
|
|
@app.post("/themes/{slug}/delete")
|
|
def themes_delete(slug: str):
|
|
try:
|
|
theme_store.delete_theme(slug)
|
|
except theme_store.ThemeError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
return RedirectResponse(url="/themes", status_code=303)
|
|
|
|
|
|
@app.get("/api/themes")
|
|
def api_themes():
|
|
return [{"slug": t.slug, "name": t.name, "word_count": len(t.words)}
|
|
for t in theme_store.list_themes()]
|
|
|
|
|
|
@app.post("/api/normalise")
|
|
async def api_normalise(request: Request):
|
|
payload = await request.json()
|
|
words = payload.get("words", []) if isinstance(payload, dict) else []
|
|
if not isinstance(words, list):
|
|
return JSONResponse({"error": "words must be a list"}, status_code=400)
|
|
results = normalise_all([str(w) for w in words])
|
|
return [
|
|
{
|
|
"display": n.display,
|
|
"grid": n.grid,
|
|
"stripped": n.stripped_prefixes,
|
|
"skipped": n.skipped,
|
|
"warning": n.warning,
|
|
}
|
|
for n in results
|
|
]
|