Add optional nursery roster composition

This commit is contained in:
Media
2026-08-30 20:04:00 -05:00
parent 3eab672c6c
commit 7651a3ed9d
12 changed files with 1578 additions and 116 deletions
+65 -44
View File
@@ -21,10 +21,13 @@ from urllib.parse import quote, unquote, urlsplit
from roster_formatter import (
BASE_DIR,
CURRENT_LAYOUT_FIELDS,
DEFAULT_LAYOUT,
LEGACY_LAYOUT_FIELDS,
LayoutSettings,
RosterFormatError,
RosterPeriod,
format_combined_roster,
format_roster,
)
@@ -37,6 +40,7 @@ DEFAULT_MAX_UPLOAD_MB = 20
RETENTION_CHECK_SECONDS = 6 * 60 * 60
DEFAULT_ALLOWED_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"})
LAYOUT_DEFAULTS_FILENAME = ".roster-layout-defaults.json"
LAYOUT_DEFAULTS_VERSION = 2
MAX_DEFAULTS_REQUEST_BYTES = 16 * 1024
@@ -110,14 +114,21 @@ class OutputStore:
try:
payload = json.loads(raw.decode("utf-8"))
if not isinstance(payload, dict) or payload.get("version") != 1:
if not isinstance(payload, dict) or set(payload) != {"version", "layout"}:
raise ValueError("unsupported settings format")
layout = payload.get("layout")
expected = set(DEFAULT_LAYOUT.as_form_values())
if not isinstance(layout, dict) or set(layout) != expected:
if not isinstance(layout, dict):
raise ValueError("settings fields do not match")
version = payload.get("version")
submitted_fields = frozenset(layout)
if version == 1 and submitted_fields == LEGACY_LAYOUT_FIELDS:
pass
elif version == LAYOUT_DEFAULTS_VERSION and submitted_fields == CURRENT_LAYOUT_FIELDS:
pass
else:
raise ValueError("unsupported settings version or fields")
values = {name: str(value) for name, value in layout.items()}
return LayoutSettings.from_mapping(values)
return LayoutSettings.from_mapping(values, defaults=DEFAULT_LAYOUT)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError) as exc:
LOGGER.warning("Ignoring invalid saved layout defaults: %s", exc)
return DEFAULT_LAYOUT
@@ -125,7 +136,10 @@ class OutputStore:
def save_layout_defaults(self, settings: LayoutSettings) -> None:
data = (
json.dumps(
{"version": 1, "layout": settings.as_json_values()},
{
"version": LAYOUT_DEFAULTS_VERSION,
"layout": settings.as_json_values(),
},
indent=2,
sort_keys=True,
)
@@ -280,7 +294,7 @@ class RosterWebApp:
self.logo_path = BASE_DIR / "assets" / "logo.png"
self.store = OutputStore(output_dir, retention_months)
self.max_upload_bytes = max_upload_bytes
self.max_request_bytes = max_upload_bytes + 1024 * 1024
self.max_request_bytes = 2 * max_upload_bytes + 1024 * 1024
self.allowed_hosts = DEFAULT_ALLOWED_HOSTS | frozenset(
host.lower().rstrip(".") for host in (allowed_hosts or set())
)
@@ -294,23 +308,11 @@ class RosterWebApp:
"__DEFAULT_MONTH__": RosterPeriod.suggested().html_value,
"__RETENTION_MONTHS__": str(self.store.retention_months),
"__MAX_UPLOAD_MB__": str(self.max_upload_bytes // (1024 * 1024)),
"__CONTENT_SHIFT__": str(saved_layout.content_shift),
"__BOTTOM_MARGIN__": str(saved_layout.bottom_margin),
"__LOGO_X__": str(saved_layout.logo_x),
"__LOGO_Y__": str(saved_layout.logo_y),
"__LOGO_SCALE__": str(saved_layout.logo_scale),
"__DATE_X__": str(saved_layout.date_x),
"__DATE_Y__": str(saved_layout.date_y),
"__DATE_FONT_SIZE__": str(saved_layout.date_font_size),
"__FACTORY_CONTENT_SHIFT__": str(DEFAULT_LAYOUT.content_shift),
"__FACTORY_BOTTOM_MARGIN__": str(DEFAULT_LAYOUT.bottom_margin),
"__FACTORY_LOGO_X__": str(DEFAULT_LAYOUT.logo_x),
"__FACTORY_LOGO_Y__": str(DEFAULT_LAYOUT.logo_y),
"__FACTORY_LOGO_SCALE__": str(DEFAULT_LAYOUT.logo_scale),
"__FACTORY_DATE_X__": str(DEFAULT_LAYOUT.date_x),
"__FACTORY_DATE_Y__": str(DEFAULT_LAYOUT.date_y),
"__FACTORY_DATE_FONT_SIZE__": str(DEFAULT_LAYOUT.date_font_size),
}
for name, value in saved_layout.as_form_values().items():
replacements[f"__{name.upper()}__"] = value
for name, value in DEFAULT_LAYOUT.as_form_values().items():
replacements[f"__FACTORY_{name.upper()}__"] = value
rendered = template
for placeholder, value in replacements.items():
rendered = rendered.replace(placeholder, value)
@@ -318,21 +320,27 @@ class RosterWebApp:
def format_upload(
self,
upload: UploadedFile,
uploads: list[UploadedFile],
values: Mapping[str, str],
) -> tuple[bytes, str, bool, list[str]]:
if not upload.filename:
raise RosterFormatError("Choose a roster PDF to upload.")
if Path(upload.filename).suffix.lower() != ".pdf":
raise RosterFormatError("Choose a file whose name ends in .pdf.")
if not upload.data:
raise RosterFormatError("The uploaded PDF is empty.")
if len(upload.data) > self.max_upload_bytes:
maximum = self.max_upload_bytes // (1024 * 1024)
raise RequestError(
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
f"The PDF must be {maximum} MB or smaller.",
)
if len(uploads) not in {1, 2}:
raise RosterFormatError("Choose one or two roster PDFs to upload.")
for index, upload in enumerate(uploads, start=1):
prefix = "" if len(uploads) == 1 else f"PDF {index}: "
if not upload.filename:
raise RosterFormatError(f"{prefix}Choose a roster PDF to upload.")
if Path(upload.filename).suffix.lower() != ".pdf":
raise RosterFormatError(
f"{prefix}Choose a file whose name ends in .pdf."
)
if not upload.data:
raise RosterFormatError(f"{prefix}The uploaded PDF is empty.")
if len(upload.data) > self.max_upload_bytes:
maximum = self.max_upload_bytes // (1024 * 1024)
raise RequestError(
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
f"{prefix}Each PDF must be {maximum} MB or smaller.",
)
period = RosterPeriod.parse(values.get("month"))
settings = LayoutSettings.from_mapping(
@@ -343,10 +351,23 @@ class RosterWebApp:
with self._format_lock:
with TemporaryDirectory(prefix="roster-web-") as temp_dir:
work_dir = Path(temp_dir)
input_path = work_dir / "upload.pdf"
input_paths = [
work_dir / f"upload-{index}.pdf"
for index in range(1, len(uploads) + 1)
]
output_path = work_dir / period.output_filename
input_path.write_bytes(upload.data)
format_roster(input_path, output_path, period, settings)
for input_path, upload in zip(input_paths, uploads, strict=True):
input_path.write_bytes(upload.data)
if len(input_paths) == 1:
format_roster(input_paths[0], output_path, period, settings)
else:
format_combined_roster(
input_paths[0],
input_paths[1],
output_path,
period,
settings,
)
png = output_path.read_bytes()
_, removed, saved = self.store.save(period.output_filename, png)
@@ -363,7 +384,7 @@ class RosterWebApp:
return settings
def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], UploadedFile]:
def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], list[UploadedFile]]:
if "\r" in content_type or "\n" in content_type:
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload content type.")
@@ -412,12 +433,12 @@ def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], Upl
except (LookupError, UnicodeDecodeError) as exc:
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid form text encoding.") from exc
if len(uploads) != 1:
if len(uploads) not in {1, 2}:
raise RequestError(
HTTPStatus.BAD_REQUEST,
"Upload exactly one roster PDF.",
"Upload one or two roster PDFs.",
)
return values, uploads[0]
return values, uploads
class RosterHTTPServer(ThreadingHTTPServer):
@@ -678,8 +699,8 @@ class RosterRequestHandler(BaseHTTPRequestHandler):
if len(body) != content_length:
raise RequestError(HTTPStatus.BAD_REQUEST, "The upload was incomplete.")
values, upload = parse_multipart(content_type, body)
png, filename, saved, removed = self.app.format_upload(upload, values)
values, uploads = parse_multipart(content_type, body)
png, filename, saved, removed = self.app.format_upload(uploads, values)
if removed:
LOGGER.info("Retention removed: %s", ", ".join(removed))
self._send_bytes(