This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/main by this push:
     new 7e2a65f  Improve the form submission error message summary
7e2a65f is described below

commit 7e2a65f48c5ab8d45c95377afa8f79a5b45646bd
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Nov 7 15:16:10 2025 +0000

    Improve the form submission error message summary
---
 atr/blueprints/post.py          | 43 +++++++++++++++++++++++++++++++++++------
 atr/form.py                     | 21 +++++++++++++++++---
 atr/htm.py                      | 11 +++++++++--
 atr/templates/macros/flash.html | 10 ++++++----
 4 files changed, 70 insertions(+), 15 deletions(-)

diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py
index c539c70..635b50c 100644
--- a/atr/blueprints/post.py
+++ b/atr/blueprints/post.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import json
 import time
 from collections.abc import Awaitable, Callable
 from types import ModuleType
@@ -23,10 +24,12 @@ from typing import Any
 import asfquart.auth as auth
 import asfquart.base as base
 import asfquart.session
+import markupsafe
 import pydantic
 import quart
 
 import atr.form
+import atr.htm as htm
 import atr.log as log
 import atr.web as web
 
@@ -110,10 +113,10 @@ def empty() -> Callable[[Callable[..., Awaitable[Any]]], 
Callable[..., Awaitable
                 context = {"session": session}
                 atr.form.validate(atr.form.Empty, form_data, context)
                 return await func(session, *args, **kwargs)
-            except pydantic.ValidationError as e:
-                # This presumably should not happen
-                log.warning(f"Empty form validation error (CSRF): {e}")
-                await quart.flash("Invalid form submission. Please try 
again.", "error")
+            except pydantic.ValidationError:
+                # This presumably should not happen, because the CSRF token 
checker reaches it first
+                msg = "Sorry, your form session expired for security reasons. 
Please try again."
+                await quart.flash(msg, "error")
                 return quart.redirect(quart.request.path)
 
         wrapper.__name__ = func.__name__
@@ -135,8 +138,36 @@ def form(
                 validated_form = atr.form.validate(form_cls, form_data, 
context)
                 return await func(session, validated_form, *args, **kwargs)
             except pydantic.ValidationError as e:
-                log.warning(f"Form validation error: {e}")
-                await quart.flash(f"Validation error: {e}", "error")
+                errors = e.errors()
+                if len(errors) == 0:
+                    raise RuntimeError("Validation failed, but no errors were 
reported")
+
+                flash_data = {}
+                for i, error in enumerate(errors):
+                    loc = error["loc"]
+                    kind = error["type"]
+                    msg = error["msg"]
+                    msg = msg.replace(": An email address", " because an email 
address")
+                    msg = msg.replace("Value error, ", "")
+                    original = error["input"]
+                    field_name, field_label = 
atr.form.name_and_label(form_cls, i, loc)
+                    flash_data[field_name] = {
+                        "label": field_label,
+                        "original": original,
+                        "kind": kind,
+                        "msg": msg,
+                    }
+
+                plural = len(errors) > 1
+                summary = f"Please fix the following issue{'s' if plural else 
''}:"
+                ul = htm.Block(htm.ul, classes=".mt-2.mb-0")
+                for _name, flash_datum in flash_data.items():
+                    ul.li[htm.strong[flash_datum["label"]], ": ", 
flash_datum["msg"]]
+                summary = f"{summary}\n{ul.collect()}"
+
+                # TODO: Centralise all uses of markupsafe.Markup
+                await quart.flash(markupsafe.Markup(summary), category="error")
+                await quart.flash(json.dumps(flash_data), 
category="form-error-data")
                 return quart.redirect(quart.request.path)
 
         wrapper.__name__ = func.__name__
diff --git a/atr/form.py b/atr/form.py
index d6754d3..8d5ea28 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -69,9 +69,19 @@ def label(description: str, *, default: Any = ..., widget: 
Widget | None = None)
     return pydantic.Field(default, description=description, 
json_schema_extra=extra)
 
 
-def session(info: pydantic.ValidationInfo) -> web.Committer | None:
-    ctx: dict[str, Any] = info.context or {}
-    return ctx.get("session")
+def name_and_label(form_cls: type[Form], i: int, loc: tuple[str | int, ...]) 
-> tuple[str, str]:
+    if loc:
+        field_name = loc[0]
+        if isinstance(field_name, str):
+            field_info = form_cls.model_fields.get(field_name)
+            if field_info and field_info.description:
+                field_label = field_info.description
+            else:
+                field_label = field_name.replace("_", " ").title()
+            return field_name, field_label
+    field_name = f".{i}"
+    field_label = "?"
+    return field_name, field_label
 
 
 async def quart_request() -> dict[str, Any]:
@@ -170,6 +180,11 @@ async def render_columns(
     return htm.form(form_classes, action=action, method="post", 
enctype="multipart/form-data")[form_children]
 
 
+def session(info: pydantic.ValidationInfo) -> web.Committer | None:
+    ctx: dict[str, Any] = info.context or {}
+    return ctx.get("session")
+
+
 def to_bool(v: Any) -> bool:
     if isinstance(v, bool):
         return v
diff --git a/atr/htm.py b/atr/htm.py
index 9d2e1b9..720febe 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -95,9 +95,10 @@ class BlockElementCallable:
 class Block:
     __match_args__ = ("elements",)
 
-    def __init__(self, element: Element | None = None, *elements: Element):
+    def __init__(self, element: Element | None = None, *elements: Element, 
classes: str | None = None):
         self.element = element
         self.elements: list[Element | str] = list(elements)
+        self.classes = classes
 
     def __str__(self) -> str:
         return f"{self.element}{self.elements}"
@@ -145,13 +146,19 @@ class Block:
             elements = self.elements
 
         if self.element is None:
+            if self.classes is not None:
+                return div(self.classes, data_src=src)[*elements]
             return div(data_src=src)[*elements]
 
         new_element = self.element.__class__(
             self.element._name,
             self.element._attrs,
             self.element._children,
-        )(data_src=src)
+        )
+        if self.classes is not None:
+            new_element = new_element(self.classes, data_src=src)
+        else:
+            new_element = new_element(data_src=src)
         # if self.element._name == "html":
         #     return "<!doctype html>" + new_element[*elements]
         return new_element[*elements]
diff --git a/atr/templates/macros/flash.html b/atr/templates/macros/flash.html
index 18be04b..f080cd2 100644
--- a/atr/templates/macros/flash.html
+++ b/atr/templates/macros/flash.html
@@ -2,10 +2,12 @@
   {% with messages = get_flashed_messages(with_categories=true) %}
     {% if messages %}
       {% for category, message in messages %}
-        <div class="flash-message flash-{{ category }}">
-          {% if category == 'error' %}<strong>Error:</strong>{% endif %}
-          {{ message }}
-        </div>
+        {% if category != 'form-error-data' %}
+          <div class="flash-message flash-{{ category }}">
+            {% if category == 'error' %}<strong>Error:</strong>{% endif %}
+            {{ message }}
+          </div>
+        {% endif %}
       {% endfor %}
     {% endif %}
   {% endwith %}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to