Release v0.1.4

This commit is contained in:
2026-07-02 14:59:52 +02:00
parent ab2343aa88
commit fb6fb67d45
27 changed files with 1437 additions and 1785 deletions

View File

@@ -19,6 +19,9 @@ from govoplan_campaign.backend.schemas import (
CampaignShareTargetsResponse,
CampaignShareUpsertRequest,
CampaignOwnerUpdateRequest,
RecipientImportMappingProfileListResponse,
RecipientImportMappingProfilePayload,
RecipientImportMappingProfileResponse,
CampaignJobsResponse,
CampaignJobDetailResponse,
CampaignRetryJobsRequest,
@@ -40,7 +43,7 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, SendAttempt, User, UserGroupMembership
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, RecipientImportMappingProfile, SendAttempt, User, UserGroupMembership
from govoplan_core.db.session import get_session
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
@@ -181,6 +184,27 @@ def _require_permission(principal: ApiPrincipal, scope: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _get_recipient_import_profile_for_principal(session: Session, profile_id: str, principal: ApiPrincipal) -> RecipientImportMappingProfile:
profile = session.get(RecipientImportMappingProfile, profile_id)
if not profile or profile.tenant_id != principal.tenant_id or profile.owner_user_id != principal.user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipient import mapping profile not found")
return profile
def _apply_recipient_import_profile_payload(profile: RecipientImportMappingProfile, payload: RecipientImportMappingProfilePayload) -> None:
profile.name = payload.name.strip()
profile.column_count = payload.column_count
profile.headers = list(payload.headers)
profile.normalized_headers = list(payload.normalized_headers)
profile.ordered_header_fingerprint = payload.ordered_header_fingerprint
profile.unordered_header_fingerprint = payload.unordered_header_fingerprint
profile.delimiter = payload.delimiter
profile.header_rows = payload.header_rows
profile.quoted = payload.quoted
profile.value_separators = payload.value_separators
profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings]
def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool:
if proposed is None:
return False
@@ -346,6 +370,109 @@ def list_campaigns(
return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns])
@router.get("/recipient-import/mapping-profiles", response_model=RecipientImportMappingProfileListResponse)
def list_recipient_import_mapping_profiles(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profiles = (
session.query(RecipientImportMappingProfile)
.filter(
RecipientImportMappingProfile.tenant_id == principal.tenant_id,
RecipientImportMappingProfile.owner_user_id == principal.user.id,
)
.order_by(RecipientImportMappingProfile.updated_at.desc(), RecipientImportMappingProfile.name.asc())
.all()
)
return RecipientImportMappingProfileListResponse(
profiles=[RecipientImportMappingProfileResponse.model_validate(profile) for profile in profiles]
)
@router.post(
"/recipient-import/mapping-profiles",
response_model=RecipientImportMappingProfileResponse,
status_code=status.HTTP_201_CREATED,
)
def create_recipient_import_mapping_profile(
payload: RecipientImportMappingProfilePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = RecipientImportMappingProfile(
tenant_id=principal.tenant_id,
owner_user_id=principal.user.id,
name=payload.name.strip(),
column_count=payload.column_count,
headers=list(payload.headers),
normalized_headers=list(payload.normalized_headers),
ordered_header_fingerprint=payload.ordered_header_fingerprint,
unordered_header_fingerprint=payload.unordered_header_fingerprint,
delimiter=payload.delimiter,
header_rows=payload.header_rows,
quoted=payload.quoted,
value_separators=payload.value_separators,
mappings=[mapping.model_dump(mode="json") for mapping in payload.mappings],
)
session.add(profile)
session.flush()
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_created",
object_type="recipient_import_mapping_profile",
object_id=profile.id,
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
commit=True,
)
session.refresh(profile)
return RecipientImportMappingProfileResponse.model_validate(profile)
@router.put("/recipient-import/mapping-profiles/{profile_id}", response_model=RecipientImportMappingProfileResponse)
def update_recipient_import_mapping_profile(
profile_id: str,
payload: RecipientImportMappingProfilePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
_apply_recipient_import_profile_payload(profile, payload)
session.add(profile)
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_updated",
object_type="recipient_import_mapping_profile",
object_id=profile.id,
details={"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint},
commit=True,
)
session.refresh(profile)
return RecipientImportMappingProfileResponse.model_validate(profile)
@router.delete("/recipient-import/mapping-profiles/{profile_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_recipient_import_mapping_profile(
profile_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")),
):
profile = _get_recipient_import_profile_for_principal(session, profile_id, principal)
details = {"name": profile.name, "ordered_header_fingerprint": profile.ordered_header_fingerprint}
session.delete(profile)
audit_from_principal(
session,
principal,
action="campaign.recipient_import_mapping_profile_deleted",
object_type="recipient_import_mapping_profile",
object_id=profile_id,
details=details,
commit=True,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{campaign_id}", response_model=CampaignResponse)
def get_campaign(
campaign_id: str,
@@ -2008,4 +2135,3 @@ def preview_campaign_attachments(
rules=rules,
unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [],
)