Loading src/federation_manager/adapters/database/agreement_repo.py +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 Loading @@ -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, Loading @@ -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, } src/federation_manager/api/errors.py +22 −1 Changes for src/federation_manager/api/errors.py: 22 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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, Loading Loading @@ -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. Loading Loading @@ -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 Loading src/federation_manager/api/internal/agreements.py 0 → 100644 +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())) src/federation_manager/api/internal/local_zones.py 0 → 100644 +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) ] src/federation_manager/api/internal/partners.py +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 Loading @@ -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: Loading Loading @@ -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())) Loading @@ -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 Loading
src/federation_manager/adapters/database/agreement_repo.py +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 Loading @@ -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, Loading @@ -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, }
src/federation_manager/api/errors.py +22 −1 Changes for src/federation_manager/api/errors.py: 22 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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, Loading Loading @@ -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. Loading Loading @@ -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 Loading
src/federation_manager/api/internal/agreements.py 0 → 100644 +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()))
src/federation_manager/api/internal/local_zones.py 0 → 100644 +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) ]
src/federation_manager/api/internal/partners.py +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 Loading @@ -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: Loading Loading @@ -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())) Loading @@ -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