Add governed contact point snapshots

This commit is contained in:
2026-08-02 06:20:24 +02:00
parent 67392f620f
commit 2e78b9ae50
10 changed files with 1620 additions and 16 deletions
+169 -1
View File
@@ -8,6 +8,11 @@ from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.contact_points import (
ContactPointResolutionRequest,
ContactPointSourceRequest,
)
from govoplan_core.core.distribution_lists import DistributionSourceReference
from govoplan_core.db.session import get_session
from govoplan_addresses.backend.carddav import AddressCardDAVError
from govoplan_addresses.backend.db.models import (
@@ -22,7 +27,10 @@ from govoplan_addresses.backend.db.models import (
ContactChannelRule,
ContactPostalAddress,
)
from govoplan_addresses.backend.capabilities import AddressesContactWriterCapability
from govoplan_addresses.backend.capabilities import (
AddressesContactPointResolutionCapability,
AddressesContactWriterCapability,
)
from govoplan_addresses.backend.schemas import (
AddressBookCreateRequest,
AddressBookListResponse,
@@ -66,6 +74,11 @@ from govoplan_addresses.backend.schemas import (
ContactChannelRuleCreateRequest,
ContactChannelRuleListResponse,
ContactChannelRuleResponse,
ContactPointResolveRequest,
ContactPointResolutionResponse,
ContactPointSnapshotResponse,
ContactPointSourcePreviewResponse,
ContactPointSourceRequestPayload,
ContactListResponse,
ContactResponse,
ContactUpdateRequest,
@@ -212,6 +225,43 @@ def _write_decision_response(decision) -> AddressBookWriteDecisionResponse:
return AddressBookWriteDecisionResponse.model_validate(payload)
def _contact_point_resolution_request(
principal: ApiPrincipal,
payload: ContactPointResolveRequest,
) -> ContactPointResolutionRequest:
return ContactPointResolutionRequest(
tenant_id=principal.tenant_id,
subject=DistributionSourceReference(**payload.subject.model_dump()),
effective_at=payload.effective_at,
purpose=payload.purpose,
requested_channels=tuple(payload.requested_channels),
address_purpose=payload.address_purpose,
fallback_rule=payload.fallback_rule,
locale=payload.locale,
postal_format=payload.postal_format,
context=payload.context,
)
def _contact_point_source_request(
principal: ApiPrincipal,
payload: ContactPointSourceRequestPayload,
) -> ContactPointSourceRequest:
return ContactPointSourceRequest(
tenant_id=principal.tenant_id,
source_id=payload.source_id,
effective_at=payload.effective_at,
purpose=payload.purpose,
requested_channels=tuple(payload.requested_channels),
address_purpose=payload.address_purpose,
fallback_rule=payload.fallback_rule,
locale=payload.locale,
postal_format=payload.postal_format,
max_items=payload.max_items,
context=payload.context,
)
def _sync_source_response(sync_source: AddressSyncSource) -> AddressSyncSourceResponse:
return AddressSyncSourceResponse.model_validate(
{
@@ -493,6 +543,124 @@ def api_lookup_addresses(
return AddressLookupResponse(contacts=[_contact_response(contact) for contact in contacts])
@router.post("/contact-points/resolve", response_model=ContactPointResolutionResponse)
def api_resolve_contact_points(
payload: ContactPointResolveRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
try:
result = AddressesContactPointResolutionCapability().resolve_contact_points(
session,
principal,
request=_contact_point_resolution_request(principal, payload),
)
return ContactPointResolutionResponse.model_validate(asdict(result))
except (AddressBookError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post(
"/contact-point-sources/preview",
response_model=ContactPointSourcePreviewResponse,
)
def api_preview_contact_point_source(
payload: ContactPointSourceRequestPayload,
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
try:
result = AddressesContactPointResolutionCapability().preview_source(
session,
principal,
request=_contact_point_source_request(principal, payload),
offset=offset,
limit=limit,
)
return ContactPointSourcePreviewResponse.model_validate(asdict(result))
except (AddressBookError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.post(
"/contact-point-snapshots",
response_model=ContactPointSnapshotResponse,
status_code=status.HTTP_201_CREATED,
)
def api_freeze_contact_point_source(
payload: ContactPointSourceRequestPayload,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
try:
snapshot = AddressesContactPointResolutionCapability().freeze_source(
session,
principal,
request=_contact_point_source_request(principal, payload),
)
audit_from_principal(
session,
principal,
action="addresses.contact_point_snapshot_created",
object_type="address_contact_point_snapshot",
object_id=snapshot.id,
details={
"source_id": snapshot.request.source_id,
"source_revision": snapshot.source_revision,
"snapshot_hash": snapshot.snapshot_hash,
"recipient_count": snapshot.recipient_count,
"excluded_count": snapshot.excluded_count,
"purpose": snapshot.request.purpose,
},
)
session.commit()
return ContactPointSnapshotResponse.model_validate(asdict(snapshot))
except (AddressBookError, ValueError) as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.get(
"/contact-point-snapshots/{snapshot_id}",
response_model=ContactPointSnapshotResponse,
)
def api_get_contact_point_snapshot(
snapshot_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
_require_scope(principal, "addresses:governance:read")
snapshot = AddressesContactPointResolutionCapability().get_snapshot(
session,
principal,
snapshot_id=snapshot_id,
)
if snapshot is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contact-point snapshot not found.",
)
return ContactPointSnapshotResponse.model_validate(asdict(snapshot))
@router.get("/address-lists", response_model=AddressListListResponse)
def api_list_address_lists(
address_book_id: str | None = Query(default=None),