Commit b78b0080 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

Merge branch 'feat/internal-agreements' into 'develop'

feat(fm): manage partner agreements through the internal API

See merge request !35
parents 529dbc54 8acb4a04
Loading
Loading
Loading
Loading
Loading
+47 −3
Changes for src/federation_manager/adapters/database/agreement_repo.py: 47 added lines, 3 removed lines.
Original line number Diff line number Diff line
from typing import Any
from uuid import UUID

from sqlalchemy import and_, func, or_, select
from sqlalchemy import Row, and_, func, insert, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from federation_manager.adapters.database.tables import federation_agreements as agreements
@@ -26,8 +27,37 @@ class PostgresAgreementRepo:
            .limit(1)
        )
        row = (await self._session.execute(stmt)).one_or_none()
        if row is None:
            return None
        return None if row is None else _to_agreement(row)

    async def find_by_id(self, agreement_id: UUID) -> Agreement | None:
        stmt = select(agreements).where(agreements.c.id == agreement_id)
        row = (await self._session.execute(stmt)).one_or_none()
        return None if row is None else _to_agreement(row)

    async def list_for_partner(self, partner_id: UUID) -> list[Agreement]:
        stmt = (
            select(agreements)
            .where(agreements.c.partner_op_id == partner_id)
            .order_by(agreements.c.created_at)
        )
        return [_to_agreement(row) for row in (await self._session.execute(stmt)).all()]

    async def add(self, agreement: Agreement) -> None:
        await self._session.execute(
            insert(agreements).values(
                id=agreement.id, partner_op_id=agreement.partner_op_id, **_values(agreement)
            )
        )
        await self._session.commit()

    async def update(self, agreement: Agreement) -> None:
        await self._session.execute(
            update(agreements).where(agreements.c.id == agreement.id).values(**_values(agreement))
        )
        await self._session.commit()


def _to_agreement(row: Row[Any]) -> Agreement:
    return Agreement(
        id=row.id,
        partner_op_id=row.partner_op_id,
@@ -50,4 +80,18 @@ class PostgresAgreementRepo:
        valid_until=row.valid_until,
        status=row.status,
        zone_resources={UUID(str(k)): v for k, v in (row.zone_resources or {}).items()},
        usage_limits=dict(row.usage_limits or {}),
    )


def _values(agreement: Agreement) -> dict[str, Any]:
    return {
        "permitted_api_types": sorted(agreement.permitted_api_types),
        "permitted_zone_ids": sorted(str(z) for z in agreement.permitted_zone_ids),
        "service_spec_mappings": agreement.service_spec_mappings(),
        "usage_limits": agreement.usage_limits,
        "zone_resources": {str(k): v for k, v in agreement.zone_resources.items()},
        "valid_from": agreement.valid_from,
        "valid_until": agreement.valid_until,
        "status": agreement.status,
    }
+22 −1
Changes for src/federation_manager/api/errors.py: 22 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -10,6 +10,8 @@ from pydantic.json_schema import models_json_schema
from federation_manager.contracts.ewbi import InvalidParam, ProblemDetails
from federation_manager.domain.errors import (
    AgreementExpired,
    AgreementNotFound,
    AgreementValidityInvalid,
    AgreementViolation,
    AppInstanceUnknown,
    AuthenticationFailed,
@@ -117,7 +119,8 @@ def problem(
# and turns anything else into its own 502, so on these routes FM maps its EWBI-side 403/502
# onto the OPG.04 v1.4.0 set (_PROBLEM_STATUSES); `type` still says which failure it was.
_PROXIED_PATH = re.compile(
    r"^/internal/(identity|partners(/[^/]+)?|partners/[^/]+/federations(/.*)?)$"
    r"^/internal/(identity|local-zones|agreements(/[^/]+)?|partners(/[^/]+)?"
    r"|partners/[^/]+/federations(/.*)?)$"
)

# Partner rejections that describe the operator's own request and are relayed as they are.
@@ -392,6 +395,24 @@ def register_exception_handlers(app: FastAPI) -> None:
            request.url.path,
        )

    @app.exception_handler(AgreementNotFound)
    async def _agreement_not_found(request: Request, exc: AgreementNotFound) -> JSONResponse:
        return problem(
            404,
            "agreement-not-found",
            "Agreement Not Found",
            "No federation agreement exists with this identifier.",
            request.url.path,
        )

    @app.exception_handler(AgreementValidityInvalid)
    async def _agreement_validity_invalid(
        request: Request, exc: AgreementValidityInvalid
    ) -> JSONResponse:
        return problem(
            422, "agreement-validity-invalid", "Invalid Validity Period", str(exc), request.url.path
        )

    @app.exception_handler(PartnerCredentialsIncomplete)
    async def _credentials_incomplete(
        request: Request, exc: PartnerCredentialsIncomplete
+158 −0
Changes for src/federation_manager/api/internal/agreements.py: 158 added lines, 0 removed lines.
Original line number Diff line number Diff line
from datetime import datetime
from typing import Annotated, Any, Literal, Self
from uuid import UUID

from fastapi import APIRouter, Depends
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator

from federation_manager.application.agreements import AgreementService
from federation_manager.contracts.ewbi import ZoneTerms
from federation_manager.dependencies import get_agreement_service
from federation_manager.domain.models import Agreement, AppMapping

router = APIRouter(prefix="/internal/agreements", tags=["internal-agreements"])

Service = Annotated[AgreementService, Depends(get_agreement_service)]

# the values FM checks a partner request against; anything else would never match
ApiType = Literal[
    "install-app",
    "remove-app",
    "device-location",
    "qod-session",
    "qod-session-remove",
    "device-location-retrieve",
    "device-status-retrieve",
]
CreateStatus = Literal["draft", "active"]
Status = Literal["draft", "active", "suspended", "expired"]


class AppSpecMapping(BaseModel):
    """A partner's (appId, appVersion, flavourId) mapped to our service specification."""

    model_config = ConfigDict(extra="forbid")

    appId: str = Field(min_length=1)
    appVersion: str = Field(min_length=1)
    flavourId: str = Field(min_length=1)
    service_specification_id: UUID


class ServiceSpecMappings(BaseModel):
    model_config = ConfigDict(extra="forbid")

    apps: list[AppSpecMapping] = []
    api_families: dict[str, UUID] = {}


class AgreementCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    partner_op_id: UUID
    permitted_api_types: list[ApiType]
    permitted_zone_ids: list[UUID] = Field(
        default=[], description="Our SRM zone ids this partner may see and use"
    )
    zone_resources: dict[UUID, ZoneTerms] = Field(
        default={},
        description="Per permitted zone, the GSMA terms: reserved resources, quota, flavours",
    )
    service_spec_mappings: ServiceSpecMappings = ServiceSpecMappings()
    usage_limits: dict[str, Any] = {}
    valid_from: AwareDatetime
    valid_until: AwareDatetime | None = None
    status: CreateStatus = "draft"

    @model_validator(mode="after")
    def _terms_only_for_permitted_zones(self) -> Self:
        stray = set(self.zone_resources) - set(self.permitted_zone_ids)
        if stray:
            raise ValueError("zone_resources lists zones missing from permitted_zone_ids")
        return self

    def fields(self) -> dict[str, Any]:
        return {
            "partner_op_id": self.partner_op_id,
            "permitted_api_types": set(self.permitted_api_types),
            "permitted_zone_ids": set(self.permitted_zone_ids),
            "zone_resources": {
                zone: terms.model_dump(mode="json", by_alias=True, exclude_none=True)
                for zone, terms in self.zone_resources.items()
            },
            "app_mappings": tuple(
                AppMapping(
                    app_id=m.appId,
                    app_version=m.appVersion,
                    flavour_id=m.flavourId,
                    service_specification_id=m.service_specification_id,
                )
                for m in self.service_spec_mappings.apps
            ),
            "api_family_mappings": dict(self.service_spec_mappings.api_families),
            "usage_limits": self.usage_limits,
            "valid_from": self.valid_from,
            "valid_until": self.valid_until,
            "status": self.status,
        }


class AgreementUpdate(BaseModel):
    """Partial update of status, limits and validity; other terms need a new agreement."""

    model_config = ConfigDict(extra="forbid")

    status: Status | None = None
    usage_limits: dict[str, Any] | None = None
    valid_from: AwareDatetime | None = None
    valid_until: AwareDatetime | None = None

    @model_validator(mode="after")
    def _required_not_cleared(self) -> Self:
        for field in ("status", "usage_limits", "valid_from"):
            if field in self.model_fields_set and getattr(self, field) is None:
                raise ValueError(f"{field} cannot be null")
        return self

    def changes(self) -> dict[str, Any]:
        return self.model_dump(exclude_unset=True)


class AgreementView(BaseModel):
    id: UUID
    partner_op_id: UUID
    permitted_api_types: list[str]
    permitted_zone_ids: list[UUID]
    zone_resources: dict[UUID, dict[str, Any]]
    service_spec_mappings: dict[str, Any]
    usage_limits: dict[str, Any]
    valid_from: datetime
    valid_until: datetime | None
    status: str

    @classmethod
    def of(cls, agreement: Agreement) -> "AgreementView":
        return cls(
            id=agreement.id,
            partner_op_id=agreement.partner_op_id,
            permitted_api_types=sorted(agreement.permitted_api_types),
            permitted_zone_ids=sorted(agreement.permitted_zone_ids, key=str),
            zone_resources=dict(agreement.zone_resources),
            service_spec_mappings=agreement.service_spec_mappings(),
            usage_limits=dict(agreement.usage_limits),
            valid_from=agreement.valid_from,
            valid_until=agreement.valid_until,
            status=agreement.status,
        )


@router.post("", status_code=201, response_model=AgreementView)
async def create_agreement(body: AgreementCreate, service: Service) -> AgreementView:
    return AgreementView.of(await service.create(body.fields()))


@router.patch("/{agreement_id}", response_model=AgreementView)
async def update_agreement(
    agreement_id: UUID, body: AgreementUpdate, service: Service
) -> AgreementView:
    return AgreementView.of(await service.update(agreement_id, body.changes()))
+29 −0
Changes for src/federation_manager/api/internal/local_zones.py: 29 added lines, 0 removed lines.
Original line number Diff line number Diff line
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends
from pydantic import BaseModel

from federation_manager.application.zone_offer import local_zones
from federation_manager.dependencies import get_catalogue_repo, get_srm_client
from federation_manager.domain.ports import CatalogueRepositoryPort, SrmQueryPort

# What an operator can put in an agreement: our zones, never ones a partner offers us.
router = APIRouter(prefix="/internal/local-zones", tags=["internal-local-zones"])

Srm = Annotated[SrmQueryPort, Depends(get_srm_client)]
Catalogue = Annotated[CatalogueRepositoryPort, Depends(get_catalogue_repo)]


class LocalZoneView(BaseModel):
    id: UUID
    name: str
    region: str | None


@router.get("", response_model=list[LocalZoneView])
async def list_local_zones(srm: Srm, catalogue: Catalogue) -> list[LocalZoneView]:
    return [
        LocalZoneView(id=zone.id, name=zone.name, region=zone.region)
        for zone in await local_zones(srm, catalogue)
    ]
+19 −4
Changes for src/federation_manager/api/internal/partners.py: 19 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -5,13 +5,16 @@ from uuid import UUID
from fastapi import APIRouter, Depends
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, SecretStr, model_validator

from federation_manager.api.internal.agreements import AgreementView
from federation_manager.application.agreements import AgreementService
from federation_manager.application.partners import PartnerRegistration, PartnerRegistryService
from federation_manager.dependencies import get_partner_registry_service
from federation_manager.dependencies import get_agreement_service, get_partner_registry_service
from federation_manager.domain.models import PartnerOP

router = APIRouter(prefix="/internal/partners", tags=["internal-partners"])

Service = Annotated[PartnerRegistryService, Depends(get_partner_registry_service)]
Agreements = Annotated[AgreementService, Depends(get_agreement_service)]


def _http_url(value: str) -> str:
@@ -129,6 +132,12 @@ class PartnerView(BaseModel):
        return cls.model_validate(partner, from_attributes=True)


class PartnerDetailView(PartnerView):
    agreements: list[AgreementView] = Field(
        description="Every agreement with this partner, oldest first, whatever its status"
    )


@router.post("", status_code=201, response_model=PartnerView)
async def register_partner(body: PartnerCreate, service: Service) -> PartnerView:
    return PartnerView.of(await service.register(body.registration()))
@@ -139,9 +148,15 @@ async def list_partners(service: Service) -> list[PartnerView]:
    return [PartnerView.of(partner) for partner in await service.list()]


@router.get("/{partner_op_id}", response_model=PartnerView)
async def get_partner(partner_op_id: UUID, service: Service) -> PartnerView:
    return PartnerView.of(await service.get(partner_op_id))
@router.get("/{partner_op_id}", response_model=PartnerDetailView)
async def get_partner(
    partner_op_id: UUID, service: Service, agreements: Agreements
) -> PartnerDetailView:
    partner = PartnerView.of(await service.get(partner_op_id))
    return PartnerDetailView(
        **partner.model_dump(),
        agreements=[AgreementView.of(a) for a in await agreements.list_for_partner(partner_op_id)],
    )


@router.patch("/{partner_op_id}", response_model=PartnerView)
Loading