refactor(api): split campaign workflow routers
This commit is contained in:
272
src/govoplan_campaign/backend/routes/sharing.py
Normal file
272
src/govoplan_campaign/backend/routes/sharing.py
Normal file
@@ -0,0 +1,272 @@
|
||||
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 -------------------------------------------------
|
||||
Reference in New Issue
Block a user