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

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


The following commit(s) were added to refs/heads/main by this push:
     new 8fd42ea  Move policy editing code to the policy writer module
8fd42ea is described below

commit 8fd42eadeeb792cc0d8da3e619521beac6bbc208
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Sep 11 16:30:09 2025 +0100

    Move policy editing code to the policy writer module
---
 atr/models/__init__.py        |   4 +-
 atr/models/policy.py          |  97 ++++++++++++++++++++
 atr/routes/projects.py        | 199 +++---------------------------------------
 atr/storage/writers/policy.py |  97 ++++++++++++++++++++
 4 files changed, 209 insertions(+), 188 deletions(-)

diff --git a/atr/models/__init__.py b/atr/models/__init__.py
index a9cb122..4cb70b3 100644
--- a/atr/models/__init__.py
+++ b/atr/models/__init__.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from . import api, distribution, helpers, results, schema, sql, tabulate
+from . import api, distribution, helpers, policy, results, schema, sql, 
tabulate
 
 # If we use .__name__, pyright gives a warning
-__all__ = ["api", "distribution", "helpers", "results", "schema", "sql", 
"tabulate"]
+__all__ = ["api", "distribution", "helpers", "policy", "results", "schema", 
"sql", "tabulate"]
diff --git a/atr/models/policy.py b/atr/models/policy.py
new file mode 100644
index 0000000..8abb337
--- /dev/null
+++ b/atr/models/policy.py
@@ -0,0 +1,97 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Any
+
+import pydantic
+
+from atr.models import schema
+
+
+# TODO: Maybe it's easier to use quart_schema for all our forms
+# We can use source=DataSource.FORM
+# But do all form input types have a pydantic counterpart?
+class ReleasePolicyData(schema.Lax):
+    """Pydantic model for release policy form data."""
+
+    project_name: str
+
+    # Compose section
+    source_artifact_paths: list[str] = pydantic.Field(default_factory=list)
+    binary_artifact_paths: list[str] = pydantic.Field(default_factory=list)
+    github_repository_name: str = ""
+    github_compose_workflow_path: list[str] = 
pydantic.Field(default_factory=list)
+    strict_checking: bool = False
+
+    # Vote section
+    mailto_addresses: list[str] = pydantic.Field(default_factory=list)
+    manual_vote: bool = False
+    default_min_hours_value_at_render: int = 72
+    min_hours: int = 72
+    pause_for_rm: bool = False
+    release_checklist: str = ""
+    default_start_vote_template_hash: str = ""
+    start_vote_template: str = ""
+    github_vote_workflow_path: list[str] = pydantic.Field(default_factory=list)
+
+    # Finish section
+    default_announce_release_template_hash: str = ""
+    announce_release_template: str = ""
+    github_finish_workflow_path: list[str] = 
pydantic.Field(default_factory=list)
+    preserve_download_files: bool = False
+
+    @pydantic.field_validator(
+        "source_artifact_paths",
+        "binary_artifact_paths",
+        "github_compose_workflow_path",
+        "github_vote_workflow_path",
+        "github_finish_workflow_path",
+        mode="before",
+    )
+    @classmethod
+    def parse_artifact_paths(cls, v: Any) -> list[str]:
+        if (v is None) or (v == ""):
+            return []
+        if isinstance(v, str):
+            return [path.strip() for path in v.split("\n") if path.strip()]
+        if isinstance(v, list):
+            return v
+        return []
+
+    @pydantic.field_validator("mailto_addresses", mode="before")
+    @classmethod
+    def parse_mailto_addresses(cls, v: Any) -> list[str]:
+        if (v is None) or (v == ""):
+            return []
+        if isinstance(v, str):
+            return [v.strip()] if v.strip() else []
+        if isinstance(v, list):
+            return v
+        return []
+
+    @pydantic.field_validator(
+        "github_repository_name",
+        "release_checklist",
+        "start_vote_template",
+        "announce_release_template",
+        mode="before",
+    )
+    @classmethod
+    def unwrap_values(cls, v: Any) -> Any:
+        if v is None:
+            return ""
+        return v
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index fa80fa7..b7f407c 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -25,14 +25,13 @@ import re
 from typing import TYPE_CHECKING, Any, Final
 
 import asfquart.base as base
-import pydantic
 import quart
 
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.log as log
-import atr.models.schema as schema
+import atr.models.policy as policy
 import atr.models.sql as sql
 import atr.routes as routes
 import atr.storage as storage
@@ -233,81 +232,6 @@ class ReleasePolicyForm(forms.Typed):
         return not self.errors
 
 
-# TODO: Maybe it's easier to use quart_schema for all our forms
-# We can use source=DataSource.FORM
-# But do all form input types have a pydantic counterpart?
-class ReleasePolicyData(schema.Lax):
-    """Pydantic model for release policy form data."""
-
-    project_name: str
-
-    # Compose section
-    source_artifact_paths: list[str] = pydantic.Field(default_factory=list)
-    binary_artifact_paths: list[str] = pydantic.Field(default_factory=list)
-    github_repository_name: str = ""
-    github_compose_workflow_path: list[str] = 
pydantic.Field(default_factory=list)
-    strict_checking: bool = False
-
-    # Vote section
-    mailto_addresses: list[str] = pydantic.Field(default_factory=list)
-    manual_vote: bool = False
-    default_min_hours_value_at_render: int = 72
-    min_hours: int = 72
-    pause_for_rm: bool = False
-    release_checklist: str = ""
-    default_start_vote_template_hash: str = ""
-    start_vote_template: str = ""
-    github_vote_workflow_path: list[str] = pydantic.Field(default_factory=list)
-
-    # Finish section
-    default_announce_release_template_hash: str = ""
-    announce_release_template: str = ""
-    github_finish_workflow_path: list[str] = 
pydantic.Field(default_factory=list)
-    preserve_download_files: bool = False
-
-    @pydantic.field_validator(
-        "source_artifact_paths",
-        "binary_artifact_paths",
-        "github_compose_workflow_path",
-        "github_vote_workflow_path",
-        "github_finish_workflow_path",
-        mode="before",
-    )
-    @classmethod
-    def parse_artifact_paths(cls, v: Any) -> list[str]:
-        if (v is None) or (v == ""):
-            return []
-        if isinstance(v, str):
-            return [path.strip() for path in v.split("\n") if path.strip()]
-        if isinstance(v, list):
-            return v
-        return []
-
-    @pydantic.field_validator("mailto_addresses", mode="before")
-    @classmethod
-    def parse_mailto_addresses(cls, v: Any) -> list[str]:
-        if (v is None) or (v == ""):
-            return []
-        if isinstance(v, str):
-            return [v.strip()] if v.strip() else []
-        if isinstance(v, list):
-            return v
-        return []
-
-    @pydantic.field_validator(
-        "github_repository_name",
-        "release_checklist",
-        "start_vote_template",
-        "announce_release_template",
-        mode="before",
-    )
-    @classmethod
-    def unwrap_values(cls, v: Any) -> Any:
-        if v is None:
-            return ""
-        return v
-
-
 @routes.committer("/project/add/<committee_name>", methods=["GET", "POST"])
 async def add_project(session: routes.CommitterSession, committee_name: str) 
-> response.Response | str:
     await session.check_access_committee(committee_name)
@@ -407,9 +331,18 @@ async def view(session: routes.CommitterSession, name: 
str) -> response.Response
                 if edited_metadata is True:
                     return quart.redirect(util.as_url(view, name=project.name))
             elif "submit_policy" in form_data:
-                edited_policy, policy_form = await _policy_edit(data, project, 
form_data)
-                if edited_policy:
-                    return quart.redirect(util.as_url(view, name=project.name))
+                policy_form = await 
ReleasePolicyForm.create_form(data=form_data)
+                if await policy_form.validate_on_submit():
+                    policy_data = 
policy.ReleasePolicyData.model_validate(policy_form.data)
+                    async with storage.write(session.uid) as write:
+                        wacm = await 
write.as_project_committee_member(project.name)
+                        try:
+                            await wacm.policy.edit(data, project, policy_data)
+                        except storage.AccessError as e:
+                            return await session.redirect(view, 
name=project.name, error=f"Error editing policy: {e}")
+                        return quart.redirect(util.as_url(view, 
name=project.name))
+                else:
+                    log.info(f"policy_form.errors: {policy_form.errors}")
 
         if metadata_form is None:
             metadata_form = await 
ProjectMetadataForm.create_form(data={"project_name": project.name})
@@ -538,62 +471,6 @@ async def _metadata_edit(
     return False, metadata_form
 
 
-async def _policy_edit(
-    data: db.Session, project: sql.Project, form_data: dict[str, str]
-) -> tuple[bool, ReleasePolicyForm]:
-    policy_form = await ReleasePolicyForm.create_form(data=form_data)
-    validated = await policy_form.validate_on_submit()
-    if not validated:
-        log.info(f"policy_form.errors: {policy_form.errors}")
-        return False, policy_form
-
-    # Use ReleasePolicyData to parse and validate the processed form data
-    try:
-        policy_data = ReleasePolicyData.model_validate(policy_form.data)
-    except Exception as e:
-        # If pydantic validation fails, log it and fall back to form validation
-        log.error(f"ReleasePolicyData validation failed: {e}")
-        log.info(f"policy_form.errors: {policy_form.errors}")
-        return False, policy_form
-
-    release_policy = project.release_policy
-    if release_policy is None:
-        release_policy = sql.ReleasePolicy(project=project)
-        project.release_policy = release_policy
-        data.add(release_policy)
-
-    # Compose section
-    release_policy.source_artifact_paths = policy_data.source_artifact_paths
-    release_policy.binary_artifact_paths = policy_data.binary_artifact_paths
-    release_policy.github_repository_name = policy_data.github_repository_name
-    # TODO: Change to paths, plural
-    release_policy.github_compose_workflow_path = 
policy_data.github_compose_workflow_path
-    release_policy.strict_checking = policy_data.strict_checking
-
-    # Vote section
-    release_policy.manual_vote = policy_data.manual_vote
-    if not release_policy.manual_vote:
-        release_policy.github_vote_workflow_path = 
policy_data.github_vote_workflow_path
-        release_policy.mailto_addresses = policy_data.mailto_addresses
-        _set_default_min_hours(policy_data, project, release_policy)
-        release_policy.pause_for_rm = policy_data.pause_for_rm
-        release_policy.release_checklist = policy_data.release_checklist
-        _set_default_start_vote_template(policy_data, project, release_policy)
-    elif project.committee and project.committee.is_podling:
-        # The caller ensures that project.committee is not None
-        await quart.flash("Manual voting is not allowed for podlings.", 
"error")
-        return False, policy_form
-
-    # Finish section
-    release_policy.github_finish_workflow_path = 
policy_data.github_finish_workflow_path
-    _set_default_announce_release_template(policy_data, project, 
release_policy)
-    release_policy.preserve_download_files = 
policy_data.preserve_download_files
-
-    await data.commit()
-    await quart.flash("Release policy updated successfully.", "success")
-    return True, policy_form
-
-
 async def _policy_form_create(project: sql.Project) -> ReleasePolicyForm:
     # TODO: Use form order for all of these fields
     policy_form = await ReleasePolicyForm.create_form()
@@ -744,53 +621,3 @@ async def _project_add_validate_display_name(display_name: 
str) -> bool:
             await quart.flash(must_use_correct_case, "error")
             return False
     return True
-
-
-def _set_default_announce_release_template(
-    policy_data: ReleasePolicyData, project: sql.Project, release_policy: 
sql.ReleasePolicy
-) -> None:
-    submitted_announce_template = policy_data.announce_release_template
-    submitted_announce_template = submitted_announce_template.replace("\r\n", 
"\n")
-    rendered_default_announce_hash = 
policy_data.default_announce_release_template_hash
-    current_default_announce_text = project.policy_announce_release_default
-    current_default_announce_hash = 
util.compute_sha3_256(current_default_announce_text.encode())
-    submitted_announce_hash = 
util.compute_sha3_256(submitted_announce_template.encode())
-
-    if (submitted_announce_hash == rendered_default_announce_hash) or (
-        submitted_announce_hash == current_default_announce_hash
-    ):
-        release_policy.announce_release_template = ""
-    else:
-        release_policy.announce_release_template = submitted_announce_template
-
-
-def _set_default_min_hours(
-    policy_data: ReleasePolicyData, project: sql.Project, release_policy: 
sql.ReleasePolicy
-) -> None:
-    submitted_min_hours = policy_data.min_hours
-    default_value_seen_on_page_min_hours = 
policy_data.default_min_hours_value_at_render
-    current_system_default_min_hours = project.policy_default_min_hours
-
-    if (
-        submitted_min_hours == default_value_seen_on_page_min_hours
-        or submitted_min_hours == current_system_default_min_hours
-    ):
-        release_policy.min_hours = None
-    else:
-        release_policy.min_hours = submitted_min_hours
-
-
-def _set_default_start_vote_template(
-    policy_data: ReleasePolicyData, project: sql.Project, release_policy: 
sql.ReleasePolicy
-) -> None:
-    submitted_start_template = policy_data.start_vote_template
-    submitted_start_template = submitted_start_template.replace("\r\n", "\n")
-    rendered_default_start_hash = policy_data.default_start_vote_template_hash
-    current_default_start_text = project.policy_start_vote_default
-    current_default_start_hash = 
util.compute_sha3_256(current_default_start_text.encode())
-    submitted_start_hash = 
util.compute_sha3_256(submitted_start_template.encode())
-
-    if (submitted_start_hash == rendered_default_start_hash) or 
(submitted_start_hash == current_default_start_hash):
-        release_policy.start_vote_template = ""
-    else:
-        release_policy.start_vote_template = submitted_start_template
diff --git a/atr/storage/writers/policy.py b/atr/storage/writers/policy.py
index 28c125d..a234c92 100644
--- a/atr/storage/writers/policy.py
+++ b/atr/storage/writers/policy.py
@@ -19,7 +19,9 @@
 from __future__ import annotations
 
 import atr.db as db
+import atr.models as models
 import atr.storage as storage
+import atr.util as util
 
 
 class GeneralPublic:
@@ -83,3 +85,98 @@ class CommitteeMember(CommitteeParticipant):
             raise storage.AccessError("No ASF UID")
         self.__asf_uid = asf_uid
         self.__committee_name = committee_name
+
+    async def edit(
+        self, data: db.Session, project: models.sql.Project, policy_data: 
models.policy.ReleasePolicyData
+    ) -> None:
+        release_policy = project.release_policy
+        if release_policy is None:
+            release_policy = models.sql.ReleasePolicy(project=project)
+            project.release_policy = release_policy
+            data.add(release_policy)
+
+        # Compose section
+        release_policy.source_artifact_paths = 
policy_data.source_artifact_paths
+        release_policy.binary_artifact_paths = 
policy_data.binary_artifact_paths
+        release_policy.github_repository_name = 
policy_data.github_repository_name
+        # TODO: Change to paths, plural
+        release_policy.github_compose_workflow_path = 
policy_data.github_compose_workflow_path
+        release_policy.strict_checking = policy_data.strict_checking
+
+        # Vote section
+        release_policy.manual_vote = policy_data.manual_vote
+        if not release_policy.manual_vote:
+            release_policy.github_vote_workflow_path = 
policy_data.github_vote_workflow_path
+            release_policy.mailto_addresses = policy_data.mailto_addresses
+            self.__set_default_min_hours(policy_data, project, release_policy)
+            release_policy.pause_for_rm = policy_data.pause_for_rm
+            release_policy.release_checklist = policy_data.release_checklist
+            self.__set_default_start_vote_template(policy_data, project, 
release_policy)
+        elif project.committee and project.committee.is_podling:
+            # The caller ensures that project.committee is not None
+            raise storage.AccessError("Manual voting is not allowed for 
podlings.")
+
+        # Finish section
+        release_policy.github_finish_workflow_path = 
policy_data.github_finish_workflow_path
+        self.__set_default_announce_release_template(policy_data, project, 
release_policy)
+        release_policy.preserve_download_files = 
policy_data.preserve_download_files
+
+        await data.commit()
+
+    def __set_default_announce_release_template(
+        self,
+        policy_data: models.policy.ReleasePolicyData,
+        project: models.sql.Project,
+        release_policy: models.sql.ReleasePolicy,
+    ) -> None:
+        submitted_announce_template = policy_data.announce_release_template
+        submitted_announce_template = 
submitted_announce_template.replace("\r\n", "\n")
+        rendered_default_announce_hash = 
policy_data.default_announce_release_template_hash
+        current_default_announce_text = project.policy_announce_release_default
+        current_default_announce_hash = 
util.compute_sha3_256(current_default_announce_text.encode())
+        submitted_announce_hash = 
util.compute_sha3_256(submitted_announce_template.encode())
+
+        if (submitted_announce_hash == rendered_default_announce_hash) or (
+            submitted_announce_hash == current_default_announce_hash
+        ):
+            release_policy.announce_release_template = ""
+        else:
+            release_policy.announce_release_template = 
submitted_announce_template
+
+    def __set_default_min_hours(
+        self,
+        policy_data: models.policy.ReleasePolicyData,
+        project: models.sql.Project,
+        release_policy: models.sql.ReleasePolicy,
+    ) -> None:
+        submitted_min_hours = policy_data.min_hours
+        default_value_seen_on_page_min_hours = 
policy_data.default_min_hours_value_at_render
+        current_system_default_min_hours = project.policy_default_min_hours
+
+        if (
+            submitted_min_hours == default_value_seen_on_page_min_hours
+            or submitted_min_hours == current_system_default_min_hours
+        ):
+            release_policy.min_hours = None
+        else:
+            release_policy.min_hours = submitted_min_hours
+
+    def __set_default_start_vote_template(
+        self,
+        policy_data: models.policy.ReleasePolicyData,
+        project: models.sql.Project,
+        release_policy: models.sql.ReleasePolicy,
+    ) -> None:
+        submitted_start_template = policy_data.start_vote_template
+        submitted_start_template = submitted_start_template.replace("\r\n", 
"\n")
+        rendered_default_start_hash = 
policy_data.default_start_vote_template_hash
+        current_default_start_text = project.policy_start_vote_default
+        current_default_start_hash = 
util.compute_sha3_256(current_default_start_text.encode())
+        submitted_start_hash = 
util.compute_sha3_256(submitted_start_template.encode())
+
+        if (submitted_start_hash == rendered_default_start_hash) or (
+            submitted_start_hash == current_default_start_hash
+        ):
+            release_policy.start_vote_template = ""
+        else:
+            release_policy.start_vote_template = submitted_start_template


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

Reply via email to