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 8be28ef  Allow admins to delete releases in any phase
8be28ef is described below

commit 8be28ef79481f52dfc9e15c56e9cae2b30378a54
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Apr 1 19:35:00 2025 +0100

    Allow admins to delete releases in any phase
---
 atr/blueprints/admin/admin.py                      | 109 +++++++++++++++++++++
 atr/blueprints/admin/templates/delete-release.html |  57 +++++++++++
 atr/routes/draft.py                                |   2 +
 atr/templates/includes/sidebar.html                |   5 +
 atr/util.py                                        |  23 +++++
 docs/conventions.html                              |   4 +
 docs/conventions.md                                |   8 ++
 7 files changed, 208 insertions(+)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 267bb69..7da70e4 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -23,12 +23,14 @@ from collections.abc import Callable, Mapping
 from typing import Any
 
 import aiofiles.os
+import aioshutil
 import asfquart
 import asfquart.base as base
 import asfquart.session as session
 import httpx
 import quart
 import werkzeug.wrappers.response as response
+import wtforms
 
 import atr.blueprints.admin as admin
 import atr.datasources.apache as apache
@@ -39,6 +41,70 @@ import atr.util as util
 _LOGGER = logging.getLogger(__name__)
 
 
+class DeleteReleaseForm(util.QuartFormTyped):
+    """Form for deleting releases."""
+
+    confirm_delete = wtforms.StringField(
+        "Confirmation",
+        validators=[
+            wtforms.validators.InputRequired("Confirmation is required"),
+            wtforms.validators.Regexp("^DELETE$", message="Please type DELETE 
to confirm"),
+        ],
+    )
+    submit = wtforms.SubmitField("Delete selected releases permanently")
+
+
[email protected]("/delete-release", methods=["GET", "POST"])
+async def admin_delete_release() -> str | response.Response:
+    """Page to delete selected releases and their associated data and files."""
+    form = await DeleteReleaseForm.create_form()
+
+    if quart.request.method == "POST":
+        if await form.validate_on_submit():
+            form_data = await quart.request.form
+            releases_to_delete = form_data.getlist("releases_to_delete")
+
+            if not releases_to_delete:
+                await quart.flash("No releases selected for deletion.", 
"warning")
+                return 
quart.redirect(quart.url_for("admin.admin_delete_release"))
+
+            success_count = 0
+            fail_count = 0
+            error_messages = []
+
+            for release_name in releases_to_delete:
+                try:
+                    await _delete_release_data(release_name)
+                    success_count += 1
+                except base.ASFQuartException as e:
+                    _LOGGER.error("Error deleting release %s: %s", 
release_name, e)
+                    fail_count += 1
+                    error_messages.append(f"{release_name}: {e}")
+                except Exception:
+                    _LOGGER.exception("Unexpected error deleting release %s:", 
release_name)
+                    fail_count += 1
+                    error_messages.append(f"{release_name}: Unexpected error")
+
+            if success_count > 0:
+                await quart.flash(f"Successfully deleted {success_count} 
release(s).", "success")
+            if fail_count > 0:
+                errors_str = "\n".join(error_messages)
+                await quart.flash(f"Failed to delete {fail_count} 
release(s):\n{errors_str}", "error")
+
+            # Redirecting back to the deletion page will refresh the list of 
releases too
+            return quart.redirect(quart.url_for("admin.admin_delete_release"))
+
+        # It's unlikely that form validation failed due to spurious release 
names
+        # Therefore we assume that the user forgot to type DELETE to confirm
+        await quart.flash("Form validation failed. Please type DELETE to 
confirm.", "warning")
+        # Fall through to the combined GET and failed form validation handling 
below
+
+    # For GET request or failed form validation
+    async with db.session() as data:
+        releases = await 
data.release(_project=True).order_by(models.Release.name).all()
+    return await quart.render_template("delete-release.html", form=form, 
releases=releases, stats=None)
+
+
 @admin.BLUEPRINT.route("/performance")
 async def admin_performance() -> str:
     """Display performance statistics for all routes."""
@@ -348,3 +414,46 @@ async def admin_keys_delete_all() -> str:
                 await data.delete(key)
 
         return f"Deleted {count} keys"
+
+
+async def _delete_release_data(release_name: str) -> None:
+    """Handle the deletion of database records and filesystem data for a 
release."""
+    async with db.session() as data:
+        release = await data.release(name=release_name).demand(
+            base.ASFQuartException(f"Release '{release_name}' not found.", 404)
+        )
+        release_dir = util.release_directory(release)
+
+        # Delete from the database
+        _LOGGER.info("Deleting database records for release: %s", release_name)
+        # Cascade should handle this, but we delete manually anyway
+        tasks_to_delete = await data.task(release_name=release_name).all()
+        for task in tasks_to_delete:
+            await data.delete(task)
+        _LOGGER.debug("Deleted %d tasks for %s", len(tasks_to_delete), 
release_name)
+
+        checks_to_delete = await 
data.check_result(release_name=release_name).all()
+        for check in checks_to_delete:
+            await data.delete(check)
+        _LOGGER.debug("Deleted %d check results for %s", 
len(checks_to_delete), release_name)
+
+        await data.delete(release)
+        _LOGGER.info("Deleted release record: %s", release_name)
+        await data.commit()
+
+    # Delete from the filesystem
+    try:
+        if await aiofiles.os.path.isdir(release_dir):
+            _LOGGER.info("Deleting filesystem directory: %s", release_dir)
+            # Believe this to be another bug in mypy Protocol handling
+            # TODO: Confirm that this is a bug, and report upstream
+            await aioshutil.rmtree(release_dir)  # type: ignore[call-arg]
+            _LOGGER.info("Successfully deleted directory: %s", release_dir)
+        else:
+            _LOGGER.warning("Filesystem directory not found, skipping 
deletion: %s", release_dir)
+    except Exception as e:
+        _LOGGER.exception("Error deleting filesystem directory %s:", 
release_dir)
+        await quart.flash(
+            f"Database records for '{release_name}' deleted, but failed to 
delete filesystem directory: {e!s}",
+            "warning",
+        )
diff --git a/atr/blueprints/admin/templates/delete-release.html 
b/atr/blueprints/admin/templates/delete-release.html
new file mode 100644
index 0000000..9116c64
--- /dev/null
+++ b/atr/blueprints/admin/templates/delete-release.html
@@ -0,0 +1,57 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  Delete release ~ ATR Admin
+{% endblock title %}
+
+{% block description %}
+  Permanently delete a release and all associated data.
+{% endblock description %}
+
+{% block content %}
+  <h1>Delete release</h1>
+
+  <div class="alert alert-danger" role="alert">
+    <strong>Warning:</strong> This action is irreversible. Deleting a release 
will permanently remove its database records, including tasks and check 
results, and its associated files from the filesystem.
+  </div>
+
+  <form method="post" class="needs-validation" novalidate>
+    {{ form.csrf_token }}
+
+    <div class="mb-3">
+      <label class="form-label">Select Release(s) to Delete:</label>
+      {% if releases %}
+        <div class="list-group overflow-y-auto border rounded">
+          {% for release in releases %}
+            <label class="list-group-item list-group-item-action d-flex gap-3">
+              <input class="form-check-input flex-shrink-0"
+                     type="checkbox"
+                     name="releases_to_delete"
+                     value="{{ release.name }}" />
+              <span>
+                <strong>{{ release.name }}</strong> ({{ 
release.project.display_name }}, Phase: {{ release.phase.value.upper() }})
+              </span>
+            </label>
+          {% endfor %}
+        </div>
+        <div class="form-text">Select one or more releases to delete 
permanently.</div>
+      {% else %}
+        <p class="text-muted">No releases found in the database.</p>
+      {% endif %}
+    </div>
+
+    <div class="mb-3">
+      {{ form.confirm_delete.label(class="form-label") }}
+      {{ form.confirm_delete(class="form-control" + (" is-invalid" if 
form.confirm_delete.errors else "") , placeholder="DELETE") }}
+      {% if form.confirm_delete.errors %}
+        <div class="invalid-feedback">{{ form.confirm_delete.errors[0] }}</div>
+      {% else %}
+        <div class="form-text">Please type DELETE exactly to confirm 
deletion.</div>
+      {% endif %}
+    </div>
+
+    {{ form.submit(class="btn btn-danger") }}
+
+  </form>
+
+{% endblock content %}
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 90b7955..e845f7b 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -221,6 +221,8 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
     if await aiofiles.os.path.exists(draft_dir):
         # Believe this to be another bug in mypy Protocol handling
         # TODO: Confirm that this is a bug, and report upstream
+        # Changing it to str(...) doesn't work either
+        # Yet it works in preview.py
         await aioshutil.rmtree(draft_dir)  # type: ignore[call-arg]
 
     return await session.redirect(directory, success="Candidate draft deleted 
successfully")
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index 4a828b3..e756a6b 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -130,6 +130,11 @@
             <a href="{{ url_for('admin.admin_performance') }}"
                {% if request.endpoint == 'admin.admin_performance' 
%}class="active"{% endif %}>Performance dashboard</a>
           </li>
+          <li>
+            <i class="fa-solid fa-trash"></i>
+            <a href="{{ url_for('admin.admin_delete_release') }}"
+               {% if request.endpoint == 'admin.admin_delete_release' 
%}class="active"{% endif %}>Delete release</a>
+          </li>
         </ul>
       {% endif %}
     {% endif %}
diff --git a/atr/util.py b/atr/util.py
index ca3a842..3b5f81f 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -202,6 +202,29 @@ async def paths_recursive(base_path: pathlib.Path, sort: 
bool = True) -> list[pa
     return paths
 
 
+def release_directory(release: models.Release) -> pathlib.Path:
+    """Determine the filesystem directory for a given release based on its 
phase."""
+    phase = release.phase
+    try:
+        project_name, version_name = release.name.rsplit("-", 1)
+    except ValueError:
+        raise base.ASFQuartException(f"Invalid release name format 
'{release.name}'", 500)
+
+    base_dir: pathlib.Path | None = None
+    match phase:
+        case models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+            base_dir = get_release_candidate_draft_dir()
+        case models.ReleasePhase.RELEASE_CANDIDATE_BEFORE_VOTE | 
models.ReleasePhase.RELEASE_CANDIDATE_DURING_VOTE:
+            base_dir = get_release_candidate_dir()
+        case models.ReleasePhase.RELEASE_PREVIEW:
+            base_dir = get_release_preview_dir()
+        case models.ReleasePhase.RELEASE_BEFORE_ANNOUNCEMENT | 
models.ReleasePhase.RELEASE_AFTER_ANNOUNCEMENT:
+            base_dir = get_release_dir()
+        # NOTE: Do NOT add "case _" here
+
+    return base_dir / project_name / version_name
+
+
 def unwrap(value: T | None, error_message: str = "unexpected None when 
unwrapping value") -> T:
     """
     Will unwrap the given value or raise a ValueError if it is None
diff --git a/docs/conventions.html b/docs/conventions.html
index 03410d9..7c2588d 100644
--- a/docs/conventions.html
+++ b/docs/conventions.html
@@ -150,6 +150,10 @@ def _verify_archive_integrity_do_something():
 <p>Do:</p>
 <pre><code class="language-python">(a or b) and (c == d) or e
 </code></pre>
+<h3>Use terse comments on their own lines</h3>
+<p>Place comments on dedicated lines preceding the relevant code block. 
Comments at the ends of lines are strictly reserved for linter or type checker 
directives. This convention enhances code scannability for such directives. 
General comments must not appear at the end of code lines. Keep comments 
concise, using sentence case without terminal punctuation. Each sentence 
forming a comment must occupy its own line.</p>
 <h2>HTML</h2>
 <h3>Use sentence case for headings</h3>
 <p>We write headings like &quot;This is a heading&quot;, and not &quot;This is 
a Heading&quot; or &quot;This Is A Heading&quot;. This follows the <a 
href="https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style#Section_headings";>Wikipedia
 style for headings</a>. The same goes for button texts.</p>
+<h3>Use Bootstrap classes for all style</h3>
+<p>We use Bootstrap classes for style, and avoid custom classes unless 
absolutely necessary. If you think that you have to resort to a custom class, 
consult the list of <a href="https://bootstrapclasses.com/";>Bootstrap 
classes</a> for guidance. There is usually a class for what you want to 
achieve, and if there isn't then you may be making things too complicated. 
Complicated, custom style is difficult for a team to maintain. If you still 
believe that a new class is strictly warranted, th [...]
diff --git a/docs/conventions.md b/docs/conventions.md
index 797df60..67feb2f 100644
--- a/docs/conventions.md
+++ b/docs/conventions.md
@@ -238,8 +238,16 @@ Do:
 (a or b) and (c == d) or e
 ```
 
+### Use terse comments on their own lines
+
+Place comments on dedicated lines preceding the relevant code block. Comments 
at the ends of lines are strictly reserved for linter or type checker 
directives. This convention enhances code scannability for such directives. 
General comments must not appear at the end of code lines. Keep comments 
concise, using sentence case without terminal punctuation. Each sentence 
forming a comment must occupy its own line.
+
 ## HTML
 
 ### Use sentence case for headings
 
 We write headings like "This is a heading", and not "This is a Heading" or 
"This Is A Heading". This follows the [Wikipedia style for 
headings](https://en.wikipedia.org/wiki/Wikipedia:Manual_of_Style#Section_headings).
 The same goes for button texts.
+
+### Use Bootstrap classes for all style
+
+We use Bootstrap classes for style, and avoid custom classes unless absolutely 
necessary. If you think that you have to resort to a custom class, consult the 
list of [Bootstrap classes](https://bootstrapclasses.com/) for guidance. There 
is usually a class for what you want to achieve, and if there isn't then you 
may be making things too complicated. Complicated, custom style is difficult 
for a team to maintain. If you still believe that a new class is strictly 
warranted, then the class m [...]


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

Reply via email to