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 64bdecc  Organise and document the database models
64bdecc is described below

commit 64bdecc7ed39ca3eba069c75a5e88ac132f36ba9
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Jul 11 18:53:09 2025 +0100

    Organise and document the database models
---
 atr/db/models.py | 760 ++++++++++++++++++++++++++++++-------------------------
 1 file changed, 422 insertions(+), 338 deletions(-)

diff --git a/atr/db/models.py b/atr/db/models.py
index f7db4b0..bf2ef45 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -18,6 +18,8 @@
 """The data models to be persisted in the database."""
 
 # NOTE: We can't use symbolic annotations here because sqlmodel doesn't 
support them
+# https://github.com/fastapi/sqlmodel/issues/196
+# https://github.com/fastapi/sqlmodel/pull/778/files
 # from __future__ import annotations
 
 import datetime
@@ -45,6 +47,85 @@ sqlmodel.SQLModel.metadata = sqlalchemy.MetaData(
 )
 
 
+# Enumerations
+
+
+class CheckResultStatus(str, enum.Enum):
+    EXCEPTION = "exception"
+    FAILURE = "failure"
+    SUCCESS = "success"
+    WARNING = "warning"
+
+
+class ProjectStatus(str, enum.Enum):
+    ACTIVE = "active"
+    DORMANT = "dormant"
+    RETIRED = "retired"
+    STANDING = "standing"
+
+
+class ReleasePhase(str, enum.Enum):
+    # Step 1: The candidate files are added from external sources and checked 
by ATR
+    RELEASE_CANDIDATE_DRAFT = "release_candidate_draft"
+    # Step 2: The project members are voting on the candidate release
+    RELEASE_CANDIDATE = "release_candidate"
+    # Step 3: The release files are being put in place
+    RELEASE_PREVIEW = "release_preview"
+    # Step 4: The release has been announced
+    RELEASE = "release"
+
+
+class TaskStatus(str, enum.Enum):
+    """Status of a task in the task queue."""
+
+    QUEUED = "queued"
+    ACTIVE = "active"
+    COMPLETED = "completed"
+    FAILED = "failed"
+
+
+class TaskType(str, enum.Enum):
+    HASHING_CHECK = "hashing_check"
+    KEYS_IMPORT_FILE = "keys_import_file"
+    LICENSE_FILES = "license_files"
+    LICENSE_HEADERS = "license_headers"
+    MESSAGE_SEND = "message_send"
+    PATHS_CHECK = "paths_check"
+    RAT_CHECK = "rat_check"
+    SBOM_GENERATE_CYCLONEDX = "sbom_generate_cyclonedx"
+    SIGNATURE_CHECK = "signature_check"
+    SVN_IMPORT_FILES = "svn_import_files"
+    TARGZ_INTEGRITY = "targz_integrity"
+    TARGZ_STRUCTURE = "targz_structure"
+    VOTE_INITIATE = "vote_initiate"
+    ZIPFORMAT_INTEGRITY = "zipformat_integrity"
+    ZIPFORMAT_STRUCTURE = "zipformat_structure"
+
+
+class UserRole(str, enum.Enum):
+    COMMITTEE_MEMBER = "committee_member"
+    RELEASE_MANAGER = "release_manager"
+    COMMITTER = "committer"
+    VISITOR = "visitor"
+    ASF_MEMBER = "asf_member"
+    SYSADMIN = "sysadmin"
+
+
+# Pydantic models
+
+
+class VoteEntry(schema.Strict):
+    result: bool
+    summary: str
+    binding_votes: int
+    community_votes: int
+    start: datetime.datetime
+    end: datetime.datetime
+
+
+# Type decorators
+
+
 class UTCDateTime(sqlalchemy.types.TypeDecorator):
     """
     A custom column type to store datetime in sqlite.
@@ -100,70 +181,105 @@ class ResultsJSON(sqlalchemy.types.TypeDecorator):
             return None
 
 
-class UserRole(str, enum.Enum):
-    COMMITTEE_MEMBER = "committee_member"
-    RELEASE_MANAGER = "release_manager"
-    COMMITTER = "committer"
-    VISITOR = "visitor"
-    ASF_MEMBER = "asf_member"
-    SYSADMIN = "sysadmin"
+# SQL models
+
+# SQL models with no dependencies
 
 
+# KeyLink:
 class KeyLink(sqlmodel.SQLModel, table=True):
     committee_name: str = sqlmodel.Field(foreign_key="committee.name", 
primary_key=True)
     key_fingerprint: str = 
sqlmodel.Field(foreign_key="publicsigningkey.fingerprint", primary_key=True)
 
 
-class PublicSigningKey(sqlmodel.SQLModel, table=True):
-    # The fingerprint must be stored as lowercase hex
-    fingerprint: str = sqlmodel.Field(primary_key=True, unique=True)
-    # The algorithm is an RFC 4880 algorithm ID
-    algorithm: int
-    # Key length in bits
-    length: int
-    # Creation date
-    created: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
-    # Latest self signature
-    latest_self_signature: datetime.datetime | None = sqlmodel.Field(
-        default=None, sa_column=sqlalchemy.Column(UTCDateTime)
-    )
-    # Expiration date
-    expires: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
-    # The primary UID declared in the key
-    primary_declared_uid: str | None
-    # The secondary UIDs declared in the key
-    secondary_declared_uids: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+# PersonalAccessToken:
+class PersonalAccessToken(sqlmodel.SQLModel, table=True):
+    id: int | None = sqlmodel.Field(default=None, primary_key=True)
+    asfuid: str = sqlmodel.Field(index=True)
+    token_hash: str = sqlmodel.Field(unique=True)
+    created: datetime.datetime = sqlmodel.Field(
+        default_factory=lambda: datetime.datetime.now(datetime.UTC), 
sa_column=sqlalchemy.Column(UTCDateTime)
     )
-    # The UID used by Apache, if available
-    apache_uid: str | None
-    # The ASCII armored key
-    ascii_armored_key: str
-    # The committees that use this key
-    committees: list["Committee"] = 
sqlmodel.Relationship(back_populates="public_signing_keys", link_model=KeyLink)
+    expires: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+    last_used: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
+    label: str | None = None
 
 
-class ReleasePolicy(sqlmodel.SQLModel, table=True):
+# SSHKey:
+class SSHKey(sqlmodel.SQLModel, table=True):
+    fingerprint: str = sqlmodel.Field(primary_key=True)
+    key: str
+    asf_uid: str
+
+
+# Task:
+class Task(sqlmodel.SQLModel, table=True):
+    """A task in the task queue."""
+
     id: int = sqlmodel.Field(default=None, primary_key=True)
-    mailto_addresses: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-    manual_vote: bool = sqlmodel.Field(default=False)
-    min_hours: int | None = sqlmodel.Field(default=None)
-    release_checklist: str = sqlmodel.Field(default="")
-    pause_for_rm: bool = sqlmodel.Field(default=False)
-    start_vote_template: str = sqlmodel.Field(default="")
-    announce_release_template: str = sqlmodel.Field(default="")
-    binary_artifact_paths: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+    status: TaskStatus = sqlmodel.Field(default=TaskStatus.QUEUED, index=True)
+    task_type: TaskType
+    task_args: Any = 
sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    added: datetime.datetime = sqlmodel.Field(
+        default_factory=lambda: datetime.datetime.now(datetime.UTC),
+        sa_column=sqlalchemy.Column(UTCDateTime, index=True),
     )
-    source_artifact_paths: list[str] = sqlmodel.Field(
-        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+    started: datetime.datetime | None = sqlmodel.Field(
+        default=None,
+        sa_column=sqlalchemy.Column(UTCDateTime),
     )
-    strict_checking: bool = sqlmodel.Field(default=False)
+    pid: int | None = None
+    completed: datetime.datetime | None = sqlmodel.Field(
+        default=None,
+        sa_column=sqlalchemy.Column(UTCDateTime),
+    )
+    result: results.Results | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(ResultsJSON))
+    error: str | None = None
+
+    # Used for check tasks
+    # We don't put these in task_args because we want to query them efficiently
+    project_name: str | None = sqlmodel.Field(default=None, 
foreign_key="project.name")
+    version_name: str | None = sqlmodel.Field(default=None, index=True)
+    revision_number: str | None = sqlmodel.Field(default=None, index=True)
+    primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
+
+    # Create an index on status and added for efficient task claiming
+    __table_args__ = (
+        sqlalchemy.Index("ix_task_status_added", "status", "added"),
+        # Ensure valid status transitions:
+        # - QUEUED can transition to ACTIVE
+        # - ACTIVE can transition to COMPLETED or FAILED
+        # - COMPLETED and FAILED are terminal states
+        sqlalchemy.CheckConstraint(
+            """
+            (
+                -- Initial state is always valid
+                status = 'QUEUED'
+                -- QUEUED -> ACTIVE requires setting started time and pid
+                OR (status = 'ACTIVE' AND started IS NOT NULL AND pid IS NOT 
NULL)
+                -- ACTIVE -> COMPLETED requires setting completed time and 
result
+                OR (status = 'COMPLETED' AND completed IS NOT NULL AND result 
IS NOT NULL)
+                -- ACTIVE -> FAILED requires setting completed time and error 
(result optional)
+                OR (status = 'FAILED' AND completed IS NOT NULL AND error IS 
NOT NULL)
+            )
+            """,
+            name="valid_task_status_transitions",
+        ),
+    )
+
+
+# TextValue:
+class TextValue(sqlmodel.SQLModel, table=True):
+    # Composite primary key, automatically handled by SQLModel
+    ns: str = sqlmodel.Field(primary_key=True, index=True)
+    key: str = sqlmodel.Field(primary_key=True, index=True)
+    value: str = sqlmodel.Field()
+
 
-    # One-to-One: A release policy is associated with a project
-    project: "Project" = sqlmodel.Relationship(back_populates="release_policy")
+# SQL core models
 
 
+# Committee: Committee Project PublicSigningKey
 class Committee(sqlmodel.SQLModel, table=True):
     # TODO: Consider using key or label for primary string keys
     # Then we can use simply "name" for full_name, and make it str rather than 
str | None
@@ -172,22 +288,32 @@ class Committee(sqlmodel.SQLModel, table=True):
     # True only if this is an incubator podling with a PPMC
     is_podling: bool = sqlmodel.Field(default=False)
 
-    # One-to-many: A committee can have multiple child committees, each child 
committee belongs to one parent committee
+    # 1-M: Committee -> [Committee]
+    # M-1: Committee -> Committee
     child_committees: list["Committee"] = sqlmodel.Relationship(
         sa_relationship_kwargs=dict(
             backref=orm.backref("parent_committee", 
remote_side="Committee.name"),
         ),
     )
+
+    # M-1: Committee -> Committee
+    # 1-M: Committee -> [Committee]
     parent_committee_name: str | None = sqlmodel.Field(default=None, 
foreign_key="committee.name")
-    # One-to-many: A committee can have multiple projects, each project 
belongs to one committee
+    # parent_committee: Optional["Committee"]
+
+    # 1-M: Committee -> [Project]
+    # M-1: Project -> Committee
     projects: list["Project"] = 
sqlmodel.Relationship(back_populates="committee")
 
     committee_members: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
     committers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
     release_managers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
 
-    # Many-to-many: A committee can have multiple signing keys, and a signing 
key can belong to multiple committees
-    public_signing_keys: list[PublicSigningKey] = 
sqlmodel.Relationship(back_populates="committees", link_model=KeyLink)
+    # M-M: Committee -> [PublicSigningKey]
+    # M-M: PublicSigningKey -> [Committee]
+    public_signing_keys: list["PublicSigningKey"] = sqlmodel.Relationship(
+        back_populates="committees", link_model=KeyLink
+    )
 
     @property
     def display_name(self) -> str:
@@ -196,13 +322,11 @@ class Committee(sqlmodel.SQLModel, table=True):
         return f"{name} (PPMC)" if self.is_podling else name
 
 
-class ProjectStatus(str, enum.Enum):
-    ACTIVE = "active"
-    DORMANT = "dormant"
-    RETIRED = "retired"
-    STANDING = "standing"
+def see_also(arg: Any) -> None:
+    pass
 
 
+# Project: Project Committee Release DistributionChannel ReleasePolicy
 class Project(sqlmodel.SQLModel, table=True):
     # TODO: Consider using key or label for primary string keys
     # Then we can use simply "name" for full_name, and make it str rather than 
str | None
@@ -212,6 +336,9 @@ class Project(sqlmodel.SQLModel, table=True):
     full_name: str | None = sqlmodel.Field(default=None)
 
     status: ProjectStatus = sqlmodel.Field(default=ProjectStatus.ACTIVE)
+
+    # M-1: Project -> Project
+    # 1-M: (Project.child_project is missing, would be Project -> [Project])
     super_project_name: str | None = sqlmodel.Field(default=None, 
foreign_key="project.name")
     # NOTE: Neither "Project" | None nor "Project | None" works
     super_project: Optional["Project"] = sqlmodel.Relationship()
@@ -220,19 +347,25 @@ class Project(sqlmodel.SQLModel, table=True):
     category: str | None = sqlmodel.Field(default=None)
     programming_languages: str | None = sqlmodel.Field(default=None)
 
-    # Many-to-one: A project belongs to one committee, a committee can have 
multiple projects
+    # M-1: Project -> Committee
+    # 1-M: Committee -> [Project]
     committee_name: str | None = sqlmodel.Field(default=None, 
foreign_key="committee.name")
     committee: Committee | None = 
sqlmodel.Relationship(back_populates="projects")
+    see_also(Committee.projects)
 
-    # One-to-many: A project can have multiple releases, each release belongs 
to one project
+    # 1-M: Project -> [Release]
+    # M-1: Release -> Project
+    # see_also(Release.project)
     releases: list["Release"] = sqlmodel.Relationship(back_populates="project")
 
-    # One-to-many: A project can have multiple distribution channels, each 
channel belongs to one project
+    # 1-M: Project -> [DistributionChannel]
+    # M-1: DistributionChannel -> Project
     distribution_channels: list["DistributionChannel"] = 
sqlmodel.Relationship(back_populates="project")
 
-    # Many-to-one: A Project can have one release policy, a release policy can 
be used by multiple entities
+    # 1-1: Project -C-> ReleasePolicy
+    # 1-1: ReleasePolicy -> Project
     release_policy_id: int | None = sqlmodel.Field(default=None, 
foreign_key="releasepolicy.id", ondelete="CASCADE")
-    release_policy: ReleasePolicy | None = sqlmodel.Relationship(
+    release_policy: Optional["ReleasePolicy"] = sqlmodel.Relationship(
         cascade_delete=True, sa_relationship_kwargs={"cascade": "all, 
delete-orphan", "single_parent": True}
     )
 
@@ -254,7 +387,7 @@ class Project(sqlmodel.SQLModel, table=True):
         """Get the short display name for the Project."""
         return self.display_name.removeprefix("Apache ")
 
-    async def releases_by_phase(self, phase: "ReleasePhase") -> 
list["Release"]:
+    async def releases_by_phase(self, phase: ReleasePhase) -> list["Release"]:
         """Get the releases for the project by phase."""
         import atr.db as db
 
@@ -425,52 +558,219 @@ Thanks,
         return policy.strict_checking
 
 
-class DistributionChannel(sqlmodel.SQLModel, table=True):
-    id: int = sqlmodel.Field(default=None, primary_key=True)
-    name: str = sqlmodel.Field(index=True, unique=True)
-    url: str
-    credentials: str
-    is_test: bool = sqlmodel.Field(default=False)
-    automation_endpoint: str
-
-    # Many-to-one: A distribution channel belongs to one project, a project 
can have multiple channels
-    project_name: str = sqlmodel.Field(foreign_key="project.name")
-    project: Project = 
sqlmodel.Relationship(back_populates="distribution_channels")
+# Release: Project ReleasePolicy Revision CheckResult
+class Release(sqlmodel.SQLModel, table=True):
+    # model_config = compat.SQLModelConfig(extra="forbid", 
from_attributes=True)
 
+    # We guarantee that "{project.name}-{version}" is unique
+    # Therefore we can use that for the name
+    name: str = sqlmodel.Field(default="", primary_key=True, unique=True)
+    phase: ReleasePhase
+    created: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+    released: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
 
-class VoteEntry(schema.Strict):
-    result: bool
-    summary: str
-    binding_votes: int
-    community_votes: int
-    start: datetime.datetime
-    end: datetime.datetime
+    # M-1: Release -> Project
+    # 1-M: Project -> [Release]
+    project_name: str = sqlmodel.Field(foreign_key="project.name")
+    project: Project = sqlmodel.Relationship(back_populates="releases")
+    see_also(Project.releases)
 
+    package_managers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    # TODO: Not all releases have a version
+    # We could either make this str | None, or we could require version to be 
set on packages only
+    # For example, Apache Airflow Providers do not have an overall version
+    # They have one version per package, i.e. per provider
+    version: str
+    sboms: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
 
-class ReleasePhase(str, enum.Enum):
-    # Step 1: The candidate files are added from external sources and checked 
by ATR
-    RELEASE_CANDIDATE_DRAFT = "release_candidate_draft"
-    # Step 2: The project members are voting on the candidate release
-    RELEASE_CANDIDATE = "release_candidate"
-    # Step 3: The release files are being put in place
-    RELEASE_PREVIEW = "release_preview"
-    # Step 4: The release has been announced
-    RELEASE = "release"
+    # 1-1: Release -C-> ReleasePolicy
+    # 1-1: ReleasePolicy -> Release
+    release_policy_id: int | None = sqlmodel.Field(default=None, 
foreign_key="releasepolicy.id")
+    release_policy: Optional["ReleasePolicy"] = sqlmodel.Relationship(
+        cascade_delete=True, sa_relationship_kwargs={"cascade": "all, 
delete-orphan", "single_parent": True}
+    )
 
+    # VoteEntry is a Pydantic model, not a SQL model
+    votes: list[VoteEntry] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    vote_manual: bool = sqlmodel.Field(default=False)
+    vote_started: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
+    vote_resolved: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
+    podling_thread_id: str | None = sqlmodel.Field(default=None)
 
-def revision_name(release_name: str, number: str) -> str:
-    return f"{release_name} {number}"
+    # 1-M: Release -C-> [Revision]
+    # M-1: Revision -> Release
+    revisions: list["Revision"] = sqlmodel.Relationship(
+        back_populates="release",
+        sa_relationship_kwargs={
+            "order_by": "Revision.seq",
+            "foreign_keys": "[Revision.release_name]",
+            "cascade": "all, delete-orphan",
+        },
+    )
+
+    # 1-M: Release -C-> [CheckResult]
+    # M-1: CheckResult -> Release
+    check_results: list["CheckResult"] = sqlmodel.Relationship(
+        back_populates="release", sa_relationship_kwargs={"cascade": "all, 
delete-orphan"}
+    )
+
+    # The combination of project_name and version must be unique
+    __table_args__ = (sqlmodel.UniqueConstraint("project_name", "version", 
name="unique_project_version"),)
+
+    @property
+    def committee(self) -> Committee | None:
+        """Get the committee for the release."""
+        project = self.project
+        if project is None:
+            return None
+        return project.committee
+
+    @property
+    def short_display_name(self) -> str:
+        """Get the short display name for the release."""
+        return f"{self.project.short_display_name} {self.version}"
+
+    @property
+    def unwrap_revision_number(self) -> str:
+        """Get the revision number for the release, or raise an exception."""
+        number = self.latest_revision_number
+        if number is None:
+            raise ValueError("Release has no revisions")
+        return number
+
+    @pydantic.computed_field  # type: ignore[prop-decorator]
+    @property
+    def latest_revision_number(self) -> str | None:
+        """Get the latest revision number for the release."""
+        # The session must still be active for this to work
+        number = getattr(self, "_latest_revision_number", None)
+        if not (isinstance(number, str) or (number is None)):
+            raise ValueError("Latest revision number is not a str or None")
+        return number
+
+    # NOTE: This does not work
+    # But it we set it with Release.latest_revision_number_query = ..., it 
might work
+    # Not clear that we'd want to do that, though
+    # @property
+    # def latest_revision_number_query(self) -> expression.ScalarSelect[str]:
+    #     return (
+    #         sqlmodel.select(validate_instrumented_attribute(Revision.number))
+    #         .where(validate_instrumented_attribute(Revision.release_name) == 
Release.name)
+    #         .order_by(validate_instrumented_attribute(Revision.seq).desc())
+    #         .limit(1)
+    #         .scalar_subquery()
+    #     )
+
+
+# SQL models referencing Committee, Project, or Release
+
+
+# CheckResult: Release
+class CheckResult(sqlmodel.SQLModel, table=True):
+    id: int = sqlmodel.Field(default=None, primary_key=True)
+
+    # M-1: CheckResult -> Release
+    # 1-M: Release -C-> [CheckResult]
+    release_name: str = sqlmodel.Field(foreign_key="release.name", 
ondelete="CASCADE")
+    release: Release = sqlmodel.Relationship(back_populates="check_results")
 
+    # We don't call this latest_revision_number, because it might not be the 
latest
+    revision_number: str | None = sqlmodel.Field(default=None, index=True)
+    checker: str
+    primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
+    member_rel_path: str | None = sqlmodel.Field(default=None, index=True)
+    created: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+    status: CheckResultStatus
+    message: str
+    data: Any = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
 
+
+# DistributionChannel: Project
+class DistributionChannel(sqlmodel.SQLModel, table=True):
+    id: int = sqlmodel.Field(default=None, primary_key=True)
+    name: str = sqlmodel.Field(index=True, unique=True)
+    url: str
+    credentials: str
+    is_test: bool = sqlmodel.Field(default=False)
+    automation_endpoint: str
+
+    project_name: str = sqlmodel.Field(foreign_key="project.name")
+
+    # M-1: DistributionChannel -> Project
+    # 1-M: Project -> [DistributionChannel]
+    project: Project = 
sqlmodel.Relationship(back_populates="distribution_channels")
+    see_also(Project.distribution_channels)
+
+
+# PublicSigningKey: Committee
+class PublicSigningKey(sqlmodel.SQLModel, table=True):
+    # The fingerprint must be stored as lowercase hex
+    fingerprint: str = sqlmodel.Field(primary_key=True, unique=True)
+    # The algorithm is an RFC 4880 algorithm ID
+    algorithm: int
+    # Key length in bits
+    length: int
+    # Creation date
+    created: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
+    # Latest self signature
+    latest_self_signature: datetime.datetime | None = sqlmodel.Field(
+        default=None, sa_column=sqlalchemy.Column(UTCDateTime)
+    )
+    # Expiration date
+    expires: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
+    # The primary UID declared in the key
+    primary_declared_uid: str | None
+    # The secondary UIDs declared in the key
+    secondary_declared_uids: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+    )
+    # The UID used by Apache, if available
+    apache_uid: str | None
+    # The ASCII armored key
+    ascii_armored_key: str
+
+    # M-M: PublicSigningKey -> [Committee]
+    # M-M: Committee -> [PublicSigningKey]
+    committees: list[Committee] = 
sqlmodel.Relationship(back_populates="public_signing_keys", link_model=KeyLink)
+
+
+# ReleasePolicy: Project
+class ReleasePolicy(sqlmodel.SQLModel, table=True):
+    id: int = sqlmodel.Field(default=None, primary_key=True)
+    mailto_addresses: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    manual_vote: bool = sqlmodel.Field(default=False)
+    min_hours: int | None = sqlmodel.Field(default=None)
+    release_checklist: str = sqlmodel.Field(default="")
+    pause_for_rm: bool = sqlmodel.Field(default=False)
+    start_vote_template: str = sqlmodel.Field(default="")
+    announce_release_template: str = sqlmodel.Field(default="")
+    binary_artifact_paths: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+    )
+    source_artifact_paths: list[str] = sqlmodel.Field(
+        default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON)
+    )
+    strict_checking: bool = sqlmodel.Field(default=False)
+
+    # 1-1: ReleasePolicy -> Project
+    # 1-1: Project -C-> ReleasePolicy
+    project: Project = sqlmodel.Relationship(back_populates="release_policy")
+
+
+# Revision: Release
 class Revision(sqlmodel.SQLModel, table=True):
     name: str = sqlmodel.Field(default="", primary_key=True, unique=True)
+
+    # M-1: Revision -> Release
+    # 1-M: Release -C-> [Revision]
     release_name: str | None = sqlmodel.Field(default=None, 
foreign_key="release.name")
-    release: "Release" = sqlmodel.Relationship(
+    release: Release = sqlmodel.Relationship(
         back_populates="revisions",
         sa_relationship_kwargs={
             "foreign_keys": "[Revision.release_name]",
         },
     )
+
     seq: int = sqlmodel.Field(default=0)
     # This was designed as a property, but it's better for it to be a column
     # That way, we can do dynamic Release.latest_revision_number construction 
easier
@@ -481,6 +781,8 @@ class Revision(sqlmodel.SQLModel, table=True):
     )
     phase: ReleasePhase
 
+    # 1-1: Revision -> Revision
+    # 1-1: Revision -> Revision
     parent_name: str | None = sqlmodel.Field(default=None, 
foreign_key="revision.name")
     parent: Optional["Revision"] = sqlmodel.Relationship(
         sa_relationship_kwargs=dict(
@@ -490,6 +792,9 @@ class Revision(sqlmodel.SQLModel, table=True):
             back_populates="child",
         )
     )
+
+    # 1-1: Revision -> Revision
+    # 1-1: Revision -> Revision
     child: Optional["Revision"] = 
sqlmodel.Relationship(back_populates="parent")
 
     description: str | None = sqlmodel.Field(default=None)
@@ -500,6 +805,10 @@ class Revision(sqlmodel.SQLModel, table=True):
     )
 
 
+def revision_name(release_name: str, number: str) -> str:
+    return f"{release_name} {number}"
+
+
 @event.listens_for(Revision, "before_insert")
 def populate_revision_sequence_and_name(
     mapper: orm.Mapper, connection: sqlalchemy.engine.Connection, revision: 
Revision
@@ -542,86 +851,31 @@ def populate_revision_sequence_and_name(
     revision.name = revision_name(revision.release_name, revision.number)
 
 
-class TaskStatus(str, enum.Enum):
-    """Status of a task in the task queue."""
-
-    QUEUED = "queued"
-    ACTIVE = "active"
-    COMPLETED = "completed"
-    FAILED = "failed"
-
-
-class TaskType(str, enum.Enum):
-    HASHING_CHECK = "hashing_check"
-    KEYS_IMPORT_FILE = "keys_import_file"
-    LICENSE_FILES = "license_files"
-    LICENSE_HEADERS = "license_headers"
-    MESSAGE_SEND = "message_send"
-    PATHS_CHECK = "paths_check"
-    RAT_CHECK = "rat_check"
-    SBOM_GENERATE_CYCLONEDX = "sbom_generate_cyclonedx"
-    SIGNATURE_CHECK = "signature_check"
-    SVN_IMPORT_FILES = "svn_import_files"
-    TARGZ_INTEGRITY = "targz_integrity"
-    TARGZ_STRUCTURE = "targz_structure"
-    VOTE_INITIATE = "vote_initiate"
-    ZIPFORMAT_INTEGRITY = "zipformat_integrity"
-    ZIPFORMAT_STRUCTURE = "zipformat_structure"
-
[email protected]_for(Release, "before_insert")
+def check_release_name(_mapper: orm.Mapper, _connection: 
sqlalchemy.Connection, release: Release) -> None:
+    if release.name == "":
+        if (release.project_name is None) or (release.version is None):
+            raise ValueError("Cannot generate release name without 
project_name and version")
+        release.name = release_name(release.project_name, release.version)
 
-class Task(sqlmodel.SQLModel, table=True):
-    """A task in the task queue."""
 
-    id: int = sqlmodel.Field(default=None, primary_key=True)
-    status: TaskStatus = sqlmodel.Field(default=TaskStatus.QUEUED, index=True)
-    task_type: TaskType
-    task_args: Any = 
sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-    added: datetime.datetime = sqlmodel.Field(
-        default_factory=lambda: datetime.datetime.now(datetime.UTC),
-        sa_column=sqlalchemy.Column(UTCDateTime, index=True),
-    )
-    started: datetime.datetime | None = sqlmodel.Field(
-        default=None,
-        sa_column=sqlalchemy.Column(UTCDateTime),
-    )
-    pid: int | None = None
-    completed: datetime.datetime | None = sqlmodel.Field(
-        default=None,
-        sa_column=sqlalchemy.Column(UTCDateTime),
+def latest_revision_number_query(release_name: str | None = None) -> 
expression.ScalarSelect[str]:
+    if release_name is None:
+        query_release_name = Release.name
+    else:
+        query_release_name = release_name
+    return (
+        sqlmodel.select(validate_instrumented_attribute(Revision.number))
+        .where(validate_instrumented_attribute(Revision.release_name) == 
query_release_name)
+        .order_by(validate_instrumented_attribute(Revision.seq).desc())
+        .limit(1)
+        .scalar_subquery()
     )
-    result: results.Results | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(ResultsJSON))
-    error: str | None = None
 
-    # Used for check tasks
-    # We don't put these in task_args because we want to query them efficiently
-    project_name: str | None = sqlmodel.Field(default=None, 
foreign_key="project.name")
-    version_name: str | None = sqlmodel.Field(default=None, index=True)
-    revision_number: str | None = sqlmodel.Field(default=None, index=True)
-    primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
 
-    # Create an index on status and added for efficient task claiming
-    __table_args__ = (
-        sqlalchemy.Index("ix_task_status_added", "status", "added"),
-        # Ensure valid status transitions:
-        # - QUEUED can transition to ACTIVE
-        # - ACTIVE can transition to COMPLETED or FAILED
-        # - COMPLETED and FAILED are terminal states
-        sqlalchemy.CheckConstraint(
-            """
-            (
-                -- Initial state is always valid
-                status = 'QUEUED'
-                -- QUEUED -> ACTIVE requires setting started time and pid
-                OR (status = 'ACTIVE' AND started IS NOT NULL AND pid IS NOT 
NULL)
-                -- ACTIVE -> COMPLETED requires setting completed time and 
result
-                OR (status = 'COMPLETED' AND completed IS NOT NULL AND result 
IS NOT NULL)
-                -- ACTIVE -> FAILED requires setting completed time and error 
(result optional)
-                OR (status = 'FAILED' AND completed IS NOT NULL AND error IS 
NOT NULL)
-            )
-            """,
-            name="valid_task_status_transitions",
-        ),
-    )
+def release_name(project_name: str, version_name: str) -> str:
+    """Return the release name for a given project and version."""
+    return f"{project_name}-{version_name}"
 
 
 def validate_instrumented_attribute(obj: Any) -> orm.InstrumentedAttribute:
@@ -631,102 +885,6 @@ def validate_instrumented_attribute(obj: Any) -> 
orm.InstrumentedAttribute:
     return obj
 
 
-class Release(sqlmodel.SQLModel, table=True):
-    # model_config = compat.SQLModelConfig(extra="forbid", 
from_attributes=True)
-
-    # We guarantee that "{project.name}-{version}" is unique
-    # Therefore we can use that for the name
-    name: str = sqlmodel.Field(default="", primary_key=True, unique=True)
-    phase: ReleasePhase
-    created: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
-    released: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
-
-    # Many-to-one: A release belongs to one project, a project can have 
multiple releases
-    project_name: str = sqlmodel.Field(foreign_key="project.name")
-    project: Project = sqlmodel.Relationship(back_populates="releases")
-
-    package_managers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-    # TODO: Not all releases have a version
-    # We could either make this str | None, or we could require version to be 
set on packages only
-    # For example, Apache Airflow Providers do not have an overall version
-    # They have one version per package, i.e. per provider
-    version: str
-    sboms: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-
-    # Many-to-one: A release can have one release policy, a release policy can 
be used by multiple releases
-    release_policy_id: int | None = sqlmodel.Field(default=None, 
foreign_key="releasepolicy.id")
-    release_policy: ReleasePolicy | None = sqlmodel.Relationship(
-        cascade_delete=True, sa_relationship_kwargs={"cascade": "all, 
delete-orphan", "single_parent": True}
-    )
-
-    votes: list[VoteEntry] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-    vote_manual: bool = sqlmodel.Field(default=False)
-    vote_started: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
-    vote_resolved: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
-    podling_thread_id: str | None = sqlmodel.Field(default=None)
-
-    revisions: list["Revision"] = sqlmodel.Relationship(
-        back_populates="release",
-        sa_relationship_kwargs={
-            "order_by": "Revision.seq",
-            "foreign_keys": "[Revision.release_name]",
-            "cascade": "all, delete-orphan",
-        },
-    )
-
-    # One-to-many: A release can have multiple check results
-    check_results: list["CheckResult"] = sqlmodel.Relationship(
-        back_populates="release", sa_relationship_kwargs={"cascade": "all, 
delete-orphan"}
-    )
-
-    # The combination of project_name and version must be unique
-    __table_args__ = (sqlmodel.UniqueConstraint("project_name", "version", 
name="unique_project_version"),)
-
-    @property
-    def committee(self) -> Committee | None:
-        """Get the committee for the release."""
-        project = self.project
-        if project is None:
-            return None
-        return project.committee
-
-    @property
-    def short_display_name(self) -> str:
-        """Get the short display name for the release."""
-        return f"{self.project.short_display_name} {self.version}"
-
-    @property
-    def unwrap_revision_number(self) -> str:
-        """Get the revision number for the release, or raise an exception."""
-        number = self.latest_revision_number
-        if number is None:
-            raise ValueError("Release has no revisions")
-        return number
-
-    @pydantic.computed_field  # type: ignore[prop-decorator]
-    @property
-    def latest_revision_number(self) -> str | None:
-        """Get the latest revision number for the release."""
-        # The session must still be active for this to work
-        number = getattr(self, "_latest_revision_number", None)
-        if not (isinstance(number, str) or (number is None)):
-            raise ValueError("Latest revision number is not a str or None")
-        return number
-
-    # NOTE: This does not work
-    # But it we set it with Release.latest_revision_number_query = ..., it 
might work
-    # Not clear that we'd want to do that, though
-    # @property
-    # def latest_revision_number_query(self) -> expression.ScalarSelect[str]:
-    #     return (
-    #         sqlmodel.select(validate_instrumented_attribute(Revision.number))
-    #         .where(validate_instrumented_attribute(Revision.release_name) == 
Release.name)
-    #         .order_by(validate_instrumented_attribute(Revision.seq).desc())
-    #         .limit(1)
-    #         .scalar_subquery()
-    #     )
-
-
 # https://github.com/fastapi/sqlmodel/issues/240#issuecomment-2074161775
 Release._latest_revision_number = orm.column_property(
     sqlalchemy.select(validate_instrumented_attribute(Revision.number))
@@ -736,77 +894,3 @@ Release._latest_revision_number = orm.column_property(
     .correlate_except(Revision)
     .scalar_subquery(),
 )
-
-
-def latest_revision_number_query(release_name: str | None = None) -> 
expression.ScalarSelect[str]:
-    if release_name is None:
-        query_release_name = Release.name
-    else:
-        query_release_name = release_name
-    return (
-        sqlmodel.select(validate_instrumented_attribute(Revision.number))
-        .where(validate_instrumented_attribute(Revision.release_name) == 
query_release_name)
-        .order_by(validate_instrumented_attribute(Revision.seq).desc())
-        .limit(1)
-        .scalar_subquery()
-    )
-
-
-class SSHKey(sqlmodel.SQLModel, table=True):
-    fingerprint: str = sqlmodel.Field(primary_key=True)
-    key: str
-    asf_uid: str
-
-
-class CheckResultStatus(str, enum.Enum):
-    EXCEPTION = "exception"
-    FAILURE = "failure"
-    SUCCESS = "success"
-    WARNING = "warning"
-
-
-class CheckResult(sqlmodel.SQLModel, table=True):
-    id: int = sqlmodel.Field(default=None, primary_key=True)
-    release_name: str = sqlmodel.Field(foreign_key="release.name", 
ondelete="CASCADE")
-    release: Release = sqlmodel.Relationship(back_populates="check_results")
-    # We don't call this latest_revision_number, because it might not be the 
latest
-    revision_number: str | None = sqlmodel.Field(default=None, index=True)
-    checker: str
-    primary_rel_path: str | None = sqlmodel.Field(default=None, index=True)
-    member_rel_path: str | None = sqlmodel.Field(default=None, index=True)
-    created: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
-    status: CheckResultStatus
-    message: str
-    data: Any = sqlmodel.Field(sa_column=sqlalchemy.Column(sqlalchemy.JSON))
-
-
-class TextValue(sqlmodel.SQLModel, table=True):
-    # Composite primary key, automatically handled by SQLModel
-    ns: str = sqlmodel.Field(primary_key=True, index=True)
-    key: str = sqlmodel.Field(primary_key=True, index=True)
-    value: str = sqlmodel.Field()
-
-
[email protected]_for(Release, "before_insert")
-def check_release_name(_mapper: orm.Mapper, _connection: 
sqlalchemy.Connection, release: Release) -> None:
-    if release.name == "":
-        if (release.project_name is None) or (release.version is None):
-            raise ValueError("Cannot generate release name without 
project_name and version")
-        release.name = release_name(release.project_name, release.version)
-
-
-def release_name(project_name: str, version_name: str) -> str:
-    """Return the release name for a given project and version."""
-    return f"{project_name}-{version_name}"
-
-
-class PersonalAccessToken(sqlmodel.SQLModel, table=True):
-    id: int | None = sqlmodel.Field(default=None, primary_key=True)
-    asfuid: str = sqlmodel.Field(index=True)
-    token_hash: str = sqlmodel.Field(unique=True)
-    created: datetime.datetime = sqlmodel.Field(
-        default_factory=lambda: datetime.datetime.now(datetime.UTC), 
sa_column=sqlalchemy.Column(UTCDateTime)
-    )
-    expires: datetime.datetime = 
sqlmodel.Field(sa_column=sqlalchemy.Column(UTCDateTime))
-    last_used: datetime.datetime | None = sqlmodel.Field(default=None, 
sa_column=sqlalchemy.Column(UTCDateTime))
-    label: str | None = None


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


Reply via email to