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]
