#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
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py index cca450a..113f17e 100644 --- a/atr/admin/__init__.py +++ b/atr/admin/__init__.py
@@ -146,7 +146,7 @@ Allows an admin to browse as another user. """ - rendered_form = form.render( + rendered_form = await form.render( model_cls=BrowseAsUserForm, submit_label="Browse as this user", ) @@ -320,7 +320,7 @@ committee_choices = [(c.key, c.display_name) for c in committees_with_keys] - rendered_form = form.render( + rendered_form = await form.render( model_cls=DeleteCommitteeKeysForm, submit_label="Delete all keys for selected committee", defaults={"committee_key": committee_choices}, @@ -416,7 +416,7 @@ else: releases_widget_with_help = htpy.p(".text-muted")["No releases found in the database."] - rendered_form = form.render( + rendered_form = await form.render( model_cls=DeleteReleaseForm, submit_label="Delete selected releases permanently", submit_classes="btn-danger", @@ -452,7 +452,7 @@ if not config.get().ALLOW_TESTS: return quart.abort(404) - rendered_form = form.render( + rendered_form = await form.render( model_cls=form.Empty, submit_label="Delete all OpenPGP keys for test user", empty=True, @@ -511,7 +511,7 @@ Check public signing key details. """ - rendered_form = form.render( + rendered_form = await form.render( model_cls=form.Empty, submit_label="Check public signing key details", empty=True, @@ -540,7 +540,7 @@ Display the form to regenerate KEYS files. """ - rendered_form = form.render( + rendered_form = await form.render( model_cls=form.Empty, submit_label="Regenerate all KEYS files", empty=True, @@ -582,7 +582,7 @@ Update keys from remote data. """ - rendered_form = form.render( + rendered_form = await form.render( model_cls=form.Empty, submit_label="Update keys", empty=True, @@ -623,7 +623,7 @@ """ URL: GET /ldap """ - rendered_form = form.render( + rendered_form = await form.render( model_cls=LdapLookupForm, submit_label="Lookup", ) @@ -662,7 +662,7 @@ end = time.perf_counter_ns() log.info(f"LDAP search took {(end - start) / 1000000} ms") - rendered_form = form.render( + rendered_form = await form.render( model_cls=LdapLookupForm, submit_label="Lookup", defaults={"uid": uid_query, "email": email_query}, @@ -806,7 +806,7 @@ Update projects from remote data. """ - rendered_form = form.render( + rendered_form = await form.render( model_cls=form.Empty, submit_label="Update projects", empty=True, @@ -861,7 +861,7 @@ rows = await data.execute_query(stmt) token_counts = [(row[0], row[1]) for row in rows] - rendered_form = form.render( + rendered_form = await form.render( model_cls=RevokeUserTokensForm, submit_label="Revoke all tokens", ) @@ -900,7 +900,7 @@ """ URL: GET /rotate-jwt-key """ - rendered_form = form.render( + rendered_form = await form.render( model_cls=RotateJwtKeyForm, submit_label="Rotate JWT key", ) @@ -1072,7 +1072,7 @@ Display the page with a button to toggle between admin and user views. """ - rendered_form = form.render( + rendered_form = await form.render( model_cls=form.Empty, submit_label="Toggle view", empty=True, @@ -1133,7 +1133,7 @@ _require_debug_and_allow_tests() except base.ASFQuartException: return quart.abort(404) - rendered_form = form.render( + rendered_form = await form.render( model_cls=ValidateJwtForm, submit_label="Validate JWT", textarea_rows=8, @@ -1175,7 +1175,7 @@ "message": str(exc), } - rendered_form = form.render( + rendered_form = await form.render( model_cls=ValidateJwtForm, submit_label="Validate JWT", textarea_rows=8,
diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py index e0a75fc..df4c8eb 100644 --- a/atr/blueprints/admin.py +++ b/atr/blueprints/admin.py
@@ -99,7 +99,7 @@ try: kwargs[form_param_name] = await enhanced_session.form_validate(form_cls, context) except pydantic.ValidationError as e: - return await common.flash_form_error(form_cls, e) + return await common.flash_form_error(form_cls, enhanced_session, e) if form_safe_params: await common.validate_safe_fields(kwargs[form_param_name], form_safe_params, kwargs)
diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py index 704c906..7465750 100644 --- a/atr/blueprints/common.py +++ b/atr/blueprints/common.py
@@ -233,10 +233,8 @@ setattr(instance, name, temp[name]) -async def flash_form_error(form_cls: type, error: pydantic.ValidationError) -> Any: +async def flash_form_error(form_cls: type, session: web.Public, error: pydantic.ValidationError) -> Any: """Flash form validation errors and return a redirect to the current page.""" - import json - errors = error.errors() if len(errors) == 0: raise RuntimeError("Validation failed, but no errors were reported") @@ -244,7 +242,8 @@ flash_data = form.flash_error_data(form_cls, errors, form_data_raw) 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") + if isinstance(session, web.Committer): + await form.validation_cache_add(session.asf_uid, quart.request.path, flash_data) return quart.redirect(quart.request.path)
diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py index 0a8a7e5..58acc39 100644 --- a/atr/blueprints/post.py +++ b/atr/blueprints/post.py
@@ -26,7 +26,7 @@ import quart import atr.blueprints.common as common -import atr.form +import atr.form as form import atr.log as log import atr.models.safe as safe import atr.web as web @@ -81,10 +81,10 @@ case web.Committer() as committer: kwargs[form_param_name] = await committer.form_validate(form_cls, context) case None: - form_data = await atr.form.quart_request() - kwargs[form_param_name] = atr.form.validate(form_cls, form_data, context=context) + form_data = await form.quart_request() + kwargs[form_param_name] = form.validate(form_cls, form_data, context=context) except pydantic.ValidationError as e: - return await common.flash_form_error(form_cls, e) + return await common.flash_form_error(form_cls, enhanced_session, e) 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 f84a7e4..4db0b5c 100644 --- a/atr/form.py +++ b/atr/form.py
@@ -18,12 +18,13 @@ 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 +import asfquart import htpy import markupsafe import pydantic @@ -75,6 +76,9 @@ URL = "url" +_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) @@ -151,7 +155,13 @@ 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: @@ -160,7 +170,7 @@ 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]: @@ -236,19 +246,7 @@ 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 +async def render( # noqa: C901 model_cls: type[Form], action: str | None = None, form_classes: str = ".atr-canary.py-4", @@ -266,6 +264,7 @@ 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 @@ -279,7 +278,9 @@ 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 = await _validation_cache_get(uid, quart.request.path) field_rows: list[htm.Element] = [] hidden_fields: list[htm.Element | htm.VoidElement | markupsafe.Markup] = [] hidden_fields.append(csrf_input()) @@ -294,7 +295,7 @@ hidden_field, row = _render_row( field_info, field_name, - flash_error_data, + error_data, defaults, errors, textarea_rows, @@ -346,8 +347,8 @@ return htm.form(form_classes, **form_attrs)[form_children] -def render_block(block: htm.Block, *args, **kwargs) -> None: - rendered = render(*args, **kwargs) +async def render_block(block: htm.Block, *args: Any, **kwargs: Any) -> None: + rendered = await render(*args, **kwargs) block.append(rendered) @@ -575,6 +576,19 @@ return pydantic.TypeAdapter(model_cls).validate_python(form, context=context) +async def validation_cache_add(uid: str, path: str, data: dict[str, Any]) -> None: + if asfquart.APP is not None: + cache = asfquart.APP.extensions["form_validation_error_cache"] + lock = asfquart.APP.extensions["form_validation_error_cache_lock"] + + now = time.monotonic() + async with lock: + stale_keys = [k for k, (_, stored_at) in cache.items() if (now - stored_at) > _FORM_ERROR_TTL_SECONDS] + for k in stale_keys: + del cache[k] + cache[(uid, path)] = (data, now) + + def value(type_alias: Any) -> Any: # This is for unwrapping from Literal for discriminators if hasattr(type_alias, "__value__"): @@ -1045,3 +1059,21 @@ elements.append(error_div) return htm.div[elements] if (len(elements) > 1) else elements[0] + + +async def _validation_cache_get(uid: str, path: str) -> dict[str, Any]: + if asfquart.APP is None: + return {} + cache = asfquart.APP.extensions["form_validation_error_cache"] + lock = asfquart.APP.extensions["form_validation_error_cache_lock"] + key = (uid, path) + + async with lock: + entry = 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 32e765c..77f87a8 100644 --- a/atr/get/announce.py +++ b/atr/get/announce.py
@@ -99,6 +99,7 @@ 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( @@ -164,6 +165,7 @@ default_body: str, default_download_path_suffix: str, download_path_description: str, + uid: str, ) -> htm.Element: """Render the announce page.""" page = htm.Block() @@ -237,7 +239,7 @@ "body": default_body, } - form.render_block( + await form.render_block( page, model_cls=shared.announce.AnnounceForm, action=util.as_url(post.announce.selected, project_key=release.project.key, version_key=release.version), @@ -253,6 +255,7 @@ 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/checks.py b/atr/get/checks.py index 261fb9a..94d4bf6 100644 --- a/atr/get/checks.py +++ b/atr/get/checks.py
@@ -178,7 +178,7 @@ if release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT: for path in all_paths: delete_file_forms[str(path)] = str( - form.render( + await form.render( model_cls=draft.DeleteFileForm, action=util.as_url( post.draft.delete_file, project_key=str(project_key), version_key=str(version_key)
diff --git a/atr/get/committees.py b/atr/get/committees.py index 4aaa0fc..410dc62 100644 --- a/atr/get/committees.py +++ b/atr/get/committees.py
@@ -71,7 +71,7 @@ algorithms=shared.algorithms, now=datetime.datetime.now(datetime.UTC), email_from_key=util.email_from_uid, - update_committee_keys_form=form.render( + update_committee_keys_form=await form.render( model_cls=shared.keys.UpdateCommitteeKeysForm, action=util.as_url(post.keys.keys), submit_label="Regenerate KEYS file",
diff --git a/atr/get/distribution.py b/atr/get/distribution.py index 32b4d9b..cf0ccc4 100644 --- a/atr/get/distribution.py +++ b/atr/get/distribution.py
@@ -119,7 +119,7 @@ ] block.table(".table.table-striped.table-bordered")[tbody] - delete_form = form.render( + delete_form = await form.render( model_cls=shared.distribution.DeleteForm, action=util.as_url(post.distribution.delete, project_key=str(project_key), version_key=str(version_key)), form_classes=".d-inline-block.m-0", @@ -214,7 +214,7 @@ ) # Render the distribution form - form_html = form.render( + form_html = await form.render( model_cls=shared.distribution.DistributionAutomateForm, submit_label="Distribute", action=action, @@ -290,7 +290,7 @@ ) # Render the distribution form - form_html = form.render( + form_html = await form.render( model_cls=shared.distribution.DistributionRecordForm, submit_label="Record distribution", action=action,
diff --git a/atr/get/draft.py b/atr/get/draft.py index 538915d..6e6a013 100644 --- a/atr/get/draft.py +++ b/atr/get/draft.py
@@ -71,14 +71,14 @@ version_key=str(version_key), file_path=str(file_path), ) - sha512_form = form.render( + sha512_form = await form.render( model_cls=shared.draft.HashGen, action=hashgen_action, submit_label="Generate SHA512", submit_classes="btn-outline-secondary", empty=True, ) - sbom_form = form.render( + sbom_form = await form.render( model_cls=form.Empty, action=util.as_url( post.draft.sbomgen,
diff --git a/atr/get/finish.py b/atr/get/finish.py index 70b2c07..874621d 100644 --- a/atr/get/finish.py +++ b/atr/get/finish.py
@@ -111,6 +111,7 @@ rc_analysis=rc_analysis, distribution_tasks=tasks, announce_disable_message=announce_msg, + uid=session.asf_uid, ) @@ -196,19 +197,20 @@ 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: +async def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]], uid: str) -> htm.Element: """Render the delete directory form.""" section = htm.Block() section.h2["Delete an empty directory"] - form.render_block( + await form.render_block( section, shared.finish.DeleteEmptyDirectoryForm, defaults={"directory_to_delete": deletable_dirs}, submit_label="Delete empty directory", submit_classes="btn-danger", form_classes=".mb-4", + uid=uid, ) return section.collect() @@ -370,6 +372,7 @@ 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,10 +413,10 @@ # Delete directory form if deletable_dirs: - page.append(_render_delete_directory_form(deletable_dirs)) + page.append(await _render_delete_directory_form(deletable_dirs, uid=uid)) # Remove RC tags section - page.append(_render_rc_tags_section(rc_analysis)) + page.append(await _render_rc_tags_section(rc_analysis)) # Custom styles page_styles = """ @@ -479,7 +482,7 @@ ] -def _render_rc_tags_section(rc_analysis: RCTagAnalysisResult) -> htm.Element: +async def _render_rc_tags_section(rc_analysis: RCTagAnalysisResult) -> htm.Element: """Render the remove RC tags section.""" section = htm.Block() @@ -493,7 +496,7 @@ _render_rc_preview_table(rc_analysis.affected_paths_preview) if rc_analysis.affected_paths_preview else "", ] - form.render_block( + await form.render_block( section, shared.finish.RemoveRCTagsForm, submit_label="Remove RC tags",
diff --git a/atr/get/ignores.py b/atr/get/ignores.py index 290b9f7..995080b 100644 --- a/atr/get/ignores.py +++ b/atr/get/ignores.py
@@ -47,28 +47,29 @@ content = htm.div[ htm.h1["Ignored checks"], htm.p[f"Manage ignored checks for project {project_key!s}."], - _add_ignore(str(project_key)), - _existing_ignores(ignores), + _add_ignore(session.asf_uid, str(project_key)), + _existing_ignores(session.asf_uid, ignores), ] return await template.blank("Ignored checks", content, javascripts=["ignore-form-change"]) -def _add_ignore(project_key: str) -> htm.Element: +async def _add_ignore(uid: str, project_key: str) -> htm.Element: form_path = util.as_url(post.ignores.ignores, project_key=project_key) block = htm.Block(htm.div) block.h2["Add ignore"] block.p["Add a new ignore for a check result."] - form.render_block( + await form.render_block( block, 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: +async 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) @@ -78,12 +79,13 @@ update_form_block = htm.Block(htm.div) form_path_update = util.as_url(post.ignores.ignores, project_key=cri.project_key) status = shared.ignores.sql_to_ignore_status(cri.status) - form.render_block( + await form.render_block( update_form_block, model_cls=shared.ignores.UpdateIgnoreForm, action=form_path_update, submit_label="Update ignore", form_classes="", + uid=uid, defaults={ "id": cri.id or 0, "release_glob": cri.release_glob or "", @@ -98,7 +100,7 @@ # Delete form delete_form_block = htm.Block(htm.div) - form.render_block( + await form.render_block( delete_form_block, model_cls=shared.ignores.DeleteIgnoreForm, action=form_path_update, @@ -117,8 +119,8 @@ 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 de1977a..7e99f3a 100644 --- a/atr/get/keys.py +++ b/atr/get/keys.py
@@ -38,7 +38,7 @@ @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. @@ -54,12 +54,13 @@ htm.h1(".mb-4")["Add your OpenPGP key"], htm.p["Add your public key to use for signing release artifacts."], ] - form.render_block( + await form.render_block( page, model_cls=shared.keys.AddOpenPGPKeyForm, 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, }, @@ -143,7 +144,7 @@ committee_choices = [(c.key, c.display_name or c.key) for c in user_committees] current_committee_keys = [c.key for c in key.committees] - # form.render_block( + # await form.render_block( # pmc_div, # model_cls=shared.keys.UpdateKeyCommitteesForm, # action=util.as_url(post.keys.details, fingerprint=fingerprint), @@ -226,9 +227,9 @@ htm.a(".btn.btn-outline-primary", href=util.as_url(ssh_add))["Add your SSH key"], ] - _openpgp_keys(page, list(user_keys)) - _ssh_keys(page, list(user_ssh_keys)) - _committee_keys(page, list(user_committees_with_keys)) + await _openpgp_keys(page, list(user_keys)) + await _ssh_keys(page, list(user_ssh_keys)) + await _committee_keys(page, list(user_committees_with_keys)) return await template.blank( "Manage keys", @@ -255,11 +256,12 @@ ] ] - form.render_block( + await form.render_block( page, 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( @@ -270,15 +272,16 @@ @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: +async def _committee_keys(page: htm.Block, user_committees_with_keys: list[sql.Committee]) -> None: page.h2("#your-committee-keys")["Your committee's keys"] page.div(".mb-4")[htm.a(".btn.btn-outline-primary", href=util.as_url(upload))["Upload a KEYS file"]] @@ -314,7 +317,7 @@ " but you can also use the form below to manually regenerate it.", ] - form.render_block( + await form.render_block( page, model_cls=shared.keys.UpdateCommitteeKeysForm, action=util.as_url(post.keys.keys), @@ -357,7 +360,7 @@ return key, is_owner -def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> None: +async def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> None: page.h3["Your OpenPGP keys"] if user_keys: thead = htm.thead[ @@ -380,7 +383,7 @@ else: row.td(".text-break.px-2.align-middle")["No PMCs associated"] with row.block(htm.td, classes=".px-2") as td: - form.render_block( + await form.render_block( td, model_cls=shared.keys.DeleteOpenPGPKeyForm, action=util.as_url(post.keys.keys), @@ -423,7 +426,7 @@ return row_div.collect() -def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None: +async def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None: page.h3["Your SSH keys"] if user_ssh_keys: grid = htm.Block(htm.div, classes=".d-grid.gap-4") @@ -447,7 +450,7 @@ htm.pre(".mt-3")[key.key], ] - form.render_block( + await form.render_block( card_block, model_cls=shared.keys.DeleteSSHKeyForm, action=util.as_url(post.keys.keys),
diff --git a/atr/get/manual.py b/atr/get/manual.py index e654689..6ddbce5 100644 --- a/atr/get/manual.py +++ b/atr/get/manual.py
@@ -56,7 +56,7 @@ if not release.vote_manual: raise RuntimeError("This page is for manual votes only") - content = _render_resolve_page(release) + content = await _render_resolve_page(release, uid=session.asf_uid) return await template.blank( title="Resolve vote", @@ -135,7 +135,7 @@ ] cancel_url = util.as_url(compose.selected, project_key=release.project.key, version_key=release.version) - manual_form = form.render( + manual_form = await form.render( model_cls=form.Empty, submit_label="Start manual vote", cancel_url=cancel_url, @@ -152,7 +152,7 @@ return page.collect() -def _render_resolve_page(release: sql.Release) -> htm.Element: +async def _render_resolve_page(release: sql.Release, uid: str) -> htm.Element: page = htm.Block() back_url = util.as_url(vote.selected, project_key=release.project.key, version_key=release.version) @@ -161,11 +161,12 @@ page.h1[f"Resolve vote for {release.short_display_name}"] page.p["This is a manual vote resolution."] - form.render_block( + await form.render_block( page, 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 4ad788c..add5d3b 100644 --- a/atr/get/projects.py +++ b/atr/get/projects.py
@@ -65,12 +65,13 @@ committee_display_name = committee.name or committee.key.title() - form.render_block( + await form.render_block( page, model_cls=shared.projects.AddProjectForm, action=util.as_url(post.projects.add_project, committee_key=committee.key), submit_label="Add project", cancel_url=util.as_url(committees.view, name=committee.key), + uid=session.asf_uid, defaults={ "committee_key": committee.key, }, @@ -104,7 +105,7 @@ delete_forms: dict[str, htm.Element] = {} for project in projects: - delete_forms[str(project.key)] = form.render( + delete_forms[str(project.key)] = await form.render( model_cls=shared.projects.DeleteSelectedProject, action=util.as_url(post.projects.delete), form_classes=".d-inline-block.m-0", @@ -199,10 +200,10 @@ 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_trusted_publishing_form(project)) + page.append(await _render_compose_form(session.asf_uid, project)) + page.append(await _render_vote_form(session.asf_uid, project)) + page.append(await _render_finish_form(session.asf_uid, project)) + page.append(await _render_trusted_publishing_form(project)) else: page.append(_render_policy_readonly(project)) @@ -214,7 +215,7 @@ page.append(await _render_releases_sections(project, candidate_drafts, candidates, previews, full_releases)) if project.created_by == session.uid: - page.append(_render_delete_section(project)) + page.append(await _render_delete_section(project)) if project.committee: if (project.committee.key in session.committees) or is_privileged: @@ -335,7 +336,7 @@ return card.collect() -def _render_compose_form(project: sql.Project) -> htm.Element: +async 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"] @@ -347,11 +348,12 @@ else: atr_tag_yaml = "" with card.block(htm.div, classes=".card-body") as card_body: - form.render_block( + await form.render_block( card_body, model_cls=shared.projects.ComposePolicyForm, action=util.as_url(post.projects.view, name=str(project.key)), submit_label="Save", + uid=uid, defaults={ "project_key": str(project.key), "license_check_mode": project.policy_license_check_mode, @@ -368,11 +370,11 @@ return card.collect() -def _render_delete_section(project: sql.Project) -> htm.Element: +async def _render_delete_section(project: sql.Project) -> htm.Element: section = htm.Block(htm.div) section.h2["Actions"] - delete_form = form.render( + delete_form = await form.render( shared.projects.DeleteProjectForm, action=util.as_url(post.projects.view, name=str(project.key)), form_classes="", @@ -394,7 +396,7 @@ return card.collect() -def _render_finish_form(project: sql.Project) -> htm.Element: +async 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"] @@ -416,11 +418,12 @@ ) with card.block(htm.div, classes=".card-body") as card_body: - form.render_block( + await form.render_block( card_body, model_cls=shared.projects.FinishPolicyForm, action=util.as_url(post.projects.view, name=str(project.key)), submit_label="Save", + uid=uid, defaults={ "project_key": project.key, "announce_release_subject": project.policy_announce_release_subject or "", @@ -609,15 +612,14 @@ return sections.collect() - -def _render_trusted_publishing_form(project: sql.Project) -> htm.Element: +async def _render_trusted_publishing_form(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 - Trusted Publishing"] ] with card.block(htm.div, classes=".card-body") as card_body: - form.render_block( + await form.render_block( card_body, model_cls=shared.projects.TrustedPublishingPolicyForm, action=util.as_url(post.projects.view, name=str(project.key)), @@ -636,8 +638,7 @@ ) return card.collect() - -def _render_vote_form(project: sql.Project) -> htm.Element: +async 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"] @@ -683,11 +684,12 @@ ) with card.block(htm.div, classes=".card-body") as card_body: - form.render_block( + await form.render_block( card_body, model_cls=shared.projects.VotePolicyForm, action=util.as_url(post.projects.view, name=str(project.key)), submit_label="Save", + uid=uid, defaults=defaults_dict, form_classes=".atr-canary.py-4.px-5", border=True,
diff --git a/atr/get/revisions.py b/atr/get/revisions.py index 2cae322..4a30e41 100644 --- a/atr/get/revisions.py +++ b/atr/get/revisions.py
@@ -183,7 +183,7 @@ if revision_history: for revision, files_diff in revision_history: - _render_revision_card( + await _render_revision_card( page, revision, files_diff, latest_revision_number, phase_key, project_key, version_key ) else: @@ -213,10 +213,10 @@ return span.collect(separator=" ") -def _render_revision_actions(body: htm.Block, revision: sql.Revision, project_key: str, version_key: str) -> None: +async def _render_revision_actions(body: htm.Block, revision: sql.Revision, project_key: str, version_key: str) -> None: body.h3(".fs-6.fw-semibold.mt-3.atr-sans")["Actions"] body.div(".mt-3")[ - form.render( + await form.render( model_cls=shared.revisions.SetRevisionForm, form_classes="", submit_classes="btn-sm btn-outline-danger", @@ -227,7 +227,7 @@ ] -def _render_revision_card( +async def _render_revision_card( page: htm.Block, revision: sql.Revision, files_diff: "FilesDiff", @@ -262,7 +262,7 @@ is_draft = phase_key == "draft" revision_is_preview = revision.phase.value.lower() == "release_preview" if (revision.number != latest_revision_number) and (is_draft or revision_is_preview): - _render_revision_actions(card_body, revision, project_key, version_key) + await _render_revision_actions(card_body, revision, project_key, version_key) def _render_revision_header(revision: sql.Revision, latest_revision_number: str | None) -> htm.Element:
diff --git a/atr/get/sbom.py b/atr/get/sbom.py index d343628..d77fd83 100644 --- a/atr/get/sbom.py +++ b/atr/get/sbom.py
@@ -114,12 +114,12 @@ ] if len(augmented_bom_versions) > 0: last_augmented_bom = max(augmented_bom_versions) - _augment_section(block, release, task_result, latest_augment, last_augmented_bom) + await _augment_section(block, release, task_result, latest_augment, last_augmented_bom) _conformance_section(block, task_result) _license_section(block, task_result) - _vulnerability_scan_section( + await _vulnerability_scan_section( block, str(project_key), str(version_key), str(file_path), task_result, osv_tasks, is_release_candidate ) @@ -130,7 +130,7 @@ return await template.blank("SBOM report", content=block.collect()) -def _augment_section( +async def _augment_section( block: htm.Block, release: sql.Release, task_result: results.SBOMToolScore, @@ -154,7 +154,7 @@ if len(augments) == 0: block.p["We can attempt to augment this SBOM with additional data."] - form.render_block( + await form.render_block( block, model_cls=shared.sbom.AugmentSBOMForm, submit_label="Augment SBOM", @@ -168,7 +168,7 @@ else: block.p["This SBOM was augmented by ATR at revision ", htm.code[augments[-1]], "."] block.p["We can perform augmentation again to check for additional new data."] - form.render_block( + await form.render_block( block, model_cls=shared.sbom.AugmentSBOMForm, submit_label="Re-augment SBOM", @@ -660,10 +660,10 @@ block.append(new_block) -def _vulnerability_scan_button(block: htm.Block) -> None: +async def _vulnerability_scan_button(block: htm.Block) -> None: block.p["You can perform a new vulnerability scan."] - form.render_block( + await form.render_block( block, model_cls=shared.sbom.ScanSBOMForm, submit_label="Scan file", @@ -713,7 +713,7 @@ _vulnerability_results_from_bom(vulns, block, scans, previous_vulns) -def _vulnerability_scan_section( +async def _vulnerability_scan_section( block: htm.Block, project: str, version: str, @@ -746,12 +746,14 @@ if not is_release_candidate: if in_progress_task is not None: - _vulnerability_scan_status(block, in_progress_task, project, version, file_path) + await _vulnerability_scan_status(block, in_progress_task, project, version, file_path) else: - _vulnerability_scan_button(block) + await _vulnerability_scan_button(block) -def _vulnerability_scan_status(block: htm.Block, task: sql.Task, project: str, version: str, file_path: str) -> None: +async def _vulnerability_scan_status( + block: htm.Block, task: sql.Task, project: str, version: str, file_path: str +) -> None: status_text = task.status.value.replace("_", " ").capitalize() block.p[f"Vulnerability scan is currently {status_text.lower()}."] block.p["Task ID: ", htm.code[str(task.id)]] @@ -761,4 +763,4 @@ htm.code[task.error], ". Additional details are unavailable from ATR.", ] - _vulnerability_scan_button(block) + await _vulnerability_scan_button(block)
diff --git a/atr/get/start.py b/atr/get/start.py index 2e99cc2..faf3fd3 100644 --- a/atr/get/start.py +++ b/atr/get/start.py
@@ -46,7 +46,7 @@ ) 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 @@ 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}"] @@ -98,7 +98,7 @@ htm.strong["release candidate draft"], ". You can then add files to this draft before promoting it for voting.", ] - form.render_block( + await form.render_block( page, model_cls=shared.start.StartReleaseForm, form_classes=".atr-canary.py-4.px-5.border.rounded", @@ -106,6 +106,7 @@ submit_label="Start new release", cancel_url=util.as_url(root.index), defaults={"project_key": project.key}, + uid=uid, ) if releases: page.h2(".mt-5")["Existing releases"]
diff --git a/atr/get/test.py b/atr/get/test.py index f1ccba4..7faf8ef 100644 --- a/atr/get/test.py +++ b/atr/get/test.py
@@ -48,7 +48,7 @@ """ URL: /test/empty """ - empty_form = form.render( + empty_form = await form.render( model_cls=form.Empty, submit_label="Submit empty form", action="/test/empty", @@ -149,13 +149,13 @@ """ URL: /test/multiple """ - apple_form = form.render( + apple_form = await form.render( model_cls=shared.test.AppleForm, submit_label="Order apples", action="/test/multiple", ) - banana_form = form.render( + banana_form = await form.render( model_cls=shared.test.BananaForm, submit_label="Order bananas", action="/test/multiple", @@ -187,7 +187,7 @@ htpy.label(class_="btn btn-outline-danger", for_="vote_2")["-1"], ] - single_form = form.render( + single_form = await form.render( model_cls=shared.test.SingleForm, submit_label="Submit", action="/test/single",
diff --git a/atr/get/tokens.py b/atr/get/tokens.py index be603aa..6746bc9 100644 --- a/atr/get/tokens.py +++ b/atr/get/tokens.py
@@ -30,7 +30,7 @@ @get.typed -async def tokens(_session: web.Committer, _tokens: Literal["tokens"]) -> str: +async def tokens(session: web.Committer, _tokens: Literal["tokens"]) -> str: """ URL: /tokens """ @@ -46,16 +46,17 @@ token is shown only once when you create it. You can revoke tokens you no longer need.""" ] - add_form = form.render( + add_form = await form.render( 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"], htm.div(".card-body")[add_form], ] - _build_tokens_table(page, tokens_list) + await _build_tokens_table(page, tokens_list) page.h2["JSON Web Token (JWT)"] jwt_section = htm.Block() @@ -65,7 +66,7 @@ in the Authorization header as a Bearer token when invoking the protected endpoints.""" ] - form.render_block( + await form.render_block( jwt_section, model_cls=form.Empty, action=util.as_url(post.tokens.jwt_post), @@ -94,7 +95,7 @@ ) -def _build_tokens_table(page: htm.Block, tokens_list: list[types.PersonalAccessTokenSafe]) -> None: +async def _build_tokens_table(page: htm.Block, tokens_list: list[types.PersonalAccessTokenSafe]) -> None: if not tokens_list: page.p["No tokens found."] return @@ -104,7 +105,7 @@ if not t.id: continue - delete_form = form.render( + delete_form = await form.render( model_cls=shared.tokens.DeleteTokenForm, action=util.as_url(post.tokens.tokens), form_classes=".mb-0",
diff --git a/atr/get/upload.py b/atr/get/upload.py index 94ef20c..82128d5 100644 --- a/atr/get/upload.py +++ b/atr/get/upload.py
@@ -100,11 +100,12 @@ ) ) - form.render_block( + await form.render_block( block, 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")) @@ -123,11 +124,12 @@ " page for this draft once the task is queued.", ] - form.render_block( + await form.render_block( block, 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/user.py b/atr/get/user.py index 2f3f354..a6110dc 100644 --- a/atr/get/user.py +++ b/atr/get/user.py
@@ -66,7 +66,7 @@ block.h3["Delete cache"] block.p["Remove your cached session information:"] - delete_cache_form = form.render( + delete_cache_form = await form.render( model_cls=shared.user.DeleteCacheForm, submit_label="Delete my cache", submit_classes="btn-danger", @@ -78,7 +78,7 @@ block.h3["Cache current session"] block.p["Press the button below to cache your current session information:"] - cache_form = form.render( + cache_form = await form.render( model_cls=shared.user.CacheUserForm, submit_label="Cache me!", submit_classes="btn-primary",
diff --git a/atr/get/vote.py b/atr/get/vote.py index c4f7a4e..4113bfd 100644 --- a/atr/get/vote.py +++ b/atr/get/vote.py
@@ -554,13 +554,14 @@ version_key=release.version, ) vote_comment_template = release.project.policy_vote_comment_template - cast_vote_form = form.render( + cast_vote_form = await form.render( model_cls=shared.vote.CastVoteForm, action=vote_action_url, submit_label="Submit vote", 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 22c4ca3..ec0cd2f 100644 --- a/atr/get/voting.py +++ b/atr/get/voting.py
@@ -101,6 +101,7 @@ default_body=default_body, min_hours=min_hours, keys_warning=keys_warning, + uid=session.asf_uid, ) return await template.blank( @@ -146,6 +147,7 @@ default_body: str, min_hours: int, keys_warning: bool, + uid: str, ) -> htm.Element: page = htm.Block() @@ -212,7 +214,7 @@ ], ] - vote_form = form.render( + vote_form = await form.render( model_cls=shared.voting.StartVotingForm, submit_label="Send vote email", cancel_url=cancel_url, @@ -226,6 +228,7 @@ "subject": custom_subject_widget, "body": custom_body_widget, }, + uid=uid, skip=["email_cc", "email_bcc"], ) page.append(vote_form)
diff --git a/atr/post/keys.py b/atr/post/keys.py index e5351d4..5e5171e 100644 --- a/atr/post/keys.py +++ b/atr/post/keys.py
@@ -217,7 +217,7 @@ @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 @@ 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_key: str, *, is_podling: bool) -> str: @@ -299,7 +300,7 @@ 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 @@ 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 @@ 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 @@ committee = await data.committee(key=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 @@ 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/post/resolve.py b/atr/post/resolve.py index 4ab38ff..c772e9d 100644 --- a/atr/post/resolve.py +++ b/atr/post/resolve.py
@@ -143,7 +143,7 @@ ) defaults["vote_result"] = "passed" if details.passed else "failed" - resolve_form = atr.form.render( + resolve_form = await atr.form.render( model_cls=shared.resolve.SubmitForm, action=util.as_url(selected, project_key=release.project.key, version_key=release.version), submit_label="Resolve vote",
diff --git a/atr/server.py b/atr/server.py index dd33a11..d7b00e5 100644 --- a/atr/server.py +++ b/atr/server.py
@@ -286,6 +286,10 @@ await asyncio.to_thread(_set_file_permissions_to_read_only) + # Initialise the form validation cache + app.extensions["form_validation_error_cache"] = {} + app.extensions["form_validation_error_cache_lock"] = asyncio.Lock() + await _backfill_archive_cache() await cache.admins_startup_load()
diff --git a/atr/shared/keys.py b/atr/shared/keys.py index 2b6fa85..e2b5c29 100644 --- a/atr/shared/keys.py +++ b/atr/shared/keys.py
@@ -129,6 +129,7 @@ async def render_upload_page( + uid: str, results: storage.outcome.List | None = None, submitted_committees: list[str] | None = None, error: bool = False, @@ -159,7 +160,7 @@ page.h2["Upload a file"] page.p["Upload a KEYS file from your computer."] - form.render_block( + await form.render_block( page, model_cls=shared.keys.UploadFileForm, action=util.as_url(post.keys.upload), @@ -167,12 +168,13 @@ defaults={"selected_committee": committee_choices}, border=True, wider_widgets=True, + uid=uid, ) page.h2(".mt-5")["Fetch existing KEYS file"] page.p["Fetch the KEYS file from the ASF downloads server for the selected committee."] - form.render_block( + await form.render_block( page, model_cls=shared.keys.UploadRemoteForm, action=util.as_url(post.keys.upload), @@ -180,6 +182,7 @@ 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 e634293..660dd89 100644 --- a/atr/shared/vote.py +++ b/atr/shared/vote.py
@@ -24,7 +24,7 @@ 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 51b0614..81b57f6 100644 --- a/atr/shared/web.py +++ b/atr/shared/web.py
@@ -82,7 +82,7 @@ for q in quarantined_failed: if q.id is None: continue - clear_quarantine_forms[q.id] = form.render( + clear_quarantine_forms[q.id] = await form.render( model_cls=draft.ClearQuarantineForm, action=util.as_url( post.draft.quarantine_clear, @@ -110,7 +110,7 @@ revision_editor = None revision_timestamp = None - delete_form = form.render( + delete_form = await form.render( model_cls=form.Empty, action=util.as_url( get.compose.selected, project_key=release.safe_project_key, version_key=release.safe_version_key @@ -123,7 +123,7 @@ delete_file_forms: dict[str, htm.Element] = {} for path in all_paths: - delete_file_forms[str(path)] = form.render( + delete_file_forms[str(path)] = await form.render( model_cls=draft.DeleteFileForm, action=util.as_url( post.draft.delete_file, project_key=release.safe_project_key, version_key=release.safe_version_key @@ -141,15 +141,17 @@ ), ) - recheck_form = form.render( + uid = session.asf_uid if session is not None else None + recheck_form = await form.render( model_cls=form.Empty, action=util.as_url( post.draft.recheck, project_key=release.safe_project_key, version_key=release.safe_version_key ), submit_label="Recheck with fresh cache", submit_classes="btn btn-primary", + uid=uid, ) - cache_reset_form = form.render( + cache_reset_form = await form.render( model_cls=form.Empty, action=util.as_url( post.draft.cache_reset, project_key=release.safe_project_key, version_key=release.safe_version_key @@ -157,6 +159,7 @@ 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 f080cd2..18be04b 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 3fa2307..78a47a5 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 @@ -128,11 +127,11 @@ type="atr_error", ) ] - flash_data = form.flash_error_data(self.__form_cls, errors, self.__form_data) - summary = form.flash_error_summary(errors, flash_data) + error_data = form.flash_error_data(self.__form_cls, errors, self.__form_data) + summary = form.flash_error_summary(errors, error_data) await quart.flash(summary, category="error") - await quart.flash(json.dumps(flash_data), category="form-error-data") + await form.validation_cache_add(self.asf_uid, quart.request.path, error_data) return quart.redirect(quart.request.path) async def form_validate(self, form_cls: type[form.Form], context: dict[str, Any]) -> pydantic.BaseModel: