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

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

commit 8909e7e067de03ee2f507eb7d3522dc71ba42690
Author: Alastair McFarlane <[email protected]>
AuthorDate: Mon Mar 16 15:25:58 2026 +0000

    #776 - implement 50k character limit for vote comment and support passing 
form errors through a cache in the form module instead of via flash in the 
session
---
 atr/blueprints/admin.py         |  3 +--
 atr/blueprints/post.py          |  4 +--
 atr/form.py                     | 56 ++++++++++++++++++++++++++++-------------
 atr/get/announce.py             |  3 +++
 atr/get/finish.py               |  7 ++++--
 atr/get/ignores.py              | 14 ++++++-----
 atr/get/keys.py                 |  9 ++++---
 atr/get/manual.py               |  5 ++--
 atr/get/projects.py             | 16 +++++++-----
 atr/get/start.py                |  5 ++--
 atr/get/tokens.py               |  1 +
 atr/get/upload.py               |  2 ++
 atr/get/vote.py                 |  1 +
 atr/get/voting.py               |  3 +++
 atr/post/keys.py                | 31 ++++++++++++-----------
 atr/shared/keys.py              |  3 +++
 atr/shared/vote.py              |  2 +-
 atr/shared/web.py               |  3 +++
 atr/templates/macros/flash.html | 10 +++-----
 atr/web.py                      |  3 +--
 20 files changed, 114 insertions(+), 67 deletions(-)

diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py
index a6f93ae9..a9e698ed 100644
--- a/atr/blueprints/admin.py
+++ b/atr/blueprints/admin.py
@@ -15,7 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import json
 from collections.abc import Awaitable, Callable
 from types import ModuleType
 from typing import Any
@@ -81,7 +80,7 @@ def form(
                 summary = atr.form.flash_error_summary(errors, flash_data)
 
                 await quart.flash(summary, category="error")
-                await quart.flash(json.dumps(flash_data), 
category="form-error-data")
+                atr.form.store_form_errors(session.asf_uid, 
quart.request.path, flash_data)
                 return quart.redirect(quart.request.path)
 
         wrapper.__annotations__ = func.__annotations__.copy()
diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py
index 6c585507..4532e828 100644
--- a/atr/blueprints/post.py
+++ b/atr/blueprints/post.py
@@ -15,7 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import json
 import time
 from collections.abc import Awaitable, Callable
 from types import ModuleType
@@ -94,7 +93,8 @@ def typed(func: Callable[..., Any]) -> web.RouteFunction[Any]:
                 flash_data = atr.form.flash_error_data(form_cls, errors, 
form_data_raw)
                 summary = atr.form.flash_error_summary(errors, flash_data)
                 await quart.flash(summary, category="error")
-                await quart.flash(json.dumps(flash_data), 
category="form-error-data")
+                if isinstance(enhanced_session, web.Committer):
+                    atr.form.store_form_errors(enhanced_session.asf_uid, 
quart.request.path, flash_data)
                 return quart.redirect(quart.request.path)
             if form_safe_params:
                 await common.validate_safe_fields(kwargs[form_param_name], 
form_safe_params, kwargs)
diff --git a/atr/form.py b/atr/form.py
index 444d9888..37f66187 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -18,9 +18,9 @@
 from __future__ import annotations
 
 import enum
-import json
 import pathlib
 import re
+import time
 import types
 from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, 
TypeAliasType, get_args, get_origin
 
@@ -74,6 +74,10 @@ class Widget(enum.Enum):
     URL = "url"
 
 
+_FORM_ERROR_CACHE: dict[tuple[str, str], tuple[dict[str, Any], float]] = {}
+_FORM_ERROR_TTL_SECONDS: Final[float] = 60.0
+
+
 def csrf_input() -> htm.VoidElement:
     csrf_token = utils.generate_csrf()
     return htpy.input(type="hidden", name="csrf_token", value=csrf_token)
@@ -150,7 +154,13 @@ def json_suitable(field_value: Any) -> Any:
 
 
 def label(
-    description: str, documentation: str | None = None, *, default: Any = ..., 
widget: Widget | None = None, **kwargs
+    description: str,
+    documentation: str | None = None,
+    *,
+    default: Any = ...,
+    widget: Widget | None = None,
+    max_length: int | None = None,
+    **kwargs,
 ) -> Any:
     extra: dict[str, Any] = {}
     if widget is not None:
@@ -159,7 +169,7 @@ def label(
         extra["documentation"] = documentation
     if len(kwargs) > 0:
         extra.update(kwargs)
-    return pydantic.Field(default, description=description, 
json_schema_extra=extra)
+    return pydantic.Field(default, description=description, 
json_schema_extra=extra, max_length=max_length)
 
 
 def name_and_label(form_cls: type[Form], i: int, loc: tuple[str | int, ...]) 
-> tuple[str, str]:
@@ -235,18 +245,6 @@ def _get_concrete_cls(form_cls: TypeAliasType, 
discriminator_value: str) -> type
     raise ValueError(f"Discriminator value {discriminator_value} not found in 
union type: {alias_value}")
 
 
-def _get_flash_error_data() -> dict[str, Any]:
-    flashed_error_messages = 
quart.get_flashed_messages(category_filter=["form-error-data"])
-    if flashed_error_messages:
-        try:
-            first_message = flashed_error_messages[0]
-            if isinstance(first_message, str):
-                return json.loads(first_message)
-        except (json.JSONDecodeError, IndexError):
-            pass
-    return {}
-
-
 def render(  # noqa: C901
     model_cls: type[Form],
     action: str | None = None,
@@ -265,6 +263,7 @@ def render(  # noqa: C901
     skip: list[str] | None = None,
     confirm: str | None = None,
     submit_disabled: bool = False,
+    uid: str | None = None,
 ) -> htm.Element:
     if action is None:
         action = quart.request.path
@@ -278,7 +277,9 @@ def render(  # noqa: C901
     elif border and (".px-" not in form_classes):
         form_classes += ".px-5"
 
-    flash_error_data: dict[str, Any] = _get_flash_error_data() if 
use_error_data else {}
+    error_data = {}
+    if use_error_data and uid:
+        error_data = _retrieve_form_errors(uid, quart.request.path)
     field_rows: list[htm.Element] = []
     hidden_fields: list[htm.Element | htm.VoidElement | markupsafe.Markup] = []
     hidden_fields.append(csrf_input())
@@ -293,7 +294,7 @@ def render(  # noqa: C901
         hidden_field, row = _render_row(
             field_info,
             field_name,
-            flash_error_data,
+            error_data,
             defaults,
             errors,
             textarea_rows,
@@ -345,7 +346,7 @@ def render(  # noqa: C901
     return htm.form(form_classes, **form_attrs)[form_children]
 
 
-def render_block(block: htm.Block, *args, **kwargs) -> None:
+def render_block(block: htm.Block, *args: Any, **kwargs: Any) -> None:
     rendered = render(*args, **kwargs)
     block.append(rendered)
 
@@ -355,6 +356,14 @@ def session(info: pydantic.ValidationInfo) -> 
web.Committer | None:
     return ctx.get("session")
 
 
+def store_form_errors(uid: str, path: str, data: dict[str, Any]) -> None:
+    now = time.monotonic()
+    stale_keys = [k for k, (_, stored_at) in _FORM_ERROR_CACHE.items() if (now 
- stored_at) > _FORM_ERROR_TTL_SECONDS]
+    for k in stale_keys:
+        del _FORM_ERROR_CACHE[k]
+    _FORM_ERROR_CACHE[(uid, path)] = (data, now)
+
+
 def to_bool(v: Any) -> bool:
     if isinstance(v, bool):
         return v
@@ -1085,3 +1094,14 @@ def _render_widget(  # noqa: C901
         elements.append(error_div)
 
     return htm.div[elements] if (len(elements) > 1) else elements[0]
+
+
+def _retrieve_form_errors(uid: str, path: str) -> dict[str, Any]:
+    key = (uid, path)
+    entry = _FORM_ERROR_CACHE.pop(key, None)
+    if entry is None:
+        return {}
+    data, stored_at = entry
+    if (time.monotonic() - stored_at) > _FORM_ERROR_TTL_SECONDS:
+        return {}
+    return data
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 5054f68d..8334f01b 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -100,6 +100,7 @@ async def selected(
         default_body=default_body,
         default_download_path_suffix=default_download_path_suffix,
         download_path_description=f"The URL will be 
{description_download_prefix} plus this suffix",
+        uid=session.asf_uid,
     )
 
     return await template.blank(
@@ -204,6 +205,7 @@ async def _render_page(
     default_body: str,
     default_download_path_suffix: str,
     download_path_description: str,
+    uid: str,
 ) -> htm.Element:
     """Render the announce page."""
     page = htm.Block()
@@ -276,6 +278,7 @@ async def _render_page(
             form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
             border=True,
             wider_widgets=True,
+            uid=uid,
         )
     else:
         page.p[htm.strong[announce_msg]]
diff --git a/atr/get/finish.py b/atr/get/finish.py
index 3690c739..a10574b6 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -111,6 +111,7 @@ async def selected(
         rc_analysis=rc_analysis,
         distribution_tasks=tasks,
         announce_disable_message=announce_msg,
+        uid=session.asf_uid,
     )
 
 
@@ -196,7 +197,7 @@ async def _get_page_data(
     return release, source_files_rel, target_dirs, deletable_dirs, 
rc_analysis_result, tasks
 
 
-def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]]) -> 
htm.Element:
+def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]], uid: 
str) -> htm.Element:
     """Render the delete directory form."""
     section = htm.Block()
 
@@ -209,6 +210,7 @@ def _render_delete_directory_form(deletable_dirs: 
list[tuple[str, str]]) -> htm.
         submit_label="Delete empty directory",
         submit_classes="btn-danger",
         form_classes=".mb-4",
+        uid=uid,
     )
 
     return section.collect()
@@ -370,6 +372,7 @@ async def _render_page(
     rc_analysis: RCTagAnalysisResult,
     distribution_tasks: Sequence[sql.Task],
     announce_disable_message: str,
+    uid: str,
 ) -> str:
     """Render the finish page using htm.py."""
     page = htm.Block()
@@ -410,7 +413,7 @@ async def _render_page(
 
     # Delete directory form
     if deletable_dirs:
-        page.append(_render_delete_directory_form(deletable_dirs))
+        page.append(_render_delete_directory_form(deletable_dirs, uid=uid))
 
     # Remove RC tags section
     page.append(_render_rc_tags_section(rc_analysis))
diff --git a/atr/get/ignores.py b/atr/get/ignores.py
index fd07bd53..ca7d7fb2 100644
--- a/atr/get/ignores.py
+++ b/atr/get/ignores.py
@@ -47,14 +47,14 @@ async def ignores(
     content = htm.div[
         htm.h1["Ignored checks"],
         htm.p[f"Manage ignored checks for project {project_name!s}."],
-        _add_ignore(str(project_name)),
-        _existing_ignores(ignores),
+        _add_ignore(session.asf_uid, str(project_name)),
+        _existing_ignores(session.asf_uid, ignores),
     ]
 
     return await template.blank("Ignored checks", content, 
javascripts=["ignore-form-change"])
 
 
-def _add_ignore(project_name: str) -> htm.Element:
+def _add_ignore(uid: str, project_name: str) -> htm.Element:
     form_path = util.as_url(post.ignores.ignores, project_name=project_name)
     block = htm.Block(htm.div)
     block.h2["Add ignore"]
@@ -64,11 +64,12 @@ def _add_ignore(project_name: str) -> htm.Element:
         model_cls=shared.ignores.AddIgnoreForm,
         action=form_path,
         submit_label="Add ignore",
+        uid=uid,
     )
     return block.collect()
 
 
-def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> htm.Element:
+def _check_result_ignore_card(uid: str, cri: sql.CheckResultIgnore) -> 
htm.Element:
     h3_id = cri.id or ""
     h3_asf_uid = cri.asf_uid
     h3_created = util.format_datetime(cri.created)
@@ -84,6 +85,7 @@ def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> 
htm.Element:
         action=form_path_update,
         submit_label="Update ignore",
         form_classes="",
+        uid=uid,
         defaults={
             "id": cri.id or 0,
             "release_glob": cri.release_glob or "",
@@ -117,8 +119,8 @@ def _check_result_ignore_card(cri: sql.CheckResultIgnore) 
-> htm.Element:
     return card
 
 
-def _existing_ignores(ignores: list[sql.CheckResultIgnore]) -> htm.Element:
+def _existing_ignores(uid: str, ignores: list[sql.CheckResultIgnore]) -> 
htm.Element:
     return htm.div[
         htm.h2["Existing ignores"],
-        [_check_result_ignore_card(cri) for cri in ignores] or htm.p["No 
ignores found."],
+        [_check_result_ignore_card(uid, cri) for cri in ignores] or htm.p["No 
ignores found."],
     ]
diff --git a/atr/get/keys.py b/atr/get/keys.py
index d0fba17f..3bd0ebfa 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -37,7 +37,7 @@ import atr.web as web
 
 
 @get.typed
-async def add(_session: web.Committer, _keys_add: Literal["keys/add"]) -> str:
+async def add(session: web.Committer, _keys_add: Literal["keys/add"]) -> str:
     """
     URL: /keys/add
     Add a new public signing key to the user's account.
@@ -59,6 +59,7 @@ async def add(_session: web.Committer, _keys_add: 
Literal["keys/add"]) -> str:
         action=util.as_url(post.keys.add),
         submit_label="Add OpenPGP key",
         cancel_url=util.as_url(keys),
+        uid=session.asf_uid,
         defaults={
             "selected_committees": committee_choices,
         },
@@ -259,6 +260,7 @@ async def ssh_add(session: web.Committer, _keys_ssh_add: 
Literal["keys/ssh/add"]
         model_cls=shared.keys.AddSSHKeyForm,
         action=util.as_url(post.keys.ssh_add),
         submit_label="Add SSH key",
+        uid=session.asf_uid,
     )
 
     return await template.blank(
@@ -269,12 +271,13 @@ async def ssh_add(session: web.Committer, _keys_ssh_add: 
Literal["keys/ssh/add"]
 
 
 @get.typed
-async def upload(_session: web.Committer, _keys_upload: 
Literal["keys/upload"]) -> str:
+async def upload(session: web.Committer, _keys_upload: Literal["keys/upload"]) 
-> str:
     """
     URL: /keys/upload
     Upload a KEYS file containing multiple OpenPGP keys.
     """
-    return await shared.keys.render_upload_page()
+    uid = session.asf_uid
+    return await shared.keys.render_upload_page(uid=uid)
 
 
 def _committee_keys(page: htm.Block, user_committees_with_keys: 
list[sql.Committee]) -> None:
diff --git a/atr/get/manual.py b/atr/get/manual.py
index e9ba1162..91a447f8 100644
--- a/atr/get/manual.py
+++ b/atr/get/manual.py
@@ -56,7 +56,7 @@ async def resolve_selected(
     if not release.vote_manual:
         raise RuntimeError("This page is for manual votes only")
 
-    content = _render_resolve_page(release)
+    content = _render_resolve_page(release, uid=session.asf_uid)
 
     return await template.blank(
         title="Resolve vote",
@@ -152,7 +152,7 @@ async def _render_page(release, revision: str) -> 
htm.Element:
     return page.collect()
 
 
-def _render_resolve_page(release: sql.Release) -> htm.Element:
+def _render_resolve_page(release: sql.Release, uid: str) -> htm.Element:
     page = htm.Block()
 
     back_url = util.as_url(vote.selected, project_name=release.project.name, 
version_name=release.version)
@@ -166,6 +166,7 @@ def _render_resolve_page(release: sql.Release) -> 
htm.Element:
         model_cls=shared.manual.ResolveVoteForm,
         form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
         submit_label="Resolve vote",
+        uid=uid,
     )
 
     return page.collect()
diff --git a/atr/get/projects.py b/atr/get/projects.py
index d57622cf..e6405b9f 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -72,6 +72,7 @@ async def add_project(
         action=util.as_url(post.projects.add_project, 
committee_name=str(committee_name)),
         submit_label="Add project",
         cancel_url=util.as_url(committees.view, name=str(committee_name)),
+        uid=session.asf_uid,
         defaults={
             "committee_name": str(committee_name),
         },
@@ -200,9 +201,9 @@ async def view(
 
     if project.status == sql.ProjectStatus.ACTIVE:
         if can_edit:
-            page.append(_render_compose_form(project))
-            page.append(_render_vote_form(project))
-            page.append(_render_finish_form(project))
+            page.append(_render_compose_form(session.asf_uid, project))
+            page.append(_render_vote_form(session.asf_uid, project))
+            page.append(_render_finish_form(session.asf_uid, project))
         else:
             page.append(_render_policy_readonly(project))
 
@@ -335,7 +336,7 @@ def _render_categories_section(project: sql.Project) -> 
htm.Element:
     return card.collect()
 
 
-def _render_compose_form(project: sql.Project) -> htm.Element:
+def _render_compose_form(uid: str, project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     
card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Compose options"]
@@ -352,6 +353,7 @@ def _render_compose_form(project: sql.Project) -> 
htm.Element:
             model_cls=shared.projects.ComposePolicyForm,
             action=util.as_url(post.projects.view, name=str(project.name)),
             submit_label="Save",
+            uid=uid,
             defaults={
                 "project_name": str(project.name),
                 "source_artifact_paths": 
"\n".join(project.policy_source_artifact_paths),
@@ -399,7 +401,7 @@ def _render_description_card(project: sql.Project) -> 
htm.Element:
     return card.collect()
 
 
-def _render_finish_form(project: sql.Project) -> htm.Element:
+def _render_finish_form(uid: str, project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     
card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Finish options"]
@@ -426,6 +428,7 @@ def _render_finish_form(project: sql.Project) -> 
htm.Element:
             model_cls=shared.projects.FinishPolicyForm,
             action=util.as_url(post.projects.view, name=str(project.name)),
             submit_label="Save",
+            uid=uid,
             defaults={
                 "project_name": project.name,
                 "github_finish_workflow_path": 
"\n".join(project.policy_github_finish_workflow_path),
@@ -616,7 +619,7 @@ async def _render_releases_sections(
     return sections.collect()
 
 
-def _render_vote_form(project: sql.Project) -> htm.Element:
+def _render_vote_form(uid: str, project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     
card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Vote options"]
@@ -668,6 +671,7 @@ def _render_vote_form(project: sql.Project) -> htm.Element:
             model_cls=shared.projects.VotePolicyForm,
             action=util.as_url(post.projects.view, name=str(project.name)),
             submit_label="Save",
+            uid=uid,
             defaults=defaults_dict,
             form_classes=".atr-canary.py-4.px-5",
             border=True,
diff --git a/atr/get/start.py b/atr/get/start.py
index 7e33a0fb..cce5a1b4 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -46,7 +46,7 @@ async def selected(session: web.Committer, _start: 
Literal["start"], project_nam
         )
 
     releases = await interaction.all_releases(project)
-    content = await _render_page(project, releases)
+    content = await _render_page(project, releases, uid=session.asf_uid)
     return await template.blank(
         title=f"Start a new release for {project.display_name}",
         content=content,
@@ -89,7 +89,7 @@ def _get_phase_symbol(phase: sql.ReleasePhase) -> str:
             return "Ⓡ"
 
 
-async def _render_page(project: sql.Project, releases: list[sql.Release]) -> 
htm.Element:
+async def _render_page(project: sql.Project, releases: list[sql.Release], uid: 
str) -> htm.Element:
     page = htm.Block()
 
     page.h1[f"Start a new release for {project.display_name}"]
@@ -106,6 +106,7 @@ async def _render_page(project: sql.Project, releases: 
list[sql.Release]) -> htm
         submit_label="Start new release",
         cancel_url=util.as_url(root.index),
         defaults={"project_name": project.name},
+        uid=uid,
     )
     if releases:
         page.h2(".mt-5")["Existing releases"]
diff --git a/atr/get/tokens.py b/atr/get/tokens.py
index be603aa4..4ecccb8f 100644
--- a/atr/get/tokens.py
+++ b/atr/get/tokens.py
@@ -50,6 +50,7 @@ async def tokens(_session: web.Committer, _tokens: 
Literal["tokens"]) -> str:
         model_cls=shared.tokens.AddTokenForm,
         form_classes=".mb-0",
         submit_label="Generate token",
+        uid=_session.asf_uid,
     )
     page.div(".card.mb-4")[
         htm.div(".card-header")["Generate new token"],
diff --git a/atr/get/upload.py b/atr/get/upload.py
index 3ff14a15..f6338a4c 100644
--- a/atr/get/upload.py
+++ b/atr/get/upload.py
@@ -105,6 +105,7 @@ async def selected(
         model_cls=shared.upload.AddFilesForm,
         submit_label="Add files",
         form_classes=".atr-canary.py-4.px-5",
+        uid=session.asf_uid,
     )
 
     block.append(htpy.div("#upload-progress-container.d-none"))
@@ -128,6 +129,7 @@ async def selected(
         model_cls=shared.upload.SvnImportForm,
         submit_label="Queue SVN import task",
         form_classes=".atr-canary.py-4.px-5",
+        uid=session.asf_uid,
     )
 
     block.h2(id="rsync-upload")["Rsync upload"]
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 7adc5aaf..f4a424ca 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -561,6 +561,7 @@ async def _render_vote_authenticated(
         form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
         custom={"decision": vote_widget},
         defaults={"comment": vote_comment_template},
+        uid=session.asf_uid,
     )
     page.append(cast_vote_form)
 
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 90db36ff..d2e26c48 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -101,6 +101,7 @@ async def selected_revision(
             default_body=default_body,
             min_hours=min_hours,
             keys_warning=keys_warning,
+            uid=session.asf_uid,
         )
 
         return await template.blank(
@@ -146,6 +147,7 @@ async def _render_page(
     default_body: str,
     min_hours: int,
     keys_warning: bool,
+    uid: str,
 ) -> htm.Element:
     page = htm.Block()
 
@@ -207,6 +209,7 @@ async def _render_page(
             "subject": custom_subject_widget,
             "body": custom_body_widget,
         },
+        uid=uid,
     )
     page.append(vote_form)
 
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 92b678a3..23a2437b 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -217,7 +217,7 @@ async def ssh_add(
 
 @post.typed
 async def upload(
-    _session: web.Committer,
+    session: web.Committer,
     _keys_upload: Literal["keys/upload"],
     upload_form: shared.keys.UploadKeysForm,
 ) -> str:
@@ -225,11 +225,12 @@ async def upload(
     URL: /keys/upload
     Upload or fetch a KEYS file containing multiple OpenPGP keys.
     """
+    uid = session.asf_uid
     match upload_form:
         case shared.keys.UploadFileForm() as upload_file_form:
-            return await _upload_file_keys(upload_file_form)
+            return await _upload_file_keys(upload_file_form, uid=uid)
         case shared.keys.UploadRemoteForm() as upload_remote_form:
-            return await _upload_remote_keys(upload_remote_form)
+            return await _upload_remote_keys(upload_remote_form, uid=uid)
 
 
 def _construct_keys_url(committee_name: str, *, is_podling: bool) -> str:
@@ -299,7 +300,7 @@ async def _fetch_keys_from_url(keys_url: str) -> str:
         raise base.ASFQuartException(f"Network error while fetching keys: 
{e}", errorcode=503)
 
 
-async def _process_keys(keys_text: str, selected_committee: str) -> str:
+async def _process_keys(keys_text: str, selected_committee: str, uid: str) -> 
str:
     """Process keys text and associate with committee."""
     async with storage.write() as write:
         wacp = write.as_committee_participant(selected_committee)
@@ -315,7 +316,7 @@ async def _process_keys(keys_text: str, selected_committee: 
str) -> str:
 
     await quart.flash(message, "success" if (success_count > 0) else "error")
 
-    return await shared.keys.render_upload_page(results=outcomes, 
submitted_committees=[selected_committee])
+    return await shared.keys.render_upload_page(results=outcomes, 
submitted_committees=[selected_committee], uid=uid)
 
 
 async def _update_committee_keys(
@@ -337,29 +338,29 @@ async def _update_committee_keys(
     return await session.redirect(get.keys.keys)
 
 
-async def _upload_file_keys(upload_file_form: shared.keys.UploadFileForm) -> 
str:
+async def _upload_file_keys(upload_file_form: shared.keys.UploadFileForm, uid: 
str) -> str:
     """Handle file upload."""
     try:
         if upload_file_form.key is None:
             await quart.flash("No KEYS file uploaded", "error")
-            return await shared.keys.render_upload_page(error=True)
+            return await shared.keys.render_upload_page(error=True, uid=uid)
 
         keys_content = await asyncio.to_thread(upload_file_form.key.read)
         keys_text = keys_content.decode("utf-8", errors="replace")
 
         if not keys_text:
             await quart.flash("No KEYS data found", "error")
-            return await shared.keys.render_upload_page(error=True)
+            return await shared.keys.render_upload_page(error=True, uid=uid)
 
         selected_committee = upload_file_form.selected_committee
-        return await _process_keys(keys_text, selected_committee)
+        return await _process_keys(keys_text, selected_committee, uid=uid)
     except Exception as e:
         log.exception("Error uploading KEYS file:")
         await quart.flash(f"Error processing KEYS file: {e!s}", "error")
-        return await shared.keys.render_upload_page(error=True)
+        return await shared.keys.render_upload_page(error=True, uid=uid)
 
 
-async def _upload_remote_keys(upload_remote_form: 
shared.keys.UploadRemoteForm) -> str:
+async def _upload_remote_keys(upload_remote_form: 
shared.keys.UploadRemoteForm, uid: str) -> str:
     """Fetch KEYS file from ASF downloads."""
     try:
         selected_committee = upload_remote_form.committee
@@ -367,7 +368,7 @@ async def _upload_remote_keys(upload_remote_form: 
shared.keys.UploadRemoteForm)
             committee = await data.committee(name=selected_committee).get()
             if not committee:
                 await quart.flash(f"Committee '{selected_committee}' not 
found", "error")
-                return await shared.keys.render_upload_page(error=True)
+                return await shared.keys.render_upload_page(error=True, 
uid=uid)
             is_podling = committee.is_podling
 
         keys_url = _construct_keys_url(selected_committee, 
is_podling=is_podling)
@@ -375,10 +376,10 @@ async def _upload_remote_keys(upload_remote_form: 
shared.keys.UploadRemoteForm)
 
         if not keys_text:
             await quart.flash("No KEYS data found at ASF downloads", "error")
-            return await shared.keys.render_upload_page(error=True)
+            return await shared.keys.render_upload_page(error=True, uid=uid)
 
-        return await _process_keys(keys_text, selected_committee)
+        return await _process_keys(keys_text, selected_committee, uid=uid)
     except Exception as e:
         log.exception("Error fetching KEYS file from ASF:")
         await quart.flash(f"Error fetching KEYS file: {e!s}", "error")
-        return await shared.keys.render_upload_page(error=True)
+        return await shared.keys.render_upload_page(error=True, uid=uid)
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index 4b66d673..675264db 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -129,6 +129,7 @@ type UploadKeysForm = Annotated[
 
 
 async def render_upload_page(
+    uid: str,
     results: storage.outcome.List | None = None,
     submitted_committees: list[str] | None = None,
     error: bool = False,
@@ -167,6 +168,7 @@ async def render_upload_page(
         defaults={"selected_committee": committee_choices},
         border=True,
         wider_widgets=True,
+        uid=uid,
     )
 
     page.h2(".mt-5")["Fetch existing KEYS file"]
@@ -180,6 +182,7 @@ async def render_upload_page(
         defaults={"committee": committee_choices},
         border=True,
         wider_widgets=True,
+        uid=uid,
     )
 
     return await template.blank(
diff --git a/atr/shared/vote.py b/atr/shared/vote.py
index e634293b..660dd898 100644
--- a/atr/shared/vote.py
+++ b/atr/shared/vote.py
@@ -24,7 +24,7 @@ import atr.storage as storage
 
 class CastVoteForm(form.Form):
     decision: Literal["+1", "0", "-1"] = form.label("Your vote", 
widget=form.Widget.CUSTOM)
-    comment: str = form.label("Comment (optional)", 
widget=form.Widget.TEXTAREA)
+    comment: str = form.label("Comment (optional)", 
widget=form.Widget.TEXTAREA, max_length=50_000)
 
 
 async def is_binding(
diff --git a/atr/shared/web.py b/atr/shared/web.py
index 93089cf3..75d8cc9c 100644
--- a/atr/shared/web.py
+++ b/atr/shared/web.py
@@ -143,6 +143,7 @@ async def check(
             ),
         )
 
+    uid = session.asf_uid if session is not None else None
     recheck_form = form.render(
         model_cls=form.Empty,
         action=util.as_url(
@@ -150,6 +151,7 @@ async def check(
         ),
         submit_label="Recheck with fresh cache",
         submit_classes="btn btn-primary",
+        uid=uid,
     )
     cache_reset_form = form.render(
         model_cls=form.Empty,
@@ -159,6 +161,7 @@ async def check(
         submit_label="Recheck with global cache",
         submit_classes="btn btn-primary",
         submit_disabled=release.check_cache_key is None,
+        uid=uid,
     )
 
     vote_task_warnings = _warnings_from_vote_result(vote_task)
diff --git a/atr/templates/macros/flash.html b/atr/templates/macros/flash.html
index f080cd2d..18be04ba 100644
--- a/atr/templates/macros/flash.html
+++ b/atr/templates/macros/flash.html
@@ -2,12 +2,10 @@
   {% with messages = get_flashed_messages(with_categories=true) %}
     {% if messages %}
       {% for category, message in messages %}
-        {% if category != 'form-error-data' %}
-          <div class="flash-message flash-{{ category }}">
-            {% if category == 'error' %}<strong>Error:</strong>{% endif %}
-            {{ message }}
-          </div>
-        {% endif %}
+        <div class="flash-message flash-{{ category }}">
+          {% if category == 'error' %}<strong>Error:</strong>{% endif %}
+          {{ message }}
+        </div>
       {% endfor %}
     {% endif %}
   {% endwith %}
diff --git a/atr/web.py b/atr/web.py
index e8cbd8e0..5b421d63 100644
--- a/atr/web.py
+++ b/atr/web.py
@@ -17,7 +17,6 @@
 
 from __future__ import annotations
 
-import json
 import urllib.parse
 from typing import TYPE_CHECKING, Any, Protocol, TypeVar
 
@@ -132,7 +131,7 @@ class Committer:
         summary = form.flash_error_summary(errors, flash_data)
 
         await quart.flash(summary, category="error")
-        await quart.flash(json.dumps(flash_data), category="form-error-data")
+        form.store_form_errors(self.asf_uid, quart.request.path, flash_data)
         return quart.redirect(quart.request.path)
 
     async def form_validate(self, form_cls: type[form.Form], context: 
dict[str, Any]) -> pydantic.BaseModel:


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

Reply via email to