feat: add campaign copying scheduling and residual handling
This commit is contained in:
@@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignCreateResponse,
|
||||
CampaignCreateMinimalRequest,
|
||||
CampaignCopyRequest,
|
||||
CampaignContentLibrarySaveRequest,
|
||||
CampaignLifecycleMutationRequest,
|
||||
CampaignLifecyclePolicyResponse,
|
||||
CampaignAddressLookupCandidate,
|
||||
@@ -42,6 +43,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.core.templates import TemplateContentDraftRequest, TemplateRef
|
||||
from govoplan_core.core.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
@@ -60,6 +62,7 @@ from govoplan_campaign.backend.change_tracking import (
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
RecipientImportMappingProfile,
|
||||
)
|
||||
@@ -70,6 +73,7 @@ from govoplan_campaign.backend.campaign.lifecycle import (
|
||||
assert_lifecycle_state_token,
|
||||
campaign_lifecycle_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
calendar_integration,
|
||||
PostboxDeliveryUnavailable,
|
||||
@@ -214,6 +218,13 @@ def _campaign_copy_external_id(
|
||||
)
|
||||
|
||||
|
||||
def _campaign_copy_configuration(
|
||||
source: dict[str, object],
|
||||
payload: CampaignCopyRequest,
|
||||
) -> dict[str, object]:
|
||||
return campaign_copy_configuration(source, payload.model_dump())
|
||||
|
||||
|
||||
@router.post("", response_model=CampaignCreateResponse)
|
||||
def create_campaign(
|
||||
payload: CampaignCreateRequest,
|
||||
@@ -924,6 +935,192 @@ def campaign_print_templates(
|
||||
}
|
||||
|
||||
|
||||
def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]:
|
||||
revision = template.revision
|
||||
revision_metadata = dict(revision.metadata) if revision else {}
|
||||
raw_targets = revision_metadata.get("campaign_targets")
|
||||
targets = (
|
||||
[str(value) for value in raw_targets if str(value) in {"subject", "text", "html"}]
|
||||
if isinstance(raw_targets, (list, tuple))
|
||||
else []
|
||||
)
|
||||
if not targets and revision is not None:
|
||||
if template.template_type == "email":
|
||||
targets = ["subject", "text", "html"]
|
||||
else:
|
||||
if revision.content_text:
|
||||
targets.append("text")
|
||||
if revision.content_html:
|
||||
targets.append("html")
|
||||
kind = str(revision_metadata.get("campaign_kind") or "").strip()
|
||||
if kind not in {"fragment", "campaign_part"}:
|
||||
kind = "fragment" if template.template_type == "content_fragment" else "campaign_part"
|
||||
return {
|
||||
"id": template.id,
|
||||
"name": template.name,
|
||||
"description": template.description,
|
||||
"template_type": template.template_type,
|
||||
"kind": kind,
|
||||
"status": template.status,
|
||||
"scope_type": template.scope_type,
|
||||
"scope_id": template.scope_id,
|
||||
"read_only": template.read_only,
|
||||
"current_revision": template.current_revision,
|
||||
"revision": revision.revision if revision else template.current_revision,
|
||||
"revision_id": revision.id if revision else template.current_revision_id,
|
||||
"locale": revision.locale if revision else None,
|
||||
"published": bool(template.published_revision_id),
|
||||
"targets": list(dict.fromkeys(targets)),
|
||||
"subject": revision_metadata.get("campaign_subject"),
|
||||
"text": revision.content_text if revision else None,
|
||||
"html": revision.content_html if revision else None,
|
||||
"body_mode": revision_metadata.get("campaign_body_mode") or "both",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/content-library")
|
||||
def campaign_content_library(
|
||||
campaign_id: str,
|
||||
query: str = Query(default="", min_length=0, max_length=200),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
integration = templates_integration()
|
||||
if not integration.content_available:
|
||||
return {
|
||||
"available": False,
|
||||
"writable": False,
|
||||
"reason": "The Templates content library is not active.",
|
||||
"items": [],
|
||||
}
|
||||
try:
|
||||
templates = integration.list_content_templates(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
limit=250,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
return {
|
||||
"available": True,
|
||||
"writable": False,
|
||||
"reason": str(exc),
|
||||
"items": [],
|
||||
}
|
||||
return {
|
||||
"available": True,
|
||||
"writable": integration.content_writable,
|
||||
"items": [_campaign_content_library_item(template) for template in templates],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/content-library", status_code=status.HTTP_201_CREATED)
|
||||
def save_campaign_content_library_item(
|
||||
campaign_id: str,
|
||||
payload: CampaignContentLibrarySaveRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(
|
||||
session,
|
||||
campaign_id,
|
||||
principal,
|
||||
write=True,
|
||||
)
|
||||
integration = templates_integration()
|
||||
if not integration.content_writable:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="The Templates content-library capability is not active.",
|
||||
)
|
||||
target = payload.target if payload.kind == "fragment" else None
|
||||
content_text = (
|
||||
payload.subject
|
||||
if target == "subject"
|
||||
else payload.text
|
||||
if target == "text"
|
||||
else None
|
||||
)
|
||||
content_html = payload.html if target == "html" else None
|
||||
if payload.kind == "campaign_part":
|
||||
content_text = payload.text
|
||||
content_html = payload.html
|
||||
metadata: dict[str, object] = {
|
||||
"campaign_kind": payload.kind,
|
||||
"campaign_targets": (
|
||||
[target]
|
||||
if target
|
||||
else [
|
||||
field
|
||||
for field, value in (
|
||||
("subject", payload.subject),
|
||||
("text", payload.text),
|
||||
("html", payload.html),
|
||||
)
|
||||
if value and value.strip()
|
||||
]
|
||||
),
|
||||
"campaign_body_mode": payload.body_mode,
|
||||
"source_module": "campaigns",
|
||||
"source_campaign_id": campaign.id,
|
||||
}
|
||||
if payload.kind == "campaign_part" and payload.subject:
|
||||
metadata["campaign_subject"] = payload.subject
|
||||
try:
|
||||
template = integration.create_content_draft(
|
||||
session,
|
||||
principal,
|
||||
request=TemplateContentDraftRequest(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
template_type=(
|
||||
"content_fragment" if payload.kind == "fragment" else "email"
|
||||
),
|
||||
usages=("campaign.content",),
|
||||
content_text=content_text,
|
||||
content_html=content_html,
|
||||
locale=payload.locale,
|
||||
scope_type=("user" if payload.visibility == "personal" else "tenant"),
|
||||
scope_id=(
|
||||
principal.account_id if payload.visibility == "personal" else None
|
||||
),
|
||||
metadata=metadata,
|
||||
),
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.content_library_saved",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
"template_id": template.id,
|
||||
"template_revision_id": (
|
||||
template.revision.id if template.revision else None
|
||||
),
|
||||
"kind": payload.kind,
|
||||
"target": payload.target,
|
||||
"visibility": payload.visibility,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
session.commit()
|
||||
except PermissionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return {"template": _campaign_content_library_item(template)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/recipient-address-sources/snapshot",
|
||||
response_model=CampaignRecipientAddressSourceSnapshotResponse,
|
||||
@@ -1652,7 +1849,10 @@ def copy_campaign(
|
||||
action="copy_campaign",
|
||||
version_id=payload.source_version_id,
|
||||
)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
if payload.include_recipients:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
if payload.include_shares:
|
||||
_require_permission(principal, "campaigns:campaign:share")
|
||||
source_version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
@@ -1674,7 +1874,7 @@ def copy_campaign(
|
||||
requested=payload.external_id,
|
||||
)
|
||||
name = (payload.name or f"{source_campaign.name} (copy)").strip()
|
||||
raw_json = copy.deepcopy(source_version.raw_json)
|
||||
raw_json = _campaign_copy_configuration(source_version.raw_json, payload)
|
||||
campaign_metadata = raw_json.get("campaign")
|
||||
if not isinstance(campaign_metadata, dict):
|
||||
raise HTTPException(
|
||||
@@ -1696,6 +1896,36 @@ def copy_campaign(
|
||||
source_base_path=source_version.source_base_path,
|
||||
commit=False,
|
||||
)
|
||||
if payload.include_policies:
|
||||
campaign.settings = copy.deepcopy(source_campaign.settings or {})
|
||||
if payload.include_mail_profile:
|
||||
campaign.mail_profile_policy = copy.deepcopy(
|
||||
source_campaign.mail_profile_policy or {}
|
||||
)
|
||||
copied_share_count = 0
|
||||
if payload.include_shares:
|
||||
source_shares = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
CampaignShare.campaign_id == source_campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
.order_by(CampaignShare.id.asc())
|
||||
.all()
|
||||
)
|
||||
for source_share in source_shares:
|
||||
session.add(
|
||||
CampaignShare(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
target_type=source_share.target_type,
|
||||
target_id=source_share.target_id,
|
||||
permission=source_share.permission,
|
||||
created_by_user_id=principal.user.id,
|
||||
)
|
||||
)
|
||||
copied_share_count = len(source_shares)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
@@ -1707,6 +1937,14 @@ def copy_campaign(
|
||||
"source_version_id": source_version.id,
|
||||
"destination_version_id": version.id,
|
||||
"copied_evidence": False,
|
||||
"copy_options": {
|
||||
"recipients": payload.include_recipients,
|
||||
"files": payload.include_files,
|
||||
"shares": payload.include_shares,
|
||||
"policies": payload.include_policies,
|
||||
"mail_profile": payload.include_mail_profile,
|
||||
},
|
||||
"copied_share_count": copied_share_count,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.scheduling import (
|
||||
campaign_schedule_source_snapshot,
|
||||
canonical_configuration_hash,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignSchedule,
|
||||
CampaignScheduleOccurrence,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_require_permission,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignScheduleCreateRequest,
|
||||
CampaignScheduleListResponse,
|
||||
CampaignScheduleOccurrenceResponse,
|
||||
CampaignScheduleResponse,
|
||||
CampaignScheduleStateRequest,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaign-schedules"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/schedules",
|
||||
response_model=CampaignScheduleListResponse,
|
||||
)
|
||||
def list_campaign_schedules(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
schedules = (
|
||||
session.query(CampaignSchedule)
|
||||
.filter(
|
||||
CampaignSchedule.tenant_id == principal.tenant_id,
|
||||
CampaignSchedule.campaign_id == campaign_id,
|
||||
)
|
||||
.order_by(CampaignSchedule.created_at.desc(), CampaignSchedule.id.asc())
|
||||
.all()
|
||||
)
|
||||
occurrences = _occurrences_by_schedule(session, schedules)
|
||||
return CampaignScheduleListResponse(
|
||||
items=[
|
||||
_schedule_response(item, occurrences.get(item.id, []))
|
||||
for item in schedules
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/schedules",
|
||||
response_model=CampaignScheduleResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_campaign_schedule(
|
||||
campaign_id: str,
|
||||
payload: CampaignScheduleCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:copy")
|
||||
if payload.include_recipients:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
if payload.include_shares:
|
||||
_require_permission(principal, "campaigns:campaign:share")
|
||||
source_version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
CampaignVersion.id == payload.source_version_id,
|
||||
CampaignVersion.campaign_id == campaign.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if source_version is None:
|
||||
raise HTTPException(status_code=404, detail="Campaign version not found")
|
||||
starts_at = payload.starts_at.astimezone(UTC)
|
||||
if starts_at < datetime.now(UTC) - timedelta(minutes=5):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign schedules cannot start in the past.",
|
||||
)
|
||||
try:
|
||||
ZoneInfo(payload.timezone)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Unknown campaign schedule timezone.",
|
||||
) from exc
|
||||
|
||||
source_shares = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
.order_by(CampaignShare.id.asc())
|
||||
.all()
|
||||
if payload.include_shares
|
||||
else []
|
||||
)
|
||||
snapshot = campaign_schedule_source_snapshot(
|
||||
configuration=source_version.raw_json,
|
||||
campaign_settings=campaign.settings or {},
|
||||
mail_profile_policy=campaign.mail_profile_policy or {},
|
||||
shares=[
|
||||
{
|
||||
"target_type": item.target_type,
|
||||
"target_id": item.target_id,
|
||||
"permission": item.permission,
|
||||
}
|
||||
for item in source_shares
|
||||
],
|
||||
)
|
||||
schedule = CampaignSchedule(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
source_version_id=source_version.id,
|
||||
created_by_user_id=principal.user.id,
|
||||
name=payload.name.strip(),
|
||||
recurrence_kind=payload.recurrence_kind,
|
||||
interval_count=payload.interval_count,
|
||||
timezone=payload.timezone,
|
||||
starts_at=starts_at,
|
||||
next_fire_at=starts_at,
|
||||
ends_at=payload.ends_at.astimezone(UTC) if payload.ends_at else None,
|
||||
max_occurrences=payload.max_occurrences,
|
||||
copy_options={
|
||||
"include_recipients": payload.include_recipients,
|
||||
"include_files": payload.include_files,
|
||||
"include_shares": payload.include_shares,
|
||||
"include_policies": payload.include_policies,
|
||||
"include_mail_profile": payload.include_mail_profile,
|
||||
},
|
||||
source_snapshot=snapshot,
|
||||
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
||||
source_base_path=source_version.source_base_path,
|
||||
)
|
||||
session.add(schedule)
|
||||
session.flush()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.schedule.created",
|
||||
object_type="campaign_schedule",
|
||||
object_id=schedule.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"source_version_id": source_version.id,
|
||||
"recurrence_kind": schedule.recurrence_kind,
|
||||
"interval_count": schedule.interval_count,
|
||||
"starts_at": schedule.starts_at.isoformat(),
|
||||
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
|
||||
"max_occurrences": schedule.max_occurrences,
|
||||
"delivery_started": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(schedule)
|
||||
return _schedule_response(schedule, [])
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{campaign_id}/schedules/{schedule_id}",
|
||||
response_model=CampaignScheduleResponse,
|
||||
)
|
||||
def set_campaign_schedule_state(
|
||||
campaign_id: str,
|
||||
schedule_id: str,
|
||||
payload: CampaignScheduleStateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
schedule = _schedule_for_campaign(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
schedule_id=schedule_id,
|
||||
for_update=True,
|
||||
)
|
||||
if schedule.resource_revision != payload.base_revision:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Campaign schedule changed. Reload it before changing its state.",
|
||||
)
|
||||
if payload.active and schedule.next_fire_at is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A completed campaign schedule cannot be resumed.",
|
||||
)
|
||||
schedule.active = payload.active
|
||||
schedule.last_error = None if payload.active else schedule.last_error
|
||||
schedule.resource_revision += 1
|
||||
session.add(schedule)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=(
|
||||
"campaign.schedule.resumed"
|
||||
if payload.active
|
||||
else "campaign.schedule.paused"
|
||||
),
|
||||
object_type="campaign_schedule",
|
||||
object_id=schedule.id,
|
||||
details={"campaign_id": campaign_id},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(schedule)
|
||||
occurrences = _occurrences_by_schedule(session, [schedule]).get(schedule.id, [])
|
||||
return _schedule_response(schedule, occurrences)
|
||||
|
||||
|
||||
def _schedule_for_campaign(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
schedule_id: str,
|
||||
for_update: bool = False,
|
||||
) -> CampaignSchedule:
|
||||
query = session.query(CampaignSchedule)
|
||||
if for_update:
|
||||
query = query.with_for_update()
|
||||
schedule = (
|
||||
query
|
||||
.filter(
|
||||
CampaignSchedule.id == schedule_id,
|
||||
CampaignSchedule.tenant_id == tenant_id,
|
||||
CampaignSchedule.campaign_id == campaign_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if schedule is None:
|
||||
raise HTTPException(status_code=404, detail="Campaign schedule not found")
|
||||
return schedule
|
||||
|
||||
|
||||
def _occurrences_by_schedule(
|
||||
session: Session,
|
||||
schedules: list[CampaignSchedule],
|
||||
) -> dict[str, list[CampaignScheduleOccurrence]]:
|
||||
ids = [item.id for item in schedules]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.filter(CampaignScheduleOccurrence.schedule_id.in_(ids))
|
||||
.order_by(
|
||||
CampaignScheduleOccurrence.scheduled_for.desc(),
|
||||
CampaignScheduleOccurrence.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grouped: dict[str, list[CampaignScheduleOccurrence]] = {}
|
||||
for row in rows:
|
||||
grouped.setdefault(row.schedule_id, []).append(row)
|
||||
return grouped
|
||||
|
||||
|
||||
def _schedule_response(
|
||||
schedule: CampaignSchedule,
|
||||
occurrences: list[CampaignScheduleOccurrence],
|
||||
) -> CampaignScheduleResponse:
|
||||
response = CampaignScheduleResponse.model_validate(schedule)
|
||||
return response.model_copy(
|
||||
update={
|
||||
"occurrences": [
|
||||
CampaignScheduleOccurrenceResponse.model_validate(item)
|
||||
for item in occurrences
|
||||
]
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user