Initial commit
This commit is contained in:
251
app/main.py
Normal file
251
app/main.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""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
|
||||
]
|
||||
Reference in New Issue
Block a user