Commit 75eb7d76 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

Merge branch 'feat/one-agreement-per-partner' into 'develop'

feat(fm): one agreement per partner, edited in place

See merge request !40
parents f61c6ea7 85485fdb
Loading
Loading
Loading
Loading
Loading
+21 −5
Changes for src/federation_manager/adapters/database/agreement_repo.py: 21 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -2,11 +2,15 @@ from typing import Any
from uuid import UUID

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

from federation_manager.adapters.database.tables import federation_agreements as agreements
from federation_manager.domain.errors import AgreementExists
from federation_manager.domain.models import Agreement, AppMapping

_ONE_PER_PARTNER = "uq_federation_agreements_one_per_partner"


class PostgresAgreementRepo:
    def __init__(self, session: AsyncSession) -> None:
@@ -43,18 +47,30 @@ class PostgresAgreementRepo:
        return [_to_agreement(row) for row in (await self._session.execute(stmt)).all()]

    async def add(self, agreement: Agreement) -> None:
        await self._session.execute(
        await self._write(
            agreement,
            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._write(
            agreement,
            update(agreements).where(agreements.c.id == agreement.id).values(**_values(agreement)),
        )

    async def _write(self, agreement: Agreement, stmt: Any) -> None:
        try:
            await self._session.execute(stmt)
            await self._session.commit()
        except IntegrityError as error:
            await self._session.rollback()
            # a concurrent create won, or an expired old agreement is being brought back
            # while the partner has a live one
            if _ONE_PER_PARTNER not in str(error.orig):
                raise
            raise AgreementExists(agreement.partner_op_id, None) from None


def _to_agreement(row: Row[Any]) -> Agreement:
+7 −0
Changes for src/federation_manager/adapters/database/tables.py: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -106,6 +106,13 @@ federation_agreements = Table(
    ),
    Index("idx_federation_agreements_partner", "partner_op_id"),
    Index("idx_federation_agreements_status_validity", "status", "valid_from", "valid_until"),
    # one agreement per partner; rows from before that rule stay, as expired history
    Index(
        "uq_federation_agreements_one_per_partner",
        "partner_op_id",
        unique=True,
        postgresql_where=text("status <> 'expired'"),
    ),
    CheckConstraint(
        _one_of("status", "draft", "active", "suspended", "expired"),
        name="ck_federation_agreements_status",
+26 −0
Changes for src/federation_manager/api/errors.py: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -9,8 +9,10 @@ from pydantic.json_schema import models_json_schema

from federation_manager.contracts.ewbi import InvalidParam, ProblemDetails
from federation_manager.domain.errors import (
    AgreementExists,
    AgreementExpired,
    AgreementNotFound,
    AgreementTermsInvalid,
    AgreementValidityInvalid,
    AgreementViolation,
    AppInstanceUnknown,
@@ -414,6 +416,30 @@ def register_exception_handlers(app: FastAPI) -> None:
            422, "agreement-validity-invalid", "Invalid Validity Period", str(exc), request.url.path
        )

    @app.exception_handler(AgreementExists)
    async def _agreement_exists(request: Request, exc: AgreementExists) -> JSONResponse:
        existing = f" Edit agreement {exc.agreement_id} instead." if exc.agreement_id else ""
        return problem(
            409,
            "agreement-exists",
            "Agreement Exists",
            f"This partner already has an agreement.{existing}",
            request.url.path,
        )

    @app.exception_handler(AgreementTermsInvalid)
    async def _agreement_terms_invalid(
        request: Request, exc: AgreementTermsInvalid
    ) -> JSONResponse:
        return problem(
            400,
            "invalid-request",
            "Invalid Request",
            "One or more request parameters or body fields are invalid; see invalidParams.",
            request.url.path,
            invalid_params=[InvalidParam(param="zone_resources", reason=str(exc))],
        )

    @app.exception_handler(PartnerCredentialsIncomplete)
    async def _credentials_incomplete(
        request: Request, exc: PartnerCredentialsIncomplete
+53 −19
Changes for src/federation_manager/api/internal/agreements.py: 53 added lines, 19 removed lines.
Original line number Diff line number Diff line
@@ -74,22 +74,14 @@ class AgreementCreate(BaseModel):
    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
            **_terms(
                {
                    "permitted_api_types": self.permitted_api_types,
                    "permitted_zone_ids": self.permitted_zone_ids,
                    "zone_resources": self.zone_resources,
                    "service_spec_mappings": self.service_spec_mappings,
                }
            ),
            "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,
@@ -98,24 +90,66 @@ class AgreementCreate(BaseModel):


class AgreementUpdate(BaseModel):
    """Partial update of status, limits and validity; other terms need a new agreement."""
    """Partial update: a partner has one agreement, so any of its terms can change in place."""

    model_config = ConfigDict(extra="forbid")

    status: Status | None = None
    permitted_api_types: list[ApiType] | None = None
    permitted_zone_ids: list[UUID] | None = None
    zone_resources: dict[UUID, ZoneTerms] | None = None
    service_spec_mappings: ServiceSpecMappings | 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:
        # only valid_until can be cleared; every other field always has a value
        for field in self.model_fields_set - {"valid_until"}:
            if 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)
        sent = {field: getattr(self, field) for field in self.model_fields_set}
        plain = {k: v for k, v in sent.items() if k not in _TERM_FIELDS}
        return {**plain, **_terms(sent)}


_TERM_FIELDS = {
    "permitted_api_types",
    "permitted_zone_ids",
    "zone_resources",
    "service_spec_mappings",
}


def _terms(sent: dict[str, Any]) -> dict[str, Any]:
    """The agreement's terms as the domain holds them, for whichever of them were sent."""
    terms: dict[str, Any] = {}
    if "permitted_api_types" in sent:
        terms["permitted_api_types"] = set(sent["permitted_api_types"])
    if "permitted_zone_ids" in sent:
        terms["permitted_zone_ids"] = set(sent["permitted_zone_ids"])
    if "zone_resources" in sent:
        terms["zone_resources"] = {
            zone: zone_terms.model_dump(mode="json", by_alias=True, exclude_none=True)
            for zone, zone_terms in sent["zone_resources"].items()
        }
    if "service_spec_mappings" in sent:
        mappings: ServiceSpecMappings = sent["service_spec_mappings"]
        terms["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 mappings.apps
        )
        terms["api_family_mappings"] = dict(mappings.api_families)
    return terms


class AgreementView(BaseModel):
+24 −1
Changes for src/federation_manager/application/agreements.py: 24 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -5,7 +5,9 @@ from uuid import UUID, uuid4

from federation_manager.application.zone_notifications import ZoneOfferNotifier
from federation_manager.domain.errors import (
    AgreementExists,
    AgreementNotFound,
    AgreementTermsInvalid,
    AgreementValidityInvalid,
    PartnerNotRegistered,
)
@@ -14,7 +16,11 @@ from federation_manager.domain.ports import AgreementRepositoryPort, PartnerRepo


class AgreementService:
    """Records the terms two operators agreed on; FM enforces them on every partner request."""
    """Records the terms two operators agreed on; FM enforces them on every partner request.

    A partner has one agreement, edited in place, so what applies is never a matter of which
    of several rows is newest.
    """

    def __init__(
        self,
@@ -31,8 +37,12 @@ class AgreementService:

    async def create(self, fields: dict[str, Any]) -> Agreement:
        partner = await self._require_partner(fields["partner_op_id"])
        existing = await self._agreements.list_for_partner(partner.id)
        if existing:
            raise AgreementExists(partner.id, _current(existing).id)
        agreement = Agreement(id=self._new_id(), **fields)
        _require_valid_period(agreement)
        _require_terms_for_permitted_zones(agreement)
        before = await self._offer(partner)
        await self._agreements.add(agreement)
        await self._tell(partner, before)
@@ -47,6 +57,7 @@ class AgreementService:
    async def update(self, agreement_id: UUID, changes: dict[str, Any]) -> Agreement:
        updated = replace(await self.get(agreement_id), **changes)
        _require_valid_period(updated)
        _require_terms_for_permitted_zones(updated)
        partner = await self._require_partner(updated.partner_op_id)
        before = await self._offer(partner)
        await self._agreements.update(updated)
@@ -76,3 +87,15 @@ class AgreementService:
def _require_valid_period(agreement: Agreement) -> None:
    if agreement.valid_until is not None and agreement.valid_until <= agreement.valid_from:
        raise AgreementValidityInvalid


def _require_terms_for_permitted_zones(agreement: Agreement) -> None:
    # checked on the merged agreement, since a PATCH may change either side alone
    if set(agreement.zone_resources) - agreement.permitted_zone_ids:
        raise AgreementTermsInvalid("zone_resources lists zones missing from permitted_zone_ids")


def _current(agreements: list[Agreement]) -> Agreement:
    # partners agreed before agreements became one per partner keep the older ones as expired
    live = [a for a in agreements if a.status != "expired"]
    return (live or agreements)[-1]
Loading