Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions app/util/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
15 changes: 15 additions & 0 deletions tests/test_admin_compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading