|
# retoor <retoor@molodetz.nl>
|
|
|
|
from .._shared import endpoint, field
|
|
from devplacepy.database.moderation import (
|
|
CONSENT_KINDS,
|
|
MODERATION_ACTIONS,
|
|
REPORTABLE_TARGETS,
|
|
REPORT_REASONS,
|
|
REPORT_STATUSES,
|
|
)
|
|
|
|
REPORT_TARGETS = list(REPORTABLE_TARGETS)
|
|
REASON_KEYS = list(REPORT_REASONS)
|
|
CONSENT_KEYS = list(CONSENT_KINDS)
|
|
|
|
SAMPLE_REPORT = {
|
|
"uid": "REPORT_UID",
|
|
"target_type": "post",
|
|
"target_uid": "POST_UID",
|
|
"target_url": "/posts/a-post",
|
|
"reason": "harassment",
|
|
"reason_label": "Harassment or bullying",
|
|
"detail": "Repeated personal attacks in the thread.",
|
|
"severity": "warn",
|
|
"status": "open",
|
|
"origin": "member",
|
|
"categories": [],
|
|
"created_at": "2026-01-05T10:00:00+00:00",
|
|
"resolved_at": "",
|
|
"reporter_name": "alice",
|
|
"owner_name": "bob",
|
|
"report_count": 2,
|
|
}
|
|
|
|
GROUP = {
|
|
"slug": "moderation",
|
|
"title": "Reporting & Moderation",
|
|
"intro": """
|
|
# Reporting & Moderation
|
|
|
|
Every externally visible surface on DevPlace is reportable through one polymorphic
|
|
endpoint, and every report lands in one queue with one state machine. The reason
|
|
list is served by `GET /reports/reasons`, so a native client renders the same
|
|
dialog the web UI does.
|
|
|
|
DevPlace commits to reviewing every report within the window published on the
|
|
[content moderation](/docs/content-moderation.html) page. Filing a report always
|
|
returns an acknowledgement carrying that window.
|
|
|
|
The moderation endpoints under `/admin/moderation` are administrator-only and are
|
|
subject to the admin seniority rule: a junior administrator cannot action a more
|
|
senior one.
|
|
|
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
|
four ways to sign requests.
|
|
""",
|
|
"endpoints": [
|
|
endpoint(
|
|
id="report-reasons",
|
|
method="GET",
|
|
path="/reports/reasons",
|
|
title="List report reasons",
|
|
summary="The reason keys a report may be filed under, with their labels.",
|
|
auth="public",
|
|
sample_response={
|
|
"reasons": [{"key": "harassment", "label": "Harassment or bullying"}],
|
|
"severities": ["info", "warn", "critical"],
|
|
},
|
|
),
|
|
endpoint(
|
|
id="report-create",
|
|
method="POST",
|
|
path="/reports/{target_type}/{target_uid}",
|
|
title="Report content",
|
|
summary="File a report against any user-generated surface.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=False,
|
|
params=[
|
|
field(
|
|
"target_type",
|
|
"path",
|
|
"enum",
|
|
True,
|
|
"post",
|
|
"The kind of content being reported.",
|
|
REPORT_TARGETS,
|
|
),
|
|
field(
|
|
"target_uid",
|
|
"path",
|
|
"string",
|
|
True,
|
|
"POST_UID",
|
|
"UID of the reported item.",
|
|
),
|
|
field(
|
|
"reason",
|
|
"form",
|
|
"enum",
|
|
True,
|
|
"harassment",
|
|
"Why the content breaks the guidelines.",
|
|
REASON_KEYS,
|
|
),
|
|
field(
|
|
"detail",
|
|
"form",
|
|
"string",
|
|
False,
|
|
"",
|
|
"Free text for the moderator, up to 2000 characters.",
|
|
),
|
|
],
|
|
notes=[
|
|
"A second report on the same target by the same reporter updates the "
|
|
"open report instead of creating a duplicate.",
|
|
"You cannot report your own content.",
|
|
],
|
|
sample_response={
|
|
"ok": True,
|
|
"redirect": "/reports/mine",
|
|
"data": {
|
|
"uid": "REPORT_UID",
|
|
"status": "open",
|
|
"severity": "warn",
|
|
"sla_hours": 24,
|
|
},
|
|
},
|
|
),
|
|
endpoint(
|
|
id="reports-mine",
|
|
method="GET",
|
|
path="/reports/mine",
|
|
title="List your reports",
|
|
summary="The reports you filed and the outcome of each.",
|
|
auth="user",
|
|
params=[
|
|
field(
|
|
"status",
|
|
"query",
|
|
"enum",
|
|
False,
|
|
"open",
|
|
"Filter by report status.",
|
|
list(REPORT_STATUSES),
|
|
),
|
|
field("page", "query", "integer", False, "1", "Page number."),
|
|
],
|
|
sample_response={
|
|
"reports": [SAMPLE_REPORT],
|
|
"pagination": {"page": 1, "total": 1, "total_pages": 1},
|
|
"status": "",
|
|
},
|
|
),
|
|
endpoint(
|
|
id="admin-moderation",
|
|
method="GET",
|
|
path="/admin/moderation",
|
|
title="The moderation queue",
|
|
summary="Reported content awaiting a decision, oldest open first.",
|
|
auth="admin",
|
|
params=[
|
|
field(
|
|
"status",
|
|
"query",
|
|
"enum",
|
|
False,
|
|
"open",
|
|
"Filter by report status.",
|
|
list(REPORT_STATUSES),
|
|
),
|
|
field("page", "query", "integer", False, "1", "Page number."),
|
|
],
|
|
sample_response={
|
|
"reports": [SAMPLE_REPORT],
|
|
"counts": {"open": 1, "acknowledged": 0, "actioned": 0, "dismissed": 0},
|
|
"sla": {
|
|
"sla_hours": 24,
|
|
"oldest_open_hours": 1.5,
|
|
"breached": 0,
|
|
"within_sla": True,
|
|
},
|
|
},
|
|
),
|
|
endpoint(
|
|
id="admin-moderation-report",
|
|
method="GET",
|
|
path="/admin/moderation/{uid}",
|
|
title="Read one report",
|
|
summary="One report with its decisions and the author's history.",
|
|
auth="admin",
|
|
params=[
|
|
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
|
|
],
|
|
sample_response={
|
|
"report": SAMPLE_REPORT,
|
|
"actions": [],
|
|
"history": [],
|
|
"available_actions": list(MODERATION_ACTIONS),
|
|
"can_remove": True,
|
|
},
|
|
),
|
|
endpoint(
|
|
id="admin-moderation-status",
|
|
method="POST",
|
|
path="/admin/moderation/{uid}/status",
|
|
title="Set a report status",
|
|
summary="Move a report through the triage state machine.",
|
|
auth="admin",
|
|
encoding="form",
|
|
params=[
|
|
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
|
|
field(
|
|
"status",
|
|
"form",
|
|
"enum",
|
|
True,
|
|
"acknowledged",
|
|
"New status.",
|
|
list(REPORT_STATUSES),
|
|
),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/admin/moderation/REPORT_UID"},
|
|
),
|
|
endpoint(
|
|
id="admin-moderation-decide",
|
|
method="POST",
|
|
path="/admin/moderation/{uid}/decide",
|
|
title="Decide a report",
|
|
summary="Apply a moderation decision and notify the affected user.",
|
|
auth="admin",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("uid", "path", "string", True, "REPORT_UID", "Report UID."),
|
|
field(
|
|
"action",
|
|
"form",
|
|
"enum",
|
|
True,
|
|
"dismiss",
|
|
"The decision to apply.",
|
|
list(MODERATION_ACTIONS),
|
|
),
|
|
field(
|
|
"reason",
|
|
"form",
|
|
"string",
|
|
False,
|
|
"",
|
|
"Reason shown to the affected user.",
|
|
),
|
|
field("notes", "form", "string", False, "", "Internal notes."),
|
|
field(
|
|
"duration_hours",
|
|
"form",
|
|
"integer",
|
|
False,
|
|
"24",
|
|
"Suspension length in hours.",
|
|
),
|
|
],
|
|
notes=[
|
|
"A report already resolved by another moderator answers 409.",
|
|
"Content removal is unavailable for targets that have no removal "
|
|
"path (direct messages, accounts, workspaces, polls, assistant "
|
|
"output); act on the account instead.",
|
|
],
|
|
sample_response={"ok": True, "redirect": "/admin/moderation/REPORT_UID"},
|
|
),
|
|
endpoint(
|
|
id="admin-user-suspend",
|
|
method="POST",
|
|
path="/admin/users/{uid}/suspend",
|
|
title="Suspend an account",
|
|
summary="Suspend an account for a fixed period with a stated reason.",
|
|
auth="admin",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("uid", "path", "string", True, "USER_UID", "User UID."),
|
|
field("reason", "form", "string", False, "", "Reason shown to the user."),
|
|
field(
|
|
"duration_hours",
|
|
"form",
|
|
"integer",
|
|
False,
|
|
"24",
|
|
"Suspension length in hours.",
|
|
),
|
|
],
|
|
notes=[
|
|
"A suspended account can still read, still see why, and still delete "
|
|
"itself, but cannot create content.",
|
|
],
|
|
sample_response={"ok": True, "redirect": "/admin/users"},
|
|
),
|
|
endpoint(
|
|
id="admin-user-lift",
|
|
method="POST",
|
|
path="/admin/users/{uid}/lift",
|
|
title="Lift a restriction",
|
|
summary="Clear a suspension or ban and restore the account.",
|
|
auth="admin",
|
|
encoding="form",
|
|
params=[field("uid", "path", "string", True, "USER_UID", "User UID.")],
|
|
sample_response={"ok": True, "redirect": "/admin/users"},
|
|
),
|
|
endpoint(
|
|
id="admin-user-ban",
|
|
method="POST",
|
|
path="/admin/users/{uid}/ban",
|
|
title="Ban an account",
|
|
summary="Permanently close an account and revoke every credential.",
|
|
auth="admin",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("uid", "path", "string", True, "USER_UID", "User UID."),
|
|
field("reason", "form", "string", False, "", "Reason shown to the user."),
|
|
],
|
|
sample_response={"ok": True, "redirect": "/admin/users"},
|
|
),
|
|
endpoint(
|
|
id="auth-accept-terms-page",
|
|
method="GET",
|
|
path="/auth/accept-terms",
|
|
title="The terms acceptance page",
|
|
summary="The Terms of Service version in force and the version this account accepted.",
|
|
auth="user",
|
|
sample_response={"terms_version": "1", "accepted_version": ""},
|
|
),
|
|
endpoint(
|
|
id="auth-accept-terms",
|
|
method="POST",
|
|
path="/auth/accept-terms",
|
|
title="Accept the terms",
|
|
summary="Record acceptance of the Terms of Service version in force.",
|
|
auth="user",
|
|
encoding="form",
|
|
notes=[
|
|
"A member whose accepted version is behind the version in force is "
|
|
"redirected here on any mutating request. Reading, the docs, the "
|
|
"safety controls and account deletion are never blocked.",
|
|
],
|
|
sample_response={"ok": True, "redirect": "/feed", "data": {"terms_version": "1"}},
|
|
),
|
|
endpoint(
|
|
id="profile-consent",
|
|
method="POST",
|
|
path="/profile/{username}/consent",
|
|
title="Grant or withdraw a consent",
|
|
summary="Change one consent on your own account. Withdrawal takes effect at once.",
|
|
auth="user",
|
|
encoding="form",
|
|
params=[
|
|
field("username", "path", "string", True, "USERNAME", "Your own username."),
|
|
field(
|
|
"kind",
|
|
"form",
|
|
"enum",
|
|
True,
|
|
"ai_third_party",
|
|
"Consent to change.",
|
|
CONSENT_KEYS,
|
|
),
|
|
field("granted", "form", "enum", True, "1", "1 grants, 0 withdraws.", ["1", "0"]),
|
|
],
|
|
notes=[
|
|
"Withdrawing `ai_third_party` makes the AI gateway refuse every call "
|
|
"that would send your own content to the provider, whatever the "
|
|
"per-feature preference says.",
|
|
"Withdrawing `activity_recording` stops presence writes; you simply "
|
|
"appear offline.",
|
|
"Only the account holder can change a consent. An administrator "
|
|
"reads the record but never grants or withdraws it for someone else.",
|
|
],
|
|
sample_response={
|
|
"ok": True,
|
|
"redirect": "/profile/USERNAME?tab=privacy",
|
|
"data": {"kind": "ai_third_party", "state": "granted"},
|
|
},
|
|
),
|
|
endpoint(
|
|
id="profile-mature-content",
|
|
method="POST",
|
|
path="/profile/{username}/mature-content",
|
|
title="Set the mature-content preference",
|
|
summary="Show or hide content labelled mature for your own account.",
|
|
auth="user",
|
|
encoding="form",
|
|
params=[
|
|
field("username", "path", "string", True, "USERNAME", "Your own username."),
|
|
field(
|
|
"mature_opt_in",
|
|
"form",
|
|
"enum",
|
|
True,
|
|
"1",
|
|
"1 shows mature content, 0 hides it.",
|
|
["1", "0"],
|
|
),
|
|
],
|
|
notes=[
|
|
"Only the account holder can change this preference. An administrator "
|
|
"reads the privacy tab but never sets it for someone else.",
|
|
],
|
|
sample_response={
|
|
"ok": True,
|
|
"redirect": "/profile/USERNAME?tab=privacy",
|
|
"data": {"mature_opt_in": True},
|
|
},
|
|
),
|
|
endpoint(
|
|
id="profile-delete",
|
|
method="GET",
|
|
path="/profile/{username}/delete",
|
|
title="Account deletion page",
|
|
summary="What deletion removes, what is retained, and the grace window.",
|
|
auth="user",
|
|
params=[
|
|
field("username", "path", "string", True, "USERNAME", "Your own username."),
|
|
],
|
|
sample_response={
|
|
"username": "USERNAME",
|
|
"grace_hours": 24,
|
|
"removed": ["Your account record, username, email address and password"],
|
|
"retained": ["Append-only audit and moderation records"],
|
|
},
|
|
),
|
|
endpoint(
|
|
id="profile-delete-confirm",
|
|
method="POST",
|
|
path="/profile/{username}/delete",
|
|
title="Delete your account",
|
|
summary="Permanently delete your account and personal data.",
|
|
auth="user",
|
|
encoding="form",
|
|
destructive=True,
|
|
params=[
|
|
field("username", "path", "string", True, "USERNAME", "Your own username."),
|
|
field("password", "form", "string", True, "PASSWORD", "Your account password."),
|
|
],
|
|
notes=[
|
|
"Only the account holder can delete an account; an administrator uses "
|
|
"a ban instead.",
|
|
"Sessions and tokens are revoked and the profile is anonymised "
|
|
"immediately; the deletion event is purged after the grace window.",
|
|
],
|
|
sample_response={
|
|
"ok": True,
|
|
"redirect": "/",
|
|
"data": {"stamp": "2026-01-05T10:00:00+00:00", "rows": 42, "grace_hours": 24},
|
|
},
|
|
),
|
|
endpoint(
|
|
id="workspaces-index",
|
|
method="GET",
|
|
path="/workspaces/index",
|
|
title="Published workspace index",
|
|
summary="Every workspace published to the public ingress, with its link.",
|
|
auth="public",
|
|
params=[field("page", "query", "integer", False, "1", "Page number.")],
|
|
notes=[
|
|
"The project-derived `description` and `project_url` come back empty "
|
|
"unless you may view the workspace's project, so a private project "
|
|
"never leaks its title or description through this public listing.",
|
|
],
|
|
sample_response={
|
|
"workspaces": [
|
|
{
|
|
"uid": "INSTANCE_UID",
|
|
"name": "demo",
|
|
"slug": "demo",
|
|
"owner_uid": "USER_UID",
|
|
"url": "{{ base }}/p/demo",
|
|
"description": "A demo workspace.",
|
|
"owner": "alice",
|
|
"maturity": "general",
|
|
"project_url": "/projects/demo",
|
|
}
|
|
],
|
|
"total": 1,
|
|
},
|
|
),
|
|
],
|
|
}
|