from __future__ import annotations from collections.abc import Iterable, Mapping from sqlalchemy import and_, or_ from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef from govoplan_core.core.campaigns import ( CampaignAccessProvider, CampaignDeliveryTaskProvider, CampaignMailPolicyContext, CampaignMailPolicyContextProvider, CampaignPolicyContext, CampaignPolicyContextProvider, CampaignRetentionProvider, ) from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_campaign.backend.db.models import Campaign, CampaignShare READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"} class CampaignMailPolicyContextService(CampaignMailPolicyContextProvider): def get_campaign_mail_policy_context( self, session: object, *, tenant_id: str, campaign_id: str, ) -> CampaignMailPolicyContext | None: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: return None return CampaignMailPolicyContext( id=campaign.id, tenant_id=campaign.tenant_id, owner_user_id=campaign.owner_user_id, owner_group_id=campaign.owner_group_id, mail_profile_policy=dict(campaign.mail_profile_policy or {}), ) def set_campaign_mail_profile_policy( self, session: object, *, tenant_id: str, campaign_id: str, policy: Mapping[str, object], ) -> Mapping[str, object]: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: raise ValueError("Campaign policy not found") payload = dict(policy) campaign.mail_profile_policy = payload session.add(campaign) # type: ignore[attr-defined] return payload def mail_policy_context_capability(context: object) -> CampaignMailPolicyContextService: return CampaignMailPolicyContextService() class CampaignAccessService(CampaignAccessProvider): def campaign_exists(self, session: object, *, tenant_id: str, campaign_id: str) -> bool: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] return bool(campaign and campaign.tenant_id == tenant_id) def can_read_campaign( self, session: object, *, tenant_id: str, campaign_id: str, user_id: str, group_ids: Iterable[str] = (), tenant_admin: bool = False, ) -> bool: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: return False if tenant_admin or campaign.owner_user_id == user_id: return True group_id_set = {str(group_id) for group_id in group_ids} if campaign.owner_group_id and campaign.owner_group_id in group_id_set: return True share_clauses = [ and_( CampaignShare.target_type == "user", CampaignShare.target_id == user_id, ) ] if group_id_set: share_clauses.append( and_( CampaignShare.target_type == "group", CampaignShare.target_id.in_(group_id_set), ) ) share = ( session.query(CampaignShare) # type: ignore[attr-defined] .filter( CampaignShare.tenant_id == tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None), or_(*share_clauses), ) .first() ) return share is not None def explain_resource_provenance( self, session: object, principal: PrincipalRef, *, resource_type: str, resource_id: str, action: str, ) -> tuple[AccessDecisionProvenance, ...]: normalized_type = resource_type.lower().strip() if normalized_type not in {"campaign", "campaign_object", "campaigns:campaign"}: return () campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined] if campaign is None or (principal.tenant_id and campaign.tenant_id != principal.tenant_id): return ( AccessDecisionProvenance( kind="resource", id=resource_id, tenant_id=principal.tenant_id, source="campaigns.not_found", details={"resource_type": "campaign", "found": False}, ), ) items = [ AccessDecisionProvenance( kind="resource", id=campaign.id, label=campaign.name, tenant_id=campaign.tenant_id, source="campaigns.campaign", details={ "resource_type": "campaign", "external_id": campaign.external_id, "status": campaign.status, }, ) ] items.extend(_owner_provenance(campaign, principal)) items.extend(_tenant_admin_provenance(principal)) permission_values = {"read", "write"} if action in READ_ACTIONS else {"write"} shares = ( session.query(CampaignShare) # type: ignore[attr-defined] .filter( CampaignShare.tenant_id == campaign.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None), CampaignShare.permission.in_(sorted(permission_values)), or_( (CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.membership_id), (CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(sorted(principal.group_ids))), ), ) .order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc()) .all() ) for share in shares: items.append( AccessDecisionProvenance( kind="share", id=share.id, label=share.permission, tenant_id=share.tenant_id, source="campaigns.share", details={ "target_type": share.target_type, "target_id": share.target_id, "permission": share.permission, }, ) ) return tuple(items) def access_capability(context: object) -> CampaignAccessService: return CampaignAccessService() def _owner_provenance(campaign: Campaign, principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]: if campaign.owner_user_id and campaign.owner_user_id == principal.membership_id: return ( AccessDecisionProvenance( kind="owner", id=campaign.owner_user_id, tenant_id=campaign.tenant_id, source="campaigns.owner", details={"owner_type": "user"}, ), ) if campaign.owner_group_id and campaign.owner_group_id in principal.group_ids: return ( AccessDecisionProvenance( kind="owner", id=campaign.owner_group_id, tenant_id=campaign.tenant_id, source="campaigns.owner", details={"owner_type": "group"}, ), ) return () def _tenant_admin_provenance(principal: PrincipalRef) -> tuple[AccessDecisionProvenance, ...]: if not scopes_grant_compatible(principal.scopes, "tenant:*"): return () return ( AccessDecisionProvenance( kind="policy", id="tenant:*", label="tenant:*", tenant_id=principal.tenant_id, source="campaigns.tenant_admin", details={"grant": "tenant_admin"}, ), ) class CampaignPolicyContextService(CampaignPolicyContextProvider): def get_campaign_policy_context( self, session: object, *, tenant_id: str | None = None, campaign_id: str, ) -> CampaignPolicyContext | None: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or (tenant_id is not None and campaign.tenant_id != tenant_id): return None return CampaignPolicyContext( id=campaign.id, tenant_id=campaign.tenant_id, owner_user_id=campaign.owner_user_id, owner_group_id=campaign.owner_group_id, settings=dict(campaign.settings or {}), ) def set_campaign_settings( self, session: object, *, tenant_id: str, campaign_id: str, settings: Mapping[str, object], ) -> Mapping[str, object]: campaign = session.get(Campaign, campaign_id) # type: ignore[attr-defined] if campaign is None or campaign.tenant_id != tenant_id: raise ValueError("Campaign privacy policy not found") payload = dict(settings) campaign.settings = payload session.add(campaign) # type: ignore[attr-defined] return payload def policy_context_capability(context: object) -> CampaignPolicyContextService: return CampaignPolicyContextService() class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider): def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]: from govoplan_campaign.backend.sending.jobs import send_campaign_job return send_campaign_job(session, job_id=job_id, enqueue_imap_task=enqueue_imap_task).as_dict() # type: ignore[arg-type] def append_sent_for_job(self, session: object, *, job_id: str) -> Mapping[str, object]: from govoplan_campaign.backend.sending.jobs import append_sent_for_job return append_sent_for_job(session, job_id=job_id).as_dict() # type: ignore[arg-type] def delivery_tasks_capability(context: object) -> CampaignDeliveryTaskService: from govoplan_campaign.backend.runtime import configure_runtime configure_runtime( registry=getattr(context, "registry", None), settings=getattr(context, "settings", None), ) return CampaignDeliveryTaskService() class CampaignRetentionService(CampaignRetentionProvider): def apply_retention(self, session: object, *, dry_run, now, policy_for_campaign_id): from govoplan_campaign.backend.retention import apply_campaign_retention return apply_campaign_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id) # type: ignore[arg-type] def retention_capability(context: object) -> CampaignRetentionService: return CampaignRetentionService()