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 2e2e101  Add an API endpoint to delete a draft release
2e2e101 is described below

commit 2e2e101b6248c50e55e845d0502ee72e7a15e732
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jul 10 19:01:33 2025 +0100

    Add an API endpoint to delete a draft release
---
 atr/blueprints/api/__init__.py | 11 ++++++++++-
 atr/blueprints/api/api.py      | 31 +++++++++++++++++++++++++++++++
 atr/routes/draft.py            | 30 +++++++++++++++---------------
 3 files changed, 56 insertions(+), 16 deletions(-)

diff --git a/atr/blueprints/api/__init__.py b/atr/blueprints/api/__init__.py
index 3c34d20..c08aa9d 100644
--- a/atr/blueprints/api/__init__.py
+++ b/atr/blueprints/api/__init__.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import sys
+
 import asfquart.base as base
 import quart
 import quart.blueprints as blueprints
@@ -51,7 +53,14 @@ async def _handle_not_found(err: exceptions.NotFound) -> 
tuple[quart.Response, i
 
 
 def _json_error(message: str, status_code: int | None) -> 
tuple[quart.Response, int]:
-    return quart.jsonify({"error": message}), status_code or 500
+    payload = {"error": message}
+    show_traceback = False
+    if show_traceback:
+        import traceback
+
+        traceback_str = "".join(traceback.format_exception(*sys.exc_info()))
+        payload["traceback"] = traceback_str
+    return quart.jsonify(payload), status_code or 500
 
 
 @BLUEPRINT.record_once
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index fa2c3ce..2be17c3 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -38,6 +38,7 @@ import atr.db.models as models
 import atr.jwtoken as jwtoken
 import atr.revision as revision
 import atr.routes as routes
+import atr.routes.draft as draft
 import atr.routes.start as start
 import atr.routes.voting as voting
 import atr.schema as schema
@@ -94,6 +95,11 @@ class VoteStartRequest(schema.Strict):
     body: str
 
 
+class DraftDeleteRequest(schema.Strict):
+    project_name: str
+    version: str
+
+
 # We implicitly have /api/openapi.json
 
 
@@ -165,6 +171,31 @@ async def committees_name_projects(name: str) -> 
tuple[list[Mapping], int]:
         return [project.model_dump() for project in committee.projects], 200
 
 
[email protected]("/draft/delete", methods=["POST"])
[email protected]
+@quart_schema.security_scheme([{"BearerAuth": []}])
+@quart_schema.validate_response(dict[str, str], 200)
+async def draft_delete_project_version() -> tuple[dict[str, str], int]:
+    payload = await _payload_get()
+    req = DraftDeleteRequest.model_validate(payload)
+    asf_uid = _jwt_asf_uid()
+
+    async with db.session() as data:
+        release_name = models.release_name(req.project_name, req.version)
+        release = await data.release(
+            name=release_name, 
phase=models.ReleasePhase.RELEASE_CANDIDATE_DRAFT, _committee=True
+        ).demand(exceptions.NotFound())
+        if not (user.is_committee_member(release.project.committee, asf_uid) 
or user.is_admin(asf_uid)):
+            raise exceptions.Forbidden("You do not have permission to delete 
this draft")
+
+        # TODO: This causes "A transaction is already begun on this Session"
+        # async with data.begin():
+        # Probably due to autobegin in data.release above
+        await draft.delete_candidate_draft(data, release_name)
+        await data.commit()
+    return {"deleted": release_name}, 200
+
+
 @api.BLUEPRINT.route("/list/<project>/<version>")
 @api.BLUEPRINT.route("/list/<project>/<version>/<revision>")
 @quart_schema.validate_response(dict[str, list[str]], 200)
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 76e5a96..d3567f4 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -113,7 +113,7 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
     async with db.session() as data:
         async with data.begin():
             try:
-                await _delete_candidate_draft(data, release_name)
+                await delete_candidate_draft(data, release_name)
             except Exception as e:
                 logging.exception("Error deleting candidate draft:")
                 return await session.redirect(root.index, error=f"Error 
deleting candidate draft: {e!s}")
@@ -131,6 +131,20 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
     return await session.redirect(root.index, success="Candidate draft deleted 
successfully")
 
 
+async def delete_candidate_draft(data: db.Session, candidate_draft_name: str) 
-> None:
+    """Delete a candidate draft and all its associated files."""
+    # Check that the release exists
+    # TODO: Use session.release here
+    release = await data.release(name=candidate_draft_name, 
_project=True).get()
+    if not release:
+        raise routes.FlashError("Candidate draft not found")
+    if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+        raise routes.FlashError("Candidate draft is not in the release 
candidate draft phase")
+
+    # Delete the release record
+    await data.delete(release)
+
+
 @routes.committer("/draft/delete-file/<project_name>/<version_name>", 
methods=["POST"])
 async def delete_file(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
     """Delete a specific file from the release candidate, creating a new 
revision."""
@@ -507,17 +521,3 @@ async def vote_preview(
         ),
     )
     return quart.Response(body, mimetype="text/plain")
-
-
-async def _delete_candidate_draft(data: db.Session, candidate_draft_name: str) 
-> None:
-    """Delete a candidate draft and all its associated files."""
-    # Check that the release exists
-    # TODO: Use session.release here
-    release = await data.release(name=candidate_draft_name, 
_project=True).get()
-    if not release:
-        raise routes.FlashError("Candidate draft not found")
-    if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
-        raise routes.FlashError("Candidate draft is not in the release 
candidate draft phase")
-
-    # Delete the release record
-    await data.delete(release)


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

Reply via email to