Add optional nursery roster composition
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user