572 lines
20 KiB
Python
572 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import json
|
|
from datetime import UTC, datetime
|
|
from typing import Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy import and_, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_campaign.backend.db.models import (
|
|
Campaign,
|
|
CampaignCollaborationEntry,
|
|
CampaignJob,
|
|
CampaignShare,
|
|
CampaignVersion,
|
|
)
|
|
from govoplan_campaign.backend.path_security import _attachment_rules
|
|
from govoplan_campaign.backend.route_support import (
|
|
_access_directory,
|
|
_get_campaign_for_principal,
|
|
_require_permission,
|
|
)
|
|
from govoplan_campaign.backend.runtime import get_registry
|
|
from govoplan_campaign.backend.schemas import (
|
|
CampaignCollaborationCreateRequest,
|
|
CampaignCollaborationEntryResponse,
|
|
CampaignCollaborationListResponse,
|
|
CampaignCollaborationModerationRequest,
|
|
CampaignCollaborationReferenceInput,
|
|
CampaignCollaborationReferenceResponse,
|
|
)
|
|
from govoplan_core.api.v1.schemas import (
|
|
ReferenceOptionListResponse,
|
|
ReferenceOptionResponse,
|
|
)
|
|
from govoplan_core.audit.logging import audit_from_principal
|
|
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
|
from govoplan_core.core.notifications import (
|
|
NotificationDispatchRequest,
|
|
notification_dispatch_provider,
|
|
)
|
|
from govoplan_core.core.references import (
|
|
access_scope_reference_page,
|
|
access_scope_reference_provider_available,
|
|
)
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_core.security.time import utc_now
|
|
|
|
|
|
router = APIRouter(prefix="/campaigns", tags=["campaign collaboration"])
|
|
|
|
|
|
@router.get(
|
|
"/{campaign_id}/collaboration/mention-options",
|
|
response_model=ReferenceOptionListResponse,
|
|
)
|
|
def search_campaign_collaboration_mentions(
|
|
campaign_id: str,
|
|
q: str = "",
|
|
selected: list[str] = Query(default=[]),
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
cursor: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
|
) -> ReferenceOptionListResponse:
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:campaign:read")
|
|
try:
|
|
page = access_scope_reference_page(
|
|
get_registry(),
|
|
principal,
|
|
scope_type="user",
|
|
reference_kind="membership",
|
|
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
|
|
allowed = [
|
|
option
|
|
for option in page.options
|
|
if _mentioned_user_has_campaign_access(
|
|
session,
|
|
campaign=campaign,
|
|
user_id=option.value,
|
|
)
|
|
]
|
|
return ReferenceOptionListResponse(
|
|
options=[ReferenceOptionResponse(**option.to_dict()) for option in allowed],
|
|
provider_available=access_scope_reference_provider_available(get_registry()),
|
|
next_cursor=page.next_cursor,
|
|
has_more=page.has_more,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/{campaign_id}/collaboration",
|
|
response_model=CampaignCollaborationListResponse,
|
|
)
|
|
def list_campaign_collaboration(
|
|
campaign_id: str,
|
|
limit: int = Query(default=25, ge=1, le=50),
|
|
cursor: str | None = None,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:read")),
|
|
) -> CampaignCollaborationListResponse:
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:campaign:read")
|
|
query = session.query(CampaignCollaborationEntry).filter(
|
|
CampaignCollaborationEntry.tenant_id == principal.tenant_id,
|
|
CampaignCollaborationEntry.campaign_id == campaign.id,
|
|
)
|
|
if not has_scope(principal, "campaigns:discussion:moderate"):
|
|
query = query.filter(CampaignCollaborationEntry.visibility == "collaborators")
|
|
if cursor:
|
|
created_at, entry_id = _decode_cursor(cursor)
|
|
query = query.filter(
|
|
or_(
|
|
CampaignCollaborationEntry.created_at < created_at,
|
|
and_(
|
|
CampaignCollaborationEntry.created_at == created_at,
|
|
CampaignCollaborationEntry.id < entry_id,
|
|
),
|
|
)
|
|
)
|
|
rows = (
|
|
query.order_by(
|
|
CampaignCollaborationEntry.created_at.desc(),
|
|
CampaignCollaborationEntry.id.desc(),
|
|
)
|
|
.limit(limit + 1)
|
|
.all()
|
|
)
|
|
has_more = len(rows) > limit
|
|
items = rows[:limit]
|
|
return CampaignCollaborationListResponse(
|
|
items=[_entry_response(item) for item in items],
|
|
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
|
|
has_more=has_more,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/{campaign_id}/collaboration",
|
|
response_model=CampaignCollaborationEntryResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_campaign_collaboration_entry(
|
|
campaign_id: str,
|
|
payload: CampaignCollaborationCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
|
) -> CampaignCollaborationEntryResponse:
|
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:campaign:read")
|
|
if payload.visibility == "moderators":
|
|
_require_permission(principal, "campaigns:discussion:moderate")
|
|
reference = _validated_reference(
|
|
session,
|
|
campaign=campaign,
|
|
reference=payload.reference,
|
|
)
|
|
mentions = _validated_mentions(
|
|
session,
|
|
campaign=campaign,
|
|
user_ids=payload.mention_user_ids,
|
|
actor_user_id=principal.user.id,
|
|
)
|
|
actor_label = (
|
|
getattr(principal.user, "display_name", None)
|
|
or getattr(principal.user, "email", None)
|
|
or principal.user.id
|
|
)
|
|
entry = CampaignCollaborationEntry(
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign.id,
|
|
campaign_version_id=reference[0] if reference else None,
|
|
reference_kind=reference[1] if reference else None,
|
|
reference_id=reference[2] if reference else None,
|
|
reference_label=reference[3] if reference else None,
|
|
actor_user_id=principal.user.id,
|
|
actor_label_snapshot=str(actor_label)[:255],
|
|
visibility=payload.visibility,
|
|
content=payload.content,
|
|
content_sha256=hashlib.sha256(payload.content.encode("utf-8")).hexdigest(),
|
|
mention_user_ids=mentions,
|
|
)
|
|
session.add(entry)
|
|
session.flush()
|
|
_enqueue_mention_notifications(
|
|
session,
|
|
campaign=campaign,
|
|
entry=entry,
|
|
mention_user_ids=mentions,
|
|
)
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.collaboration.posted",
|
|
object_type="campaign_collaboration_entry",
|
|
object_id=entry.id,
|
|
details={
|
|
"campaign_id": campaign.id,
|
|
"campaign_version_id": entry.campaign_version_id,
|
|
"visibility": entry.visibility,
|
|
"reference_kind": entry.reference_kind,
|
|
"reference_id": entry.reference_id,
|
|
"mention_count": len(mentions),
|
|
"content_sha256": entry.content_sha256,
|
|
"content_disclosed": False,
|
|
},
|
|
commit=True,
|
|
)
|
|
session.refresh(entry)
|
|
return _entry_response(entry)
|
|
|
|
|
|
@router.post(
|
|
"/{campaign_id}/collaboration/{entry_id}/withdraw",
|
|
response_model=CampaignCollaborationEntryResponse,
|
|
)
|
|
def withdraw_campaign_collaboration_entry(
|
|
campaign_id: str,
|
|
entry_id: str,
|
|
payload: CampaignCollaborationModerationRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
|
) -> CampaignCollaborationEntryResponse:
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:campaign:read")
|
|
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
|
|
if entry.actor_user_id != principal.user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Only the author can withdraw this collaboration entry.",
|
|
)
|
|
if entry.redacted_at is not None or entry.withdrawn_at is not None:
|
|
return _entry_response(entry)
|
|
entry.content = None
|
|
entry.withdrawn_at = utc_now()
|
|
entry.withdrawn_by_user_id = principal.user.id
|
|
entry.tombstone_reason = payload.reason
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.collaboration.withdrawn",
|
|
object_type="campaign_collaboration_entry",
|
|
object_id=entry.id,
|
|
details={
|
|
"campaign_id": campaign_id,
|
|
"content_sha256": entry.content_sha256,
|
|
"reason_recorded": bool(payload.reason),
|
|
"content_disclosed": False,
|
|
},
|
|
commit=True,
|
|
)
|
|
session.refresh(entry)
|
|
return _entry_response(entry)
|
|
|
|
|
|
@router.post(
|
|
"/{campaign_id}/collaboration/{entry_id}/redact",
|
|
response_model=CampaignCollaborationEntryResponse,
|
|
)
|
|
def redact_campaign_collaboration_entry(
|
|
campaign_id: str,
|
|
entry_id: str,
|
|
payload: CampaignCollaborationModerationRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:moderate")),
|
|
) -> CampaignCollaborationEntryResponse:
|
|
_get_campaign_for_principal(session, campaign_id, principal)
|
|
_require_permission(principal, "campaigns:campaign:read")
|
|
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
|
|
if entry.redacted_at is not None:
|
|
return _entry_response(entry)
|
|
entry.content = None
|
|
entry.redacted_at = utc_now()
|
|
entry.redacted_by_user_id = principal.user.id
|
|
entry.tombstone_reason = payload.reason
|
|
audit_from_principal(
|
|
session,
|
|
principal,
|
|
action="campaign.collaboration.redacted",
|
|
object_type="campaign_collaboration_entry",
|
|
object_id=entry.id,
|
|
details={
|
|
"campaign_id": campaign_id,
|
|
"content_sha256": entry.content_sha256,
|
|
"reason_recorded": bool(payload.reason),
|
|
"previously_withdrawn": entry.withdrawn_at is not None,
|
|
"content_disclosed": False,
|
|
},
|
|
commit=True,
|
|
)
|
|
session.refresh(entry)
|
|
return _entry_response(entry)
|
|
|
|
|
|
def _entry_for_campaign(
|
|
session: Session,
|
|
*,
|
|
campaign_id: str,
|
|
entry_id: str,
|
|
principal: ApiPrincipal,
|
|
) -> CampaignCollaborationEntry:
|
|
entry = session.get(CampaignCollaborationEntry, entry_id)
|
|
if (
|
|
entry is None
|
|
or entry.tenant_id != principal.tenant_id
|
|
or entry.campaign_id != campaign_id
|
|
):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collaboration entry not found")
|
|
return entry
|
|
|
|
|
|
def _validated_reference(
|
|
session: Session,
|
|
*,
|
|
campaign: Campaign,
|
|
reference: CampaignCollaborationReferenceInput | None,
|
|
) -> tuple[str | None, str, str, str] | None:
|
|
if reference is None:
|
|
return None
|
|
kind = reference.kind
|
|
reference_id = reference.id
|
|
version: CampaignVersion | None = None
|
|
default_label = kind.replace("_", " ").title()
|
|
if kind == "campaign_version":
|
|
version = session.get(CampaignVersion, reference_id)
|
|
if version is not None:
|
|
default_label = f"Version {version.version_number}"
|
|
elif kind == "delivery_job":
|
|
job = session.get(CampaignJob, reference_id)
|
|
if job is None or job.campaign_id != campaign.id or job.tenant_id != campaign.tenant_id:
|
|
raise _invalid_reference()
|
|
version = session.get(CampaignVersion, job.campaign_version_id)
|
|
default_label = f"Delivery job {job.id[:8]}"
|
|
elif kind in {"recipient_import_batch", "attachment_rule"}:
|
|
version_id, separator, child_id = reference_id.partition(":")
|
|
if not separator or not version_id or not child_id:
|
|
raise _invalid_reference()
|
|
version = session.get(CampaignVersion, version_id)
|
|
if version is not None and kind == "recipient_import_batch":
|
|
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
|
entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {}
|
|
imports = entries.get("imports") if isinstance(entries, dict) else []
|
|
if not any(isinstance(item, dict) and str(item.get("id") or "") == child_id for item in imports or []):
|
|
raise _invalid_reference()
|
|
default_label = "Recipient import batch"
|
|
elif version is not None:
|
|
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
|
if child_id not in {path for path, _rule in _attachment_rules(raw)}:
|
|
raise _invalid_reference()
|
|
default_label = "Attachment rule"
|
|
else:
|
|
referenced_campaign_id, separator, remainder = reference_id.partition(":")
|
|
version_id, separator_two, report_kind = remainder.partition(":")
|
|
if (
|
|
not separator
|
|
or not separator_two
|
|
or referenced_campaign_id != campaign.id
|
|
or not version_id
|
|
or not report_kind
|
|
):
|
|
raise _invalid_reference()
|
|
version = session.get(CampaignVersion, version_id)
|
|
default_label = report_kind.replace("_", " ").title()
|
|
if version is None or version.campaign_id != campaign.id:
|
|
raise _invalid_reference()
|
|
return version.id, kind, reference_id, default_label[:255]
|
|
|
|
|
|
def _invalid_reference() -> HTTPException:
|
|
return HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="The collaboration reference is not stable evidence owned by this campaign.",
|
|
)
|
|
|
|
|
|
def _validated_mentions(
|
|
session: Session,
|
|
*,
|
|
campaign: Campaign,
|
|
user_ids: list[str],
|
|
actor_user_id: str,
|
|
) -> list[str]:
|
|
mentions = [user_id for user_id in user_ids if user_id != actor_user_id]
|
|
invalid = [
|
|
user_id
|
|
for user_id in mentions
|
|
if not _mentioned_user_has_campaign_access(
|
|
session,
|
|
campaign=campaign,
|
|
user_id=user_id,
|
|
)
|
|
]
|
|
if invalid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="Mentioned users must be active and already have access to this campaign.",
|
|
)
|
|
return mentions
|
|
|
|
|
|
def _mentioned_user_has_campaign_access(
|
|
session: Session,
|
|
*,
|
|
campaign: Campaign,
|
|
user_id: str,
|
|
) -> bool:
|
|
directory = _access_directory()
|
|
user = next(
|
|
(candidate for candidate in directory.users_for_tenant(campaign.tenant_id) if candidate.id == user_id),
|
|
None,
|
|
)
|
|
if user is None or user.status != "active":
|
|
return False
|
|
if campaign.owner_user_id == user_id:
|
|
return True
|
|
group_ids = {
|
|
group.id
|
|
for group in directory.groups_for_user(user_id, tenant_id=campaign.tenant_id)
|
|
}
|
|
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
|
|
return True
|
|
clauses = [
|
|
and_(
|
|
CampaignShare.target_type == "user",
|
|
CampaignShare.target_id == user_id,
|
|
)
|
|
]
|
|
if group_ids:
|
|
clauses.append(
|
|
and_(
|
|
CampaignShare.target_type == "group",
|
|
CampaignShare.target_id.in_(sorted(group_ids)),
|
|
)
|
|
)
|
|
return (
|
|
session.query(CampaignShare.id)
|
|
.filter(
|
|
CampaignShare.tenant_id == campaign.tenant_id,
|
|
CampaignShare.campaign_id == campaign.id,
|
|
CampaignShare.revoked_at.is_(None),
|
|
or_(*clauses),
|
|
)
|
|
.first()
|
|
is not None
|
|
)
|
|
|
|
|
|
def _enqueue_mention_notifications(
|
|
session: Session,
|
|
*,
|
|
campaign: Campaign,
|
|
entry: CampaignCollaborationEntry,
|
|
mention_user_ids: list[str],
|
|
) -> None:
|
|
provider = notification_dispatch_provider(get_registry())
|
|
if provider is None or not mention_user_ids:
|
|
return
|
|
try:
|
|
with session.begin_nested():
|
|
for user_id in mention_user_ids:
|
|
provider.enqueue_notification(
|
|
session,
|
|
NotificationDispatchRequest(
|
|
tenant_id=campaign.tenant_id,
|
|
source_module="campaigns",
|
|
source_resource_type="campaign_collaboration_entry",
|
|
source_resource_id=entry.id,
|
|
event_kind="campaign.collaboration.mentioned",
|
|
channel="inbox",
|
|
recipient_type="user",
|
|
recipient_id=user_id,
|
|
subject=f"Mentioned in campaign: {campaign.name}",
|
|
body_text=(
|
|
f"{entry.actor_label_snapshot} mentioned you in the campaign collaboration thread."
|
|
),
|
|
action_url=f"/campaigns/{campaign.id}/activity",
|
|
payload={
|
|
"campaign_id": campaign.id,
|
|
"entry_id": entry.id,
|
|
"content_disclosed": False,
|
|
},
|
|
),
|
|
enqueue_delivery=False,
|
|
)
|
|
except Exception:
|
|
# Collaboration remains available when the optional Notifications
|
|
# provider is absent or temporarily unhealthy.
|
|
return
|
|
|
|
|
|
def _entry_response(entry: CampaignCollaborationEntry) -> CampaignCollaborationEntryResponse:
|
|
tombstone: Literal["withdrawn", "redacted"] | None = None
|
|
if entry.redacted_at is not None:
|
|
tombstone = "redacted"
|
|
elif entry.withdrawn_at is not None:
|
|
tombstone = "withdrawn"
|
|
reference = None
|
|
if entry.reference_kind and entry.reference_id:
|
|
reference = CampaignCollaborationReferenceResponse(
|
|
kind=entry.reference_kind, # type: ignore[arg-type]
|
|
id=entry.reference_id,
|
|
label=entry.reference_label,
|
|
)
|
|
return CampaignCollaborationEntryResponse(
|
|
id=entry.id,
|
|
campaign_id=entry.campaign_id,
|
|
actor_user_id=entry.actor_user_id,
|
|
actor_label=entry.actor_label_snapshot,
|
|
visibility=entry.visibility, # type: ignore[arg-type]
|
|
content=entry.content if tombstone is None else None,
|
|
content_sha256=entry.content_sha256,
|
|
mention_user_ids=list(entry.mention_user_ids or []),
|
|
reference=reference,
|
|
tombstone=tombstone,
|
|
tombstone_reason=entry.tombstone_reason,
|
|
withdrawn_at=entry.withdrawn_at,
|
|
redacted_at=entry.redacted_at,
|
|
created_at=entry.created_at,
|
|
)
|
|
|
|
|
|
def _encode_cursor(entry: CampaignCollaborationEntry) -> str:
|
|
created_at = entry.created_at
|
|
if created_at.tzinfo is None:
|
|
# SQLite returns timezone-aware columns as naive UTC values.
|
|
created_at = created_at.replace(tzinfo=UTC)
|
|
payload = json.dumps(
|
|
{"created_at": created_at.astimezone(UTC).isoformat(), "id": entry.id},
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
|
|
|
|
|
def _decode_cursor(value: str) -> tuple[datetime, str]:
|
|
try:
|
|
padded = value + "=" * (-len(value) % 4)
|
|
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
|
created_at = datetime.fromisoformat(str(payload["created_at"]))
|
|
entry_id = str(payload["id"])
|
|
if created_at.tzinfo is None or not entry_id or len(entry_id) > 36:
|
|
raise ValueError
|
|
return created_at, entry_id
|
|
except (
|
|
KeyError,
|
|
TypeError,
|
|
ValueError,
|
|
UnicodeDecodeError,
|
|
json.JSONDecodeError,
|
|
binascii.Error,
|
|
) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="Invalid collaboration cursor.",
|
|
) from exc
|