Add governed tabular and LDAP address sources

This commit is contained in:
2026-08-02 15:39:34 +02:00
parent e60339a5bf
commit 2c421022d4
19 changed files with 3846 additions and 78 deletions
+304 -4
View File
@@ -15,6 +15,12 @@ from govoplan_core.core.contact_points import (
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.ldap import AddressLdapError
from govoplan_addresses.backend.ldap_schemas import (
AddressLdapConnectionRequest,
AddressLdapDiscoveryResponse,
AddressLdapSourceCreateRequest,
)
from govoplan_addresses.backend.db.models import (
AddressBook,
AddressList,
@@ -29,6 +35,27 @@ from govoplan_addresses.backend.db.models import (
ContactPointQualityDecision,
ContactPostalAddress,
)
from govoplan_addresses.backend.import_schemas import (
AddressImportCommitRequest,
AddressImportPreviewRequest,
AddressImportProfileCreateRequest,
AddressImportProfileListResponse,
AddressImportProfileResponse,
AddressImportProfileUpdateRequest,
AddressImportRollbackRequest,
AddressImportRunResponse,
)
from govoplan_addresses.backend.imports import (
apply_address_import,
create_import_profile,
get_import_run,
import_run_payload,
list_import_profiles,
preview_address_import,
retire_import_profile,
rollback_address_import,
update_import_profile,
)
from govoplan_addresses.backend.capabilities import (
AddressesContactPointResolutionCapability,
AddressesContactWriterCapability,
@@ -112,6 +139,7 @@ from govoplan_addresses.backend.service import (
create_address_list,
create_address_list_entry,
create_carddav_sync_source,
create_ldap_sync_source,
create_contact,
create_contact_channel_rule,
create_contact_quality_decision,
@@ -124,6 +152,7 @@ from govoplan_addresses.backend.service import (
delete_contact,
delete_sync_source,
discover_carddav_address_books,
discover_ldap_base_dns,
export_address_book_vcard,
export_contact_vcard,
end_contact_channel_rule,
@@ -1341,6 +1370,55 @@ def api_discover_carddav_address_books(
raise _error(AddressBookError(str(exc))) from exc
@router.post("/ldap/discover", response_model=AddressLdapDiscoveryResponse)
def api_discover_ldap_base_dns(
payload: AddressLdapConnectionRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:sync:write")
try:
return AddressLdapDiscoveryResponse(
base_dns=list(discover_ldap_base_dns(session, principal, payload))
)
except (AddressBookError, AddressLdapError) as exc:
raise _error(AddressBookError(str(exc))) from exc
@router.post(
"/address-books/{book_id}/ldap/sources",
response_model=AddressSyncSourceResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_ldap_sync_source(
book_id: str,
payload: AddressLdapSourceCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:sync:write")
try:
sync_source = create_ldap_sync_source(session, principal, book_id, payload)
_audit_address_sync(
session,
principal,
action="addresses.sync_source_created",
object_type="address_sync_source",
object_id=sync_source.id,
details={
"address_book_id": book_id,
"connector_type": "ldap",
"sync_direction": "read_only",
},
)
session.commit()
session.refresh(sync_source)
return _sync_source_response(sync_source)
except (AddressBookError, AddressLdapError) as exc:
session.rollback()
raise _error(AddressBookError(str(exc))) from exc
@router.get("/credentials", response_model=AddressCredentialEnvelopeListResponse)
def api_list_address_credentials(
source_id: str | None = Query(default=None),
@@ -1381,7 +1459,7 @@ def api_create_carddav_sync_source(
session.commit()
session.refresh(sync_source)
return _sync_source_response(sync_source)
except (AddressBookError, AddressCardDAVError) as exc:
except (AddressBookError, AddressCardDAVError, AddressLdapError) as exc:
session.rollback()
raise _error(AddressBookError(str(exc))) from exc
@@ -1428,7 +1506,7 @@ def api_preview_sync_source(
)
session.commit()
return _sync_plan_response(plan)
except (AddressBookError, AddressCardDAVError) as exc:
except (AddressBookError, AddressCardDAVError, AddressLdapError) as exc:
session.rollback()
raise _error(AddressBookError(str(exc))) from exc
@@ -1460,9 +1538,41 @@ def api_run_sync_source(
)
session.commit()
return _sync_plan_response(plan)
except (AddressBookError, AddressCardDAVError) as exc:
except (AddressBookError, AddressCardDAVError, AddressLdapError) as exc:
message = str(exc)
session.rollback()
raise _error(AddressBookError(str(exc))) from exc
try:
finish_sync_attempt(
session,
principal,
sync_source_id,
AddressSyncAttemptFinishRequest(
status="failed",
error=message,
diagnostic={
"severity": "error",
"code": "connector_unavailable",
"message": message,
"retryable": True,
"stage": "read",
},
),
)
record_sync_diagnostic(
session,
principal,
sync_source_id,
AddressSyncDiagnosticCreateRequest(
severity="error",
code="connector_unavailable",
message=message,
details={"retryable": True, "stage": "read"},
),
)
session.commit()
except Exception:
session.rollback()
raise _error(AddressBookError(message)) from exc
@router.post("/address-books/{book_id}/sync-sources", response_model=AddressSyncSourceResponse, status_code=status.HTTP_201_CREATED)
@@ -1981,6 +2091,196 @@ def api_import_address_book_vcards(
raise _error(AddressBookError(str(exc))) from exc
@router.get("/import-profiles", response_model=AddressImportProfileListResponse)
def api_list_address_import_profiles(
include_history: bool = Query(default=False),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
return AddressImportProfileListResponse(
profiles=[
AddressImportProfileResponse.model_validate(item)
for item in list_import_profiles(session, principal, include_history=include_history)
]
)
@router.post("/import-profiles", response_model=AddressImportProfileResponse, status_code=status.HTTP_201_CREATED)
def api_create_address_import_profile(
payload: AddressImportProfileCreateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:write")
try:
profile = create_import_profile(session, principal, payload)
session.flush()
audit_from_principal(
session,
principal,
action="addresses.import_profile_created",
object_type="address_import_profile",
object_id=profile.profile_key,
details={"version": profile.version, "source_format": profile.source_format, "scope_type": profile.scope_type},
)
session.commit()
session.refresh(profile)
return AddressImportProfileResponse.model_validate(profile)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.patch("/import-profiles/{profile_id}", response_model=AddressImportProfileResponse)
def api_update_address_import_profile(
profile_id: str,
payload: AddressImportProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:write")
try:
profile = update_import_profile(session, principal, profile_id, payload)
session.flush()
audit_from_principal(
session,
principal,
action="addresses.import_profile_versioned",
object_type="address_import_profile",
object_id=profile.profile_key,
details={"version": profile.version, "source_format": profile.source_format},
)
session.commit()
session.refresh(profile)
return AddressImportProfileResponse.model_validate(profile)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.delete("/import-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_retire_address_import_profile(
profile_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:write")
try:
retire_import_profile(session, principal, profile_id)
audit_from_principal(
session,
principal,
action="addresses.import_profile_retired",
object_type="address_import_profile",
object_id=profile_id,
)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.post(
"/address-books/{book_id}/imports/preview",
response_model=AddressImportRunResponse,
status_code=status.HTTP_201_CREATED,
)
def api_preview_address_import(
book_id: str,
payload: AddressImportPreviewRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:write")
try:
run = preview_address_import(session, principal, book_id, payload)
audit_from_principal(
session,
principal,
action="addresses.import_previewed",
object_type="address_import_run",
object_id=run.id,
details={"input_hash": run.input_hash, "plan_hash": run.plan_hash, "statistics": run.statistics},
)
session.commit()
session.refresh(run)
return AddressImportRunResponse.model_validate(import_run_payload(run))
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.get("/imports/{run_id}", response_model=AddressImportRunResponse)
def api_get_address_import_run(
run_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:read")
try:
return AddressImportRunResponse.model_validate(
import_run_payload(get_import_run(session, principal, run_id))
)
except AddressBookError as exc:
raise _error(exc) from exc
@router.post("/imports/{run_id}/apply", response_model=AddressImportRunResponse)
def api_apply_address_import(
run_id: str,
payload: AddressImportCommitRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:write")
try:
run = apply_address_import(session, principal, run_id, expected_plan_hash=payload.expected_plan_hash)
session.flush()
audit_from_principal(
session,
principal,
action="addresses.import_applied",
object_type="address_import_run",
object_id=run.id,
details={"input_hash": run.input_hash, "plan_hash": run.plan_hash, "statistics": run.statistics},
)
session.commit()
session.refresh(run)
return AddressImportRunResponse.model_validate(import_run_payload(run))
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.post("/imports/{run_id}/rollback", response_model=AddressImportRunResponse)
def api_rollback_address_import(
run_id: str,
payload: AddressImportRollbackRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_scope(principal, "addresses:contact:write")
try:
run = rollback_address_import(session, principal, run_id, payload)
session.flush()
audit_from_principal(
session,
principal,
action="addresses.import_rolled_back",
object_type="address_import_run",
object_id=run.id,
details={"reason": payload.reason, "plan_hash": run.plan_hash},
)
session.commit()
session.refresh(run)
return AddressImportRunResponse.model_validate(import_run_payload(run))
except AddressBookError as exc:
session.rollback()
raise _error(exc) from exc
@router.get("/address-books/{book_id}/vcards/export")
def api_export_address_book_vcards(
book_id: str,