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 1d3a5a3  Add a directory browser for each phase
1d3a5a3 is described below

commit 1d3a5a35c2bb787ffdce1d022449ee99da13fdd7
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Mar 28 15:09:12 2025 +0200

    Add a directory browser for each phase
---
 atr/routes/__init__.py                             | 48 +++++++++++
 atr/routes/candidate.py                            | 29 +++++++
 atr/routes/draft.py                                | 63 +++++++++++----
 atr/routes/preview.py                              | 28 +++++++
 atr/routes/release.py                              | 27 +++++++
 .../{draft-list.html => draft-files.html}          |  0
 atr/templates/phase-viewer.html                    | 94 ++++++++++++++++++++++
 atr/util.py                                        | 27 ++++++-
 8 files changed, 298 insertions(+), 18 deletions(-)

diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 535f173..6d96c40 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -18,6 +18,7 @@
 from __future__ import annotations
 
 import asyncio
+import datetime
 import functools
 import logging
 import time
@@ -265,6 +266,11 @@ def app_route_performance_measure(route_path: str, 
http_methods: list[str] | Non
     return decorator
 
 
+def format_datetime(timestamp: int) -> str:
+    """Format a Unix timestamp into a human readable datetime string."""
+    return datetime.datetime.fromtimestamp(timestamp, 
tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S")
+
+
 def format_file_size(size_in_bytes: int) -> str:
     """Format a file size with appropriate units and comma-separated digits."""
     # Format the raw bytes with commas
@@ -284,6 +290,48 @@ def format_file_size(size_in_bytes: int) -> str:
         return f"{formatted_bytes} bytes"
 
 
+def format_permissions(mode: int) -> str:
+    """Format Unix file permissions in ls -l style."""
+    # File type
+    if mode & 0o040000:
+        # Directory
+        perms = "d"
+    elif mode & 0o0100000:
+        # Regular file
+        perms = "-"
+    elif mode & 0o020000:
+        # Character special
+        perms = "c"
+    elif mode & 0o060000:
+        # Block special
+        perms = "b"
+    elif mode & 0o010000:
+        # FIFO
+        perms = "p"
+    elif mode & 0o0140000:
+        # Socket
+        perms = "s"
+    else:
+        perms = "?"
+
+    # Owner permissions
+    perms += "r" if mode & 0o400 else "-"
+    perms += "w" if mode & 0o200 else "-"
+    perms += "x" if mode & 0o100 else "-"
+
+    # Group permissions
+    perms += "r" if mode & 0o040 else "-"
+    perms += "w" if mode & 0o020 else "-"
+    perms += "x" if mode & 0o010 else "-"
+
+    # Others permissions
+    perms += "r" if mode & 0o004 else "-"
+    perms += "w" if mode & 0o002 else "-"
+    perms += "x" if mode & 0o001 else "-"
+
+    return perms
+
+
 async def get_form(request: quart.Request) -> datastructures.MultiDict:
     # The request.form() method in Quart calls a synchronous tempfile method
     # It calls quart.wrappers.request.form _load_form_data
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 2c6f714..5665f0c 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -64,6 +64,35 @@ async def resolve(session: routes.CommitterSession) -> 
response.Response | str:
     return await _resolve_post(session)
 
 
[email protected]("/candidate/viewer/<project_name>/<version_name>")
+async def viewer(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
+    """Show all the files in the rsync upload directory for a release."""
+    # Check that the user has access to the project
+    if not any((p.name == project_name) for p in (await 
session.user_projects)):
+        return await session.redirect(vote, error="You do not have access to 
this project")
+
+    # Check that the release exists
+    async with db.session() as data:
+        release = await data.release(name=f"{project_name}-{version_name}", 
_project=True).demand(
+            base.ASFQuartException("Release does not exist", errorcode=404)
+        )
+
+    # Convert async generator to list
+    file_stats = [
+        stat async for stat in 
util.content_list(util.get_release_candidate_dir(), project_name, version_name)
+    ]
+
+    return await quart.render_template(
+        "phase-viewer.html",
+        file_stats=file_stats,
+        release=release,
+        format_datetime=routes.format_datetime,
+        format_file_size=routes.format_file_size,
+        format_permissions=routes.format_permissions,
+        phase="release candidate",
+    )
+
+
 @routes.committer("/candidate/vote")
 async def vote(session: routes.CommitterSession) -> str:
     """Show all release candidates to which the user has access."""
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 0c52d7a..b409dbb 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -56,6 +56,22 @@ class AddProtocol(Protocol):
     project_name: wtforms.SelectField
 
 
+class DeleteForm(util.QuartFormTyped):
+    """Form for deleting a candidate draft."""
+
+    candidate_draft_name = wtforms.StringField(
+        "Candidate draft name", 
validators=[wtforms.validators.InputRequired("Candidate draft name is 
required")]
+    )
+    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 candidate draft")
+
+
 async def _number_of_release_files(release: models.Release) -> int:
     """Return the number of files in the release."""
     path_project = release.project.name
@@ -232,22 +248,6 @@ async def add_project(
     )
 
 
-class DeleteForm(util.QuartFormTyped):
-    """Form for deleting a candidate draft."""
-
-    candidate_draft_name = wtforms.StringField(
-        "Candidate draft name", 
validators=[wtforms.validators.InputRequired("Candidate draft name is 
required")]
-    )
-    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 candidate draft")
-
-
 @routes.committer("/draft/delete", methods=["POST"])
 async def delete(session: routes.CommitterSession) -> response.Response:
     """Delete a candidate draft and all its associated files."""
@@ -353,7 +353,7 @@ async def files(session: routes.CommitterSession, 
project_name: str, version_nam
         path_tasks[path] = await db.recent_tasks(data, 
f"{project_name}-{version_name}", str(path), path_modified[path])
 
     return await quart.render_template(
-        "draft-list.html",
+        "draft-files.html",
         asf_id=session.uid,
         project_name=project_name,
         version_name=version_name,
@@ -640,6 +640,35 @@ async def tools(session: routes.CommitterSession, 
project_name: str, version_nam
     )
 
 
[email protected]("/draft/viewer/<project_name>/<version_name>")
+async def viewer(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
+    """Show all the files in the rsync upload directory for a release."""
+    # Check that the user has access to the project
+    if not any((p.name == project_name) for p in (await 
session.user_projects)):
+        return await session.redirect(add, error="You do not have access to 
this project")
+
+    # Check that the release exists
+    async with db.session() as data:
+        release = await data.release(name=f"{project_name}-{version_name}", 
_project=True).demand(
+            base.ASFQuartException("Release does not exist", errorcode=404)
+        )
+
+    # Convert async generator to list
+    file_stats = [
+        stat async for stat in 
util.content_list(util.get_release_candidate_draft_dir(), project_name, 
version_name)
+    ]
+
+    return await quart.render_template(
+        "phase-viewer.html",
+        file_stats=file_stats,
+        release=release,
+        format_datetime=routes.format_datetime,
+        format_file_size=routes.format_file_size,
+        format_permissions=routes.format_permissions,
+        phase="release candidate draft",
+    )
+
+
 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
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 4d517e8..3bd8712 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -22,6 +22,7 @@ import logging
 import aiofiles.os
 import aioshutil
 import asfquart
+import asfquart.base as base
 import quart
 import werkzeug.wrappers.response as response
 import wtforms
@@ -200,6 +201,33 @@ async def review(session: routes.CommitterSession) -> str:
     )
 
 
[email protected]("/preview/viewer/<project_name>/<version_name>")
+async def viewer(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
+    """Show all the files in the rsync upload directory for a release."""
+    # Check that the user has access to the project
+    if not any((p.name == project_name) for p in (await 
session.user_projects)):
+        return await session.redirect(review, error="You do not have access to 
this project")
+
+    # Check that the release exists
+    async with db.session() as data:
+        release = await data.release(name=f"{project_name}-{version_name}", 
_project=True).demand(
+            base.ASFQuartException("Release does not exist", errorcode=404)
+        )
+
+    # Convert async generator to list
+    file_stats = [stat async for stat in 
util.content_list(util.get_release_preview_dir(), project_name, version_name)]
+
+    return await quart.render_template(
+        "phase-viewer.html",
+        file_stats=file_stats,
+        release=release,
+        format_datetime=routes.format_datetime,
+        format_file_size=routes.format_file_size,
+        format_permissions=routes.format_permissions,
+        phase="release preview",
+    )
+
+
 async def _delete_preview(data: db.Session, preview_name: str) -> None:
     """Delete a release preview and all its associated files."""
     # Check that the release exists
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 816eac7..ec4163c 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -21,6 +21,7 @@ import logging
 import logging.handlers
 
 import asfquart
+import asfquart.base as base
 import quart
 import werkzeug.wrappers.response as response
 
@@ -28,6 +29,7 @@ import atr.db as db
 import atr.db.models as models
 import atr.routes as routes
 import atr.routes.candidate as candidate
+import atr.util as util
 
 if asfquart.APP is ...:
     raise RuntimeError("APP is not set")
@@ -91,3 +93,28 @@ async def review(session: routes.CommitterSession) -> str:
         "release-review.html",
         releases=list(releases_before_announcement) + 
list(releases_after_announcement),
     )
+
+
[email protected]("/release/viewer/<project_name>/<version_name>")
+async def viewer(session: routes.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
+    """Show all the files in the rsync upload directory for a release."""
+    # Releases are public, so we don't need to filter by user
+
+    # Check that the release exists
+    async with db.session() as data:
+        release = await data.release(name=f"{project_name}-{version_name}", 
_project=True).demand(
+            base.ASFQuartException("Release does not exist", errorcode=404)
+        )
+
+    # Convert async generator to list
+    file_stats = [stat async for stat in 
util.content_list(util.get_release_dir(), project_name, version_name)]
+
+    return await quart.render_template(
+        "phase-viewer.html",
+        file_stats=file_stats,
+        release=release,
+        format_datetime=routes.format_datetime,
+        format_file_size=routes.format_file_size,
+        format_permissions=routes.format_permissions,
+        phase="release",
+    )
diff --git a/atr/templates/draft-list.html b/atr/templates/draft-files.html
similarity index 100%
rename from atr/templates/draft-list.html
rename to atr/templates/draft-files.html
diff --git a/atr/templates/phase-viewer.html b/atr/templates/phase-viewer.html
new file mode 100644
index 0000000..47d7125
--- /dev/null
+++ b/atr/templates/phase-viewer.html
@@ -0,0 +1,94 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  Viewer for {{ release.project.display_name }} {{ release.version }} ~ ATR
+{% endblock title %}
+
+{% block description %}
+  View the files for the {{ release.project.display_name }} {{ release.version 
}} {{ phase }}.
+{% endblock description %}
+
+{% block content %}
+  <h1>Viewer for {{ release.project.display_name }} {{ release.version }}</h1>
+  <p class="intro">
+    This page shows the files for the {{ release.project.display_name }} {{ 
release.version }} <strong>{{ phase }}</strong>.
+  </p>
+
+  <div class="card mb-4">
+    <div class="card-header d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Release information</h5>
+    </div>
+    <div class="card-body">
+      <div class="row">
+        <div class="col-md-6">
+          <p>
+            <strong>Project:</strong> {{ release.project.display_name }}
+          </p>
+          <p>
+            <strong>Version:</strong> {{ release.version }}
+          </p>
+          <p>
+            <strong>Label:</strong> {{ release.name }}
+          </p>
+        </div>
+        <div class="col-md-6">
+          <p>
+            <strong>Stage:</strong> <span class="badge bg-{% if release.stage 
== 'CURRENT' %}success{% elif release.stage == 'CANDIDATE' %}warning{% elif 
release.stage == 'BUILD' %}info{% else %}secondary{% endif %}">{{ 
release.stage.value.upper() }}</span>
+          </p>
+          <p>
+            <strong>Phase:</strong> <span class="badge bg-info">{{ 
release.phase.value.upper() }}</span>
+          </p>
+          <p>
+            <strong>Created:</strong> {{ release.created.strftime("%Y-%m-%d 
%H:%M") }}
+          </p>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="card mb-4">
+    <div class="card-header d-flex justify-content-between align-items-center">
+      <h5 class="mb-0">Files</h5>
+    </div>
+    <div class="card-body">
+      {% if file_stats|length > 0 %}
+        <div class="table-responsive">
+          <table class="table table-striped table-hover">
+            <thead>
+              <tr>
+                <th>Permissions</th>
+                <th>File path</th>
+                <th>Size</th>
+                <th>Modified</th>
+              </tr>
+            </thead>
+            <tbody>
+              {% for stat in file_stats %}
+                <tr>
+                  <td>{{ format_permissions(stat.permissions) }}</td>
+                  <td>
+                    {% if stat.is_file %}
+                      {{ stat.path }}
+                    {% else %}
+                      <strong>{{ stat.path }}/</strong>
+                    {% endif %}
+                  </td>
+                  <td>
+                    {% if stat.is_file %}
+                      {{ format_file_size(stat.size) }}
+                    {% else %}
+                      -
+                    {% endif %}
+                  </td>
+                  <td>{{ format_datetime(stat.modified) }}</td>
+                </tr>
+              {% endfor %}
+            </tbody>
+          </table>
+        </div>
+      {% else %}
+        <div class="alert alert-info">This {{ phase }} does not have any 
files.</div>
+      {% endif %}
+    </div>
+  </div>
+{% endblock content %}
diff --git a/atr/util.py b/atr/util.py
index dbbe814..539b1b0 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -18,7 +18,7 @@
 import dataclasses
 import hashlib
 import pathlib
-from collections.abc import Callable, Mapping, Sequence
+from collections.abc import AsyncGenerator, Callable, Mapping, Sequence
 from typing import Annotated, Any, TypeVar
 
 import aiofiles.os
@@ -62,6 +62,16 @@ class DictToList:
         )
 
 
[email protected]
+class FileStat:
+    path: str
+    modified: int
+    size: int
+    permissions: int
+    is_file: bool
+    is_dir: bool
+
+
 class QuartFormTyped(quart_wtf.QuartForm):
     """Quart form with type annotations."""
 
@@ -101,6 +111,21 @@ async def compute_sha512(file_path: pathlib.Path) -> str:
     return sha512.hexdigest()
 
 
+async def content_list(phase_subdir: pathlib.Path, project_name: str, 
version_name: str) -> AsyncGenerator[FileStat]:
+    """List all the files in the given path."""
+    base_path = phase_subdir / project_name / version_name
+    for path in await paths_recursive(base_path):
+        stat = await aiofiles.os.stat(base_path / path)
+        yield FileStat(
+            path=str(path),
+            modified=int(stat.st_mtime),
+            size=stat.st_size,
+            permissions=stat.st_mode,
+            is_file=bool(stat.st_mode & 0o0100000),
+            is_dir=bool(stat.st_mode & 0o040000),
+        )
+
+
 async def file_sha3(path: str) -> str:
     """Compute SHA3-256 hash of a file."""
     sha3 = hashlib.sha3_256()


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

Reply via email to