Commit 0ba11674 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: add ApiClientEntitlement model, repository, and mapper with database integration

parent b4785525
Loading
Loading
Loading
Loading
+36 −1
Changes for src/portal_service/adapters/database/mappers.py: 36 added lines, 1 removed line.
Original line number Diff line number Diff line
from portal_service.adapters.database.sql import ApiClientRow, LoginAttemptRow, LoginSessionRow
from portal_service.adapters.database.sql import (
    ApiClientEntitlementRow,
    ApiClientRow,
    LoginAttemptRow,
    LoginSessionRow,
)
from portal_service.domain.models import (
    ApiClient,
    ApiClientEntitlement,
    ApprovalStatus,
    DecidedBy,
    DisabledBy,
    EntitlementState,
    LoginAttempt,
    LoginSession,
    OperationalState,
@@ -98,3 +106,30 @@ class ApiClientMapper:
            secret_rotated_at=domain.secret_rotated_at,
            deleted_at=domain.deleted_at,
        )


class ApiClientEntitlementMapper:
    @staticmethod
    def to_domain(row: ApiClientEntitlementRow) -> ApiClientEntitlement:
        return ApiClientEntitlement(
            id=row.id,
            api_client_id=row.api_client_id,
            product=row.product,
            state=EntitlementState(row.state),
            decided_by=DecidedBy(row.decided_by),
            reason=row.reason,
            decided_at=row.decided_at,
            requested_at=row.requested_at,
        )

    @staticmethod
    def to_row(domain: ApiClientEntitlement) -> ApiClientEntitlementRow:
        return ApiClientEntitlementRow(
            id=domain.id,
            api_client_id=domain.api_client_id,
            product=domain.product,
            state=domain.state,
            decided_by=domain.decided_by,
            reason=domain.reason,
            decided_at=domain.decided_at,
        )
+4 −0
Changes for src/portal_service/adapters/database/repos/__init__.py: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
from portal_service.adapters.database.repos.api_client_entitlements import (
    SqlApiClientEntitlementRepository,
)
from portal_service.adapters.database.repos.api_clients import SqlApiClientRepository
from portal_service.adapters.database.repos.login_attempts import SqlLoginAttemptRepository
from portal_service.adapters.database.repos.login_sessions import SqlLoginSessionRepository

__all__ = [
    "SqlApiClientEntitlementRepository",
    "SqlApiClientRepository",
    "SqlLoginAttemptRepository",
    "SqlLoginSessionRepository",
+31 −0
Changes for src/portal_service/adapters/database/repos/api_client_entitlements.py: 31 added lines, 0 removed lines.
Original line number Diff line number Diff line
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from portal_service.adapters.database.mappers import ApiClientEntitlementMapper
from portal_service.adapters.database.sql import ApiClientEntitlementRow
from portal_service.domain.models import ApiClientEntitlement
from portal_service.domain.ports.database.api_client_entitlements import (
    ApiClientEntitlementRepository,
)


class SqlApiClientEntitlementRepository(ApiClientEntitlementRepository):
    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def save(self, entitlement: ApiClientEntitlement) -> ApiClientEntitlement:
        merged = await self._session.merge(ApiClientEntitlementMapper.to_row(entitlement))
        await self._session.flush()
        await self._session.refresh(merged)
        return ApiClientEntitlementMapper.to_domain(merged)

    async def list_by_client(self, api_client_id: UUID) -> list[ApiClientEntitlement]:
        stmt = (
            select(ApiClientEntitlementRow)
            .where(ApiClientEntitlementRow.api_client_id == api_client_id)
            .order_by(ApiClientEntitlementRow.requested_at)
        )
        rows = await self._session.scalars(stmt)
        return [ApiClientEntitlementMapper.to_domain(row) for row in rows]
+30 −1
Changes for src/portal_service/adapters/database/sql.py: 30 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@ already exists.
from datetime import datetime
from uuid import UUID

from sqlalchemy import DateTime, Index, LargeBinary, MetaData, String, func, text
from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, MetaData, String, func, text
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

@@ -107,3 +107,32 @@ class ApiClientRow(Base):
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
    )


class ApiClientEntitlementRow(Base):
    """
    One API product for one API client.d
    """

    __tablename__ = "api_client_entitlements"
    __table_args__ = (
        Index("idx_api_client_entitlements_api_client_id", "api_client_id"),
        Index(
            "uq_api_client_entitlements_active_product",
            "api_client_id",
            "product",
            unique=True,
            postgresql_where=text("state IN ('pending', 'approved')"),
        ),
    )

    id: Mapped[UUID] = mapped_column(primary_key=True)
    api_client_id: Mapped[UUID] = mapped_column(ForeignKey("api_clients.id"))
    product: Mapped[str] = mapped_column(String(64))
    state: Mapped[str] = mapped_column(String(32))
    decided_by: Mapped[str] = mapped_column(String(32))
    reason: Mapped[str | None] = mapped_column(String(255))
    decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    requested_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now()
    )
+27 −0
Changes for src/portal_service/domain/models.py: 27 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -146,3 +146,30 @@ class ApiClient(BaseModel):
    deleted_at: datetime | None = None
    created_at: datetime | None = None
    updated_at: datetime | None = None


class EntitlementState(StrEnum):
    PENDING = "pending"
    APPROVED = "approved"
    REJECTED = "rejected"
    REVOKED = "revoked"


class DecidedBy(StrEnum):
    AUTO = "auto"
    CREATOR = "creator"
    OPERATOR = "operator"
    SYSTEM = "system"


class ApiClientEntitlement(BaseModel):
    """Whether an API client may use one API product, and who decided it."""

    id: UUID
    api_client_id: UUID
    product: str
    state: EntitlementState
    decided_by: DecidedBy
    reason: str | None = None
    decided_at: datetime | None = None
    requested_at: datetime | None = None
Loading