from __future__ import annotations from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.api.v1.schemas import ( ReferenceOptionListResponse, ReferenceOptionResponse, ) from govoplan_campaign.backend.schemas import ( CampaignShareItem, CampaignShareListResponse, CampaignShareTargetItem, CampaignShareTargetsResponse, CampaignShareUpsertRequest, CampaignOwnerUpdateRequest, CampaignResponse, ) from govoplan_core.auth import ApiPrincipal, require_scope from govoplan_core.audit.logging import audit_from_principal from govoplan_campaign.backend.db.models import ( CampaignShare, ) from govoplan_core.db.session import get_session from govoplan_core.core.references import ( access_scope_reference_page, access_scope_reference_provider_available, ) from govoplan_core.core.runtime import get_registry from govoplan_core.security.time import utc_now from govoplan_campaign.backend.route_support import ( _access_directory, _clear_current_version_mail_profile_for_owner_transfer, _get_campaign_for_principal, ) router = APIRouter(prefix="/campaigns", tags=["campaigns"]) @router.get( "/{campaign_id}/share-target-options", response_model=ReferenceOptionListResponse, ) def search_campaign_share_targets( campaign_id: str, target_type: Literal["user", "group"], q: str = "", selected: list[str] = Query(default=[]), limit: int = Query(default=50, ge=1, le=200), cursor: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), ) -> ReferenceOptionListResponse: _get_campaign_for_principal(session, campaign_id, principal, write=True) registry = get_registry() try: page = access_scope_reference_page( registry, principal, scope_type=target_type, reference_kind="membership" if target_type == "user" else "group", query=q, selected_values=selected, limit=limit, cursor=cursor, administrative=True, session=session, ) except ValueError as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc return ReferenceOptionListResponse( options=[ ReferenceOptionResponse(**option.to_dict()) for option in page.options ], provider_available=access_scope_reference_provider_available(registry), next_cursor=page.next_cursor, has_more=page.has_more, ) @router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse) def list_campaign_share_targets( campaign_id: str, limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) directory = _access_directory() users = [ user for user in directory.users_for_tenant(principal.tenant_id) if user.status == "active" ] groups = [ group for group in directory.groups_for_tenant(principal.tenant_id) if group.status == "active" ] if len(users) > limit or len(groups) > limit: raise HTTPException( status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=( f"Campaign share targets exceed the maximum response size of {limit} " "users or groups. Use a searchable directory selector." ), ) return CampaignShareTargetsResponse( users=[ CampaignShareTargetItem( id=item.id, name=item.display_name or item.email, secondary=item.email ) for item in users ], groups=[ CampaignShareTargetItem(id=item.id, name=item.name, secondary=None) for item in groups ], ) @router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse) def list_campaign_shares( campaign_id: str, page: int = Query(default=1, ge=1), page_size: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), ): campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) query = session.query(CampaignShare).filter( CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None), ) total = query.order_by(None).count() pages = max(1, (total + page_size - 1) // page_size) shares = ( query.order_by( CampaignShare.target_type.asc(), CampaignShare.target_id.asc(), CampaignShare.id.asc(), ) .offset((page - 1) * page_size) .limit(page_size) .all() ) return CampaignShareListResponse( shares=[CampaignShareItem.model_validate(item) for item in shares], total=total, page=page, page_size=page_size, pages=pages, ) @router.put("/{campaign_id}/owner", response_model=CampaignResponse) def update_campaign_owner( campaign_id: str, payload: CampaignOwnerUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), ): campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) if payload.owner_user_id and payload.owner_group_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Choose either a user owner or a group owner, not both", ) directory = _access_directory() if payload.owner_user_id: owner = directory.get_user(payload.owner_user_id) if owner is not None and ( owner.tenant_id != principal.tenant_id or owner.status != "active" ): owner = None if owner is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found" ) if payload.owner_group_id: group = directory.get_group(payload.owner_group_id) if group is not None and ( group.tenant_id != principal.tenant_id or group.status != "active" ): group = None if group is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found" ) owner_changed = ( campaign.owner_user_id != payload.owner_user_id or campaign.owner_group_id != payload.owner_group_id ) mail_profile_reselection_required = False if owner_changed: mail_profile_reselection_required = ( _clear_current_version_mail_profile_for_owner_transfer(session, campaign) ) campaign.owner_user_id = payload.owner_user_id campaign.owner_group_id = payload.owner_group_id session.add(campaign) audit_from_principal( session, principal, action="campaign.owner_updated", object_type="campaign", object_id=campaign.id, details={ **payload.model_dump(), "mail_profile_reselection_required": mail_profile_reselection_required, }, commit=True, ) return CampaignResponse.model_validate(campaign) @router.post( "/{campaign_id}/shares", response_model=CampaignShareItem, status_code=status.HTTP_201_CREATED, ) def upsert_campaign_share( campaign_id: str, payload: CampaignShareUpsertRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), ): campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) directory = _access_directory() if payload.target_type == "user": target = directory.get_user(payload.target_id) if target is not None and ( target.tenant_id != principal.tenant_id or target.status != "active" ): target = None else: target = directory.get_group(payload.target_id) if target is not None and ( target.tenant_id != principal.tenant_id or target.status != "active" ): target = None if target is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found" ) share = ( session.query(CampaignShare) .filter( CampaignShare.campaign_id == campaign.id, CampaignShare.target_type == payload.target_type, CampaignShare.target_id == payload.target_id, ) .one_or_none() ) if share is None: share = CampaignShare( tenant_id=principal.tenant_id, campaign_id=campaign.id, target_type=payload.target_type, target_id=payload.target_id, permission=payload.permission, created_by_user_id=principal.user.id, ) else: share.permission = payload.permission share.revoked_at = None session.add(share) audit_from_principal( session, principal, action="campaign.share_upserted", object_type="campaign", object_id=campaign.id, details=payload.model_dump(), commit=True, ) return CampaignShareItem.model_validate(share) @router.delete( "/{campaign_id}/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT ) def revoke_campaign_share( campaign_id: str, share_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), ): campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) share = ( session.query(CampaignShare) .filter( CampaignShare.id == share_id, CampaignShare.campaign_id == campaign.id, CampaignShare.tenant_id == principal.tenant_id, ) .one_or_none() ) if share is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Campaign share not found" ) share.revoked_at = utc_now() session.add(share) audit_from_principal( session, principal, action="campaign.share_revoked", object_type="campaign", object_id=campaign.id, details={"share_id": share_id}, commit=True, ) return None # Queue / delivery control -------------------------------------------------