273 lines
8.9 KiB
Python
273 lines
8.9 KiB
Python
from __future__ import annotations
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
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.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-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 -------------------------------------------------
|