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]