diff --git a/app/routes/api.py b/app/routes/api.py index 64b127a7..b34ea85f 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -6,6 +6,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from sqlmodel import Session, select @@ -14,7 +15,7 @@ from app.models.user import PublicContact, UserModel, user_update_instance from app.util.auth_dependencies import CurrentMember from app.util.database import get_session -from app.util.forms import FORM_CORRECT_ANSWERS, Forms, apply_fuzzy_parsing, iter_form_elements, transform_dict, wrong_quiz_answers +from app.util.forms import FORM_CORRECT_ANSWERS, Forms, apply_fuzzy_parsing, form_field_labels, iter_form_elements, transform_dict, wrong_quiz_answers from app.util.kennelish import Transformer logger = logging.getLogger(__name__) @@ -177,7 +178,20 @@ async def post_form( except json.JSONDecodeError: return {"description": "Malformed JSON input."} - model_validated = model(**inp).model_dump() + try: + model_validated = model(**inp).model_dump() + except ValidationError as e: + # A typo'd email, or a radio answer from a page opened before the + # form's options changed. That's the member's to fix, not a crash; + # log field names and error types only, since the input is PII. + failures = [(str(err["loc"][0]) if err["loc"] else "", err["type"]) for err in e.errors()] + logger.warning("Form %s submit failed validation: %s", num, failures) + bad_keys = list(dict.fromkeys(key for key, _ in failures)) + labels = form_field_labels(kennelish_data) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Please check: " + "; ".join(labels.get(key, key) for key in bad_keys) + ". If the form changed since you opened it, reload the page and try again.", + ) from e # Grade quiz-style radios, if this form has any. Wrong answers bounce # the submission back instead of getting silently recorded. diff --git a/app/util/forms.py b/app/util/forms.py index ce9fad1a..8eecbbd7 100644 --- a/app/util/forms.py +++ b/app/util/forms.py @@ -59,6 +59,23 @@ def iter_form_elements(kennelish_data): yield from iter_form_elements(el.get("elements")) +def form_field_labels(kennelish_data) -> dict[str, str]: + """ + Map each field key in a Kennelish form to what the member sees it called: + its label, else the nearest h3 above it (quiz radios only carry a generic + "What do you do?" caption), else its caption, else the key itself. + """ + labels = {} + heading = None + for el in iter_form_elements(kennelish_data): + if el.get("input") == "h3": + heading = el.get("label") + key = el.get("key") + if key: + labels[key] = el.get("label") or heading or el.get("caption") or key + return labels + + def wrong_quiz_answers(num: str, kennelish_data, submitted: dict) -> list[str]: """ Return the headings of the quiz questions in form `num` that `submitted` diff --git a/tests/test_admin_compliance.py b/tests/test_admin_compliance.py index 7beb21ab..2f148142 100644 --- a/tests/test_admin_compliance.py +++ b/tests/test_admin_compliance.py @@ -53,6 +53,21 @@ def test_wrong_answer_names_the_question(client: TestClient, session: Session, a assert not admin_user.admin_compliance_signtime +def test_stale_option_is_a_friendly_422(client: TestClient, untrained_admin_jwt: str): + # Page opened before an option's wording changed, submitted after. + body = {**ANSWERS, "admin_compliance_reporter_ack": "Ask for members consent before sending their emails"} + response = client.post(f"/api/form/{FORM}", cookies={"token": untrained_admin_jwt}, json=body) + assert response.status_code == 422 + assert "Scenario 1" in response.json()["detail"] + assert "reload the page" in response.json()["detail"] + + +def test_malformed_email_is_a_friendly_422(client: TestClient, jwt: str): + response = client.post("/api/form/2", cookies={"token": jwt}, json={"email": "someone@nodomain"}) + assert response.status_code == 422 + assert "Preferred Email" in response.json()["detail"] + + def test_passing_unlocks_panel_and_stamps_signtime_server_side(client: TestClient, session: Session, admin_user: UserModel, untrained_admin_jwt: str): # No signature time in the body: the server sets it on a pass. response = client.post(f"/api/form/{FORM}", cookies={"token": untrained_admin_jwt}, json=ANSWERS)