stapel-moderation 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stapel_moderation-0.1.0/CONFIG.MD +126 -0
- stapel_moderation-0.1.0/LICENSE +21 -0
- stapel_moderation-0.1.0/PKG-INFO +221 -0
- stapel_moderation-0.1.0/README.md +193 -0
- stapel_moderation-0.1.0/__init__.py +48 -0
- stapel_moderation-0.1.0/_capabilities.py +61 -0
- stapel_moderation-0.1.0/_codegen.py +117 -0
- stapel_moderation-0.1.0/_codegen_settings.py +130 -0
- stapel_moderation-0.1.0/actions.py +301 -0
- stapel_moderation-0.1.0/admin.py +108 -0
- stapel_moderation-0.1.0/apps.py +41 -0
- stapel_moderation-0.1.0/authz.py +253 -0
- stapel_moderation-0.1.0/checks.py +319 -0
- stapel_moderation-0.1.0/codegen_urls.py +15 -0
- stapel_moderation-0.1.0/conf.py +175 -0
- stapel_moderation-0.1.0/conftest.py +327 -0
- stapel_moderation-0.1.0/docs/capabilities.json +612 -0
- stapel_moderation-0.1.0/docs/errors.json +582 -0
- stapel_moderation-0.1.0/docs/flows.json +1 -0
- stapel_moderation-0.1.0/docs/llms.txt +284 -0
- stapel_moderation-0.1.0/docs/schema.json +1739 -0
- stapel_moderation-0.1.0/dto.py +97 -0
- stapel_moderation-0.1.0/errors.py +132 -0
- stapel_moderation-0.1.0/events.py +264 -0
- stapel_moderation-0.1.0/functions.py +173 -0
- stapel_moderation-0.1.0/gdpr.py +116 -0
- stapel_moderation-0.1.0/migrations/0001_initial.py +193 -0
- stapel_moderation-0.1.0/migrations/__init__.py +0 -0
- stapel_moderation-0.1.0/models.py +519 -0
- stapel_moderation-0.1.0/notifications.py +245 -0
- stapel_moderation-0.1.0/presenters.py +496 -0
- stapel_moderation-0.1.0/projections.py +68 -0
- stapel_moderation-0.1.0/prompts.py +123 -0
- stapel_moderation-0.1.0/py.typed +0 -0
- stapel_moderation-0.1.0/pyproject.toml +94 -0
- stapel_moderation-0.1.0/registry.py +475 -0
- stapel_moderation-0.1.0/schemas/consumes/moderation.applied.json +24 -0
- stapel_moderation-0.1.0/schemas/consumes/staff.role.revoked.json +18 -0
- stapel_moderation-0.1.0/schemas/consumes/task.failed.json +25 -0
- stapel_moderation-0.1.0/schemas/consumes/user.deleted.json +15 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.appeal.opened.json +26 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.appeal.resolved.json +30 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.case.opened.json +46 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.case.queued.json +28 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.completed.json +57 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.report.received.json +33 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.report.reviewed.json +31 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.sanction.expired.json +29 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.sanction.issued.json +61 -0
- stapel_moderation-0.1.0/schemas/emits/moderation.sanction.lifted.json +29 -0
- stapel_moderation-0.1.0/schemas/functions/moderation.case_status.json +19 -0
- stapel_moderation-0.1.0/schemas/functions/moderation.check_sanctions.json +19 -0
- stapel_moderation-0.1.0/schemas/functions/moderation.policy_disclosure.json +18 -0
- stapel_moderation-0.1.0/schemas/functions/moderation.sanctions_by_users.json +18 -0
- stapel_moderation-0.1.0/schemas/functions/moderation.sanctions_export.json +25 -0
- stapel_moderation-0.1.0/schemas/functions/moderation.submit.json +36 -0
- stapel_moderation-0.1.0/screening.py +280 -0
- stapel_moderation-0.1.0/serializers.py +239 -0
- stapel_moderation-0.1.0/services.py +1360 -0
- stapel_moderation-0.1.0/setup.cfg +4 -0
- stapel_moderation-0.1.0/stapel_moderation.egg-info/PKG-INFO +221 -0
- stapel_moderation-0.1.0/stapel_moderation.egg-info/SOURCES.txt +142 -0
- stapel_moderation-0.1.0/stapel_moderation.egg-info/dependency_links.txt +1 -0
- stapel_moderation-0.1.0/stapel_moderation.egg-info/requires.txt +3 -0
- stapel_moderation-0.1.0/stapel_moderation.egg-info/top_level.txt +1 -0
- stapel_moderation-0.1.0/tasks.py +389 -0
- stapel_moderation-0.1.0/tests/test_api.py +439 -0
- stapel_moderation-0.1.0/tests/test_appeals.py +185 -0
- stapel_moderation-0.1.0/tests/test_comm.py +317 -0
- stapel_moderation-0.1.0/tests/test_contract.py +291 -0
- stapel_moderation-0.1.0/tests/test_gdpr.py +238 -0
- stapel_moderation-0.1.0/tests/test_intake.py +182 -0
- stapel_moderation-0.1.0/tests/test_notifications.py +275 -0
- stapel_moderation-0.1.0/tests/test_public_api.py +55 -0
- stapel_moderation-0.1.0/tests/test_queue.py +282 -0
- stapel_moderation-0.1.0/tests/test_registry.py +300 -0
- stapel_moderation-0.1.0/tests/test_sanctions.py +360 -0
- stapel_moderation-0.1.0/tests/test_screening.py +374 -0
- stapel_moderation-0.1.0/translations/errors.es.json +29 -0
- stapel_moderation-0.1.0/translations/errors.ru.json +29 -0
- stapel_moderation-0.1.0/urls.py +16 -0
- stapel_moderation-0.1.0/urls_v1.py +89 -0
- stapel_moderation-0.1.0/views.py +760 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# CONFIG.MD — stapel-moderation
|
|
2
|
+
|
|
3
|
+
Config registry for **stapel-moderation** (`static-scaffold-and-config.md`
|
|
4
|
+
§2). One row per configuration key the module reads, its **source** (`env` =
|
|
5
|
+
the process environment / the `STAPEL_MODERATION` settings dict), what it is
|
|
6
|
+
for, whether it is required, and its default.
|
|
7
|
+
|
|
8
|
+
All keys are read through `moderation_settings`
|
|
9
|
+
(`stapel_moderation.conf.AppSettings`, namespace `STAPEL_MODERATION`).
|
|
10
|
+
Resolution order per key: `settings.STAPEL_MODERATION` dict → a flat Django
|
|
11
|
+
setting of the same name → environment variable → the default below.
|
|
12
|
+
|
|
13
|
+
Two notes on the `env` column, which is the manifest's vocabulary for "the
|
|
14
|
+
process environment or the `STAPEL_MODERATION` settings dict" rather than a
|
|
15
|
+
claim that every key is settable from a shell:
|
|
16
|
+
|
|
17
|
+
- the **structured** keys (`TARGET_TYPES`, `REASONS`, `RULES`,
|
|
18
|
+
`SANCTION_LADDER`, `BLACKLIST_KINDS`, and the three `*_SCHEDULE` dicts)
|
|
19
|
+
carry shapes an environment string cannot express, so in practice they are
|
|
20
|
+
set in the settings dict and `AppSettings` never reads them from the
|
|
21
|
+
environment;
|
|
22
|
+
- `SCREENER` is the module's only `import_strings` key. It names a callable
|
|
23
|
+
the process imports and runs, so it is **env-closed by policy** — a stray
|
|
24
|
+
export in an entrypoint must not get to choose which classifier decides
|
|
25
|
+
what gets published.
|
|
26
|
+
|
|
27
|
+
## stapel-moderation
|
|
28
|
+
|
|
29
|
+
### Registries (source: env)
|
|
30
|
+
|
|
31
|
+
| Key | Source | Purpose | Required | Default |
|
|
32
|
+
|-----|--------|---------|----------|---------|
|
|
33
|
+
| TARGET_TYPES | env | The flagship merge-registry: `{target_type: policy dict or None}`. Built-ins are EMPTY — the module ships knowing no targets, so a host declares what may be moderated. `None` removes a type. Checks `E001`–`E006` police it; `E004` refuses a policy with no `content_function`. | no | `{}` |
|
|
34
|
+
| REASONS | env | Complaint taxonomy merged OVER a non-empty built-in set (spam, offensive, harassment, counterfeit, fraud, illegal, adult, personal_data, off_platform_payment, wrong_category, other). `None` removes. System reasons cannot be removed. | no | `{}` |
|
|
35
|
+
| RULES | env | Deterministic pre-LLM screening rules, `{code: {pattern, decision, severity, reason_code, applies_to}}`. Ships EMPTY: a shipped keyword list is somebody else's speech policy. | no | `{}` |
|
|
36
|
+
|
|
37
|
+
### Target policy defaults (source: env)
|
|
38
|
+
|
|
39
|
+
| Key | Source | Purpose | Required | Default |
|
|
40
|
+
|-----|--------|---------|----------|---------|
|
|
41
|
+
| GATE_DEFAULT | env | `pre` (target waits for a verdict) or `post` (target is live and can be taken down), for types that do not say. | no | `post` |
|
|
42
|
+
|
|
43
|
+
### Screening (source: env)
|
|
44
|
+
|
|
45
|
+
| Key | Source | Purpose | Required | Default |
|
|
46
|
+
|-----|--------|---------|----------|---------|
|
|
47
|
+
| SCREENER | env | Dotted path to `(case, content, *, reports) -> ScreeningResult`. The seam a host ML provider plugs into instead of forking. Env-closed by `import_strings` policy. | no | `stapel_moderation.screening.default_screener` |
|
|
48
|
+
| SCREEN_ENABLED | env | Master switch for the automatic stage. Off = every case goes straight to a person — the honest configuration with no model available. | no | `True` |
|
|
49
|
+
| SCREEN_MAX_ATTEMPTS | env | Retries of the `moderation.screen` comm-Task before it parks FAILED. | no | `3` |
|
|
50
|
+
| SCREEN_DEADLINE_SECONDS | env | Task deadline; `sweep_tasks` drops overdue ones. | no | `900` |
|
|
51
|
+
| SCREEN_TIMEOUT_SECONDS | env | Timeout of the `llm.complete` call. Deliberately not core's 5s `FUNCTION_TIMEOUT` — a completion never finishes in five seconds. | no | `60` |
|
|
52
|
+
| CONTENT_TIMEOUT_SECONDS | env | Timeout of a target's `content_function`. | no | `10` |
|
|
53
|
+
| LLM_MODEL | env | Size passed to `llm.complete`: `small`/`medium`/`large`. | no | `medium` |
|
|
54
|
+
| LLM_PROVIDER | env | Provider name; empty lets `llm.complete` pick its configured default. | no | `""` |
|
|
55
|
+
| LLM_CONFIDENCE_FLOOR | env | Below it the decision is forced to `needs_review` whatever the model said. | no | `0.7` |
|
|
56
|
+
| ON_SCREENING_FAILURE | env | `hold` \| `approve` \| `reject` when the screener could not answer. **Closed default `hold`**: the human queue IS the fallback. Anything else prints `moderation.W001`. | no | `hold` |
|
|
57
|
+
| ON_SCREENING_UNAVAILABLE | env | Same vocabulary, for "no screener configured at all". | no | `hold` |
|
|
58
|
+
| AUTO_RESOLVE_STALE_QUEUE | env | Seconds after which an unreviewed QUEUED case auto-approves. `None` = never, and never is the point. A number prints `moderation.W002`. | no | `None` |
|
|
59
|
+
|
|
60
|
+
### Queue (source: env)
|
|
61
|
+
|
|
62
|
+
| Key | Source | Purpose | Required | Default |
|
|
63
|
+
|-----|--------|---------|----------|---------|
|
|
64
|
+
| CLAIM_LEASE_SECONDS | env | How long a moderator's claim holds before `sweep_stale_cases` returns the case to the queue. | no | `900` |
|
|
65
|
+
| MAX_PAGE_SIZE | env | Server ceiling on any keyset page. | no | `100` |
|
|
66
|
+
| APPLY_ACK_TIMEOUT_SECONDS | env | Seconds to wait for a target module's optional `moderation.applied` ack. `None` = do not wait; no released consumer emits one. | no | `None` |
|
|
67
|
+
|
|
68
|
+
### Evidence and the wire (source: env)
|
|
69
|
+
|
|
70
|
+
| Key | Source | Purpose | Required | Default |
|
|
71
|
+
|-----|--------|---------|----------|---------|
|
|
72
|
+
| EVIDENCE_EXCERPT_CHARS | env | Length of the content excerpt STORED with a verdict (never emitted), so a statement of reasons stays checkable after the content is gone. | no | `512` |
|
|
73
|
+
| VERDICT_NOTE_WIRE_CHARS | env | Truncation of the statement of reasons that DOES ride `moderation.completed`. | no | `200` |
|
|
74
|
+
|
|
75
|
+
### Media screening (source: env)
|
|
76
|
+
|
|
77
|
+
| Key | Source | Purpose | Required | Default |
|
|
78
|
+
|-----|--------|---------|----------|---------|
|
|
79
|
+
| MEDIA_SCREEN_TIER | env | Largest CDN variant width handed to the model. | no | `1024` |
|
|
80
|
+
| MAX_MEDIA_PER_CASE | env | Image prompts disable the provider's prompt cache, so every media screen is paid in full — this is the price control. | no | `4` |
|
|
81
|
+
| MEDIA_TRANSPORT | env | `url` (the model vendor fetches it — needs publicly reachable CDN URLs) or `data_b64` (inlined, bounded by the broker's max payload). | no | `url` |
|
|
82
|
+
|
|
83
|
+
### Complaint intake (source: env)
|
|
84
|
+
|
|
85
|
+
| Key | Source | Purpose | Required | Default |
|
|
86
|
+
|-----|--------|---------|----------|---------|
|
|
87
|
+
| ALLOW_ANONYMOUS_REPORTS | env | **Closed.** Opening it makes `contact_email` mandatory and demands a captcha; without one, prints `moderation.W003`. | no | `False` |
|
|
88
|
+
| REPORT_THROTTLE | env | DRF scoped rate for the report endpoint. A library cannot own `DEFAULT_THROTTLE_RATES`, so the rate lives here. `None` disables — a conscious act. | no | `20/h` |
|
|
89
|
+
|
|
90
|
+
### Appeals (source: env)
|
|
91
|
+
|
|
92
|
+
| Key | Source | Purpose | Required | Default |
|
|
93
|
+
|-----|--------|---------|----------|---------|
|
|
94
|
+
| APPEAL_REQUIRES_DIFFERENT_ACTOR | env | The moderator who decided may not decide the appeal. A one-moderator team turns it off knowingly. | no | `True` |
|
|
95
|
+
| APPEAL_URL_TEMPLATE | env | Template with `{case_id}` for the `appeal_url` notification variable. Empty yields `""` rather than an invented address. | no | `""` |
|
|
96
|
+
|
|
97
|
+
### Sanctions (source: env)
|
|
98
|
+
|
|
99
|
+
| Key | Source | Purpose | Required | Default |
|
|
100
|
+
|-----|--------|---------|----------|---------|
|
|
101
|
+
| SANCTION_LADDER | env | `{kind: [seconds, ...]}`; the n-th sanction of a kind takes the n-th rung, the last rung repeats, `None` = permanent. | no | `{"posting_restricted": [86400, 604800, 2592000], "suspended": [604800, 2592000, None]}` |
|
|
102
|
+
| BLACKLIST_KINDS | env | Which sanction kinds kill live sessions through core's cross-service user blacklist. | no | `["suspended", "banned"]` |
|
|
103
|
+
| BLACKLIST_TTL_SECONDS | env | TTL of that cache key. The row stays the truth; `rearm_active_sanctions` keeps the key alive. | no | `7200` |
|
|
104
|
+
|
|
105
|
+
### Scheduled work (source: env)
|
|
106
|
+
|
|
107
|
+
| Key | Source | Purpose | Required | Default |
|
|
108
|
+
|-----|--------|---------|----------|---------|
|
|
109
|
+
| SWEEP_SCHEDULE | env | crontab kwargs for `sweep_stale_cases`. | no | `{"minute": "*/5"}` |
|
|
110
|
+
| REARM_SCHEDULE | env | crontab kwargs for `rearm_active_sanctions` and `expire_sanctions`. Must be well inside `BLACKLIST_TTL_SECONDS`. | no | `{"minute": "*/30"}` |
|
|
111
|
+
| PURGE_SCHEDULE | env | crontab kwargs for `purge_expired_cases`. | no | `{"hour": 4, "minute": 20}` |
|
|
112
|
+
| RETENTION_DAYS | env | How long a resolved case is kept — one annual reporting cycle. | no | `365` |
|
|
113
|
+
| SANCTION_RETENTION_DAYS | env | Longer than the case, because the progressive ladder IS memory. | no | `1095` |
|
|
114
|
+
| EXPORT_PAGE_SIZE | env | Rows per page of the projection rebuild snapshot. | no | `500` |
|
|
115
|
+
|
|
116
|
+
### Notifications (source: env)
|
|
117
|
+
|
|
118
|
+
| Key | Source | Purpose | Required | Default |
|
|
119
|
+
|-----|--------|---------|----------|---------|
|
|
120
|
+
| NOTIFY_COOLDOWN_SECONDS | env | One letter per (type, recipient) per window. Forty reports about one listing must not become forty letters to its author. | no | `600` |
|
|
121
|
+
|
|
122
|
+
### Tenancy (source: env)
|
|
123
|
+
|
|
124
|
+
| Key | Source | Purpose | Required | Default |
|
|
125
|
+
|-----|--------|---------|----------|---------|
|
|
126
|
+
| WORKSPACE_SCOPED | env | Declared and closed. The capability names ship on day one so role overlays never migrate; the branch is not built, so turning this on denies loudly rather than granting silently. | no | `False` |
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stapel contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stapel-moderation
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Target-generic moderation queue: reports, verdicts, sanctions and appeals for the Stapel framework
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/usestapel/stapel-moderation
|
|
7
|
+
Project-URL: Repository, https://github.com/usestapel/stapel-moderation
|
|
8
|
+
Project-URL: Documentation, https://github.com/usestapel/stapel-moderation#readme
|
|
9
|
+
Project-URL: Changelog, https://github.com/usestapel/stapel-moderation/blob/main/CHANGELOG.md
|
|
10
|
+
Project-URL: Issues, https://github.com/usestapel/stapel-moderation/issues
|
|
11
|
+
Keywords: django,stapel,moderation,trust-and-safety,dsa
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: stapel-core<1.0,>=0.32.0
|
|
26
|
+
Provides-Extra: all
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
|
|
30
|
+
|
|
31
|
+
# stapel-moderation
|
|
32
|
+
|
|
33
|
+
[](https://github.com/usestapel/stapel-moderation/actions/workflows/ci.yml?query=branch%3Amain)
|
|
34
|
+
[](https://app.codecov.io/gh/usestapel/stapel-moderation)
|
|
35
|
+
[](https://pypi.org/project/stapel-moderation/)
|
|
36
|
+
[](https://pepy.tech/project/stapel-moderation)
|
|
37
|
+
[](https://pypi.org/project/stapel-moderation/)
|
|
38
|
+
[](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE)
|
|
39
|
+
[](https://github.com/usestapel/stapel-moderation/blob/main/docs/llms.txt)
|
|
40
|
+
|
|
41
|
+
> The fleet's single producer of moderation verdicts: one target-generic queue over listings, reviews, chat messages and profiles, keyed by an opaque (target_type, target_key) and driven by a host-registered policy per type. One Case per target however many people complain, one status vocabulary in the whole module, an append-only Verdict and an append-only CaseEvent audit trail whose mutations are FORBIDDEN by mandate declaration. Screening is a comm-Task (deterministic rules, then schema-constrained llm.complete) with a closed hold-for-a-human default when the automation cannot answer. Resolution ACTS on the target by emitting moderation.completed, which stapel-listings and stapel-reviews already consume. Sanctions carry a kind, a scope, a reason, a clock, an appeal and an audit trail, and they bite through core's cross-service user blacklist — the hook every request path already checked and nobody had ever called. Plus DSA artefacts generated from the registries: a public policy disclosure, a statement of reasons on every takedown, an acknowledgement to every complainant, and an internal appeal heard by a different moderator.
|
|
42
|
+
|
|
43
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install stapel-moderation
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## At a glance
|
|
52
|
+
|
|
53
|
+
| Fact | Value |
|
|
54
|
+
|---|---|
|
|
55
|
+
| Version | `0.1.0` |
|
|
56
|
+
| Python | `>=3.11` (3.11, 3.12, 3.13) |
|
|
57
|
+
| HTTP operations | 18 |
|
|
58
|
+
| Config axes | 11 |
|
|
59
|
+
| Usage surface | 56 |
|
|
60
|
+
| Extension points | 6 |
|
|
61
|
+
| Error codes | 69 |
|
|
62
|
+
| Fleet dependencies | [`stapel-agent`](https://github.com/usestapel/stapel-agent) (optional) · [`stapel-auth`](https://github.com/usestapel/stapel-auth) (optional) · [`stapel-cdn`](https://github.com/usestapel/stapel-cdn) (optional) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-notifications`](https://github.com/usestapel/stapel-notifications) (optional) |
|
|
63
|
+
|
|
64
|
+
## Documentation
|
|
65
|
+
|
|
66
|
+
[OpenAPI](https://github.com/usestapel/stapel-moderation/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-moderation/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-moderation/blob/main/docs/llms.txt)
|
|
67
|
+
|
|
68
|
+
## What this is
|
|
69
|
+
|
|
70
|
+
One **moderation queue** for everything a product publishes. A listing, a
|
|
71
|
+
review, a chat message and an avatar are the same kind of work item here, and
|
|
72
|
+
one moderator console works all of them.
|
|
73
|
+
|
|
74
|
+
Three decisions carry the whole design.
|
|
75
|
+
|
|
76
|
+
**The unit of work is a Case, not a complaint.** Forty people reporting one
|
|
77
|
+
listing produce forty `Report` rows hanging off **one** `Case` with
|
|
78
|
+
`report_count = 40`. The system this replaced kept forty queue rows, which is
|
|
79
|
+
why its moderators saw the same listing forty times and why its queue page
|
|
80
|
+
read two whole tables into memory before it could show anybody anything.
|
|
81
|
+
|
|
82
|
+
**There is one status vocabulary in the module: `Case.state`.** A `Report` has
|
|
83
|
+
no status of its own and inherits its case's; a `Verdict` has none because it
|
|
84
|
+
is an append-only fact; a `Sanction` has an orthogonal lifecycle that never
|
|
85
|
+
mixes with a case state. The predecessor had three near-identical unrelated
|
|
86
|
+
status enums plus two more free copies in a serializer and an HTML template,
|
|
87
|
+
and they could and did disagree.
|
|
88
|
+
|
|
89
|
+
**Moderation never calls a host back to mutate it.** Resolving a case emits
|
|
90
|
+
`moderation.completed`, and the target module applies the verdict to itself —
|
|
91
|
+
[stapel-listings](https://github.com/usestapel/stapel-listings) 0.4.0 and
|
|
92
|
+
[stapel-reviews](https://github.com/usestapel/stapel-reviews) 0.2.0 already
|
|
93
|
+
consume it. The action IS the fact, so a new kind of moderated thing is a
|
|
94
|
+
registry entry plus a consumer in its own repository, never a branch in here.
|
|
95
|
+
|
|
96
|
+
## Quick start
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install stapel-moderation
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
INSTALLED_APPS = [
|
|
104
|
+
# ...
|
|
105
|
+
"stapel_moderation",
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
# urls.py
|
|
109
|
+
path("moderation/", include("stapel_moderation.urls")) # -> /moderation/api/v1/...
|
|
110
|
+
|
|
111
|
+
# Declare what may be moderated. The module ships knowing NO target types.
|
|
112
|
+
STAPEL_MODERATION = {
|
|
113
|
+
"TARGET_TYPES": {
|
|
114
|
+
"listing": {
|
|
115
|
+
"intake_events": ["listing.submitted"],
|
|
116
|
+
"id_field": "listing_id",
|
|
117
|
+
"content_function": "listings.moderation_content",
|
|
118
|
+
"notification_types": {"content_blocked": "listing_blocked"},
|
|
119
|
+
},
|
|
120
|
+
"review": {
|
|
121
|
+
"id_field": "review_id",
|
|
122
|
+
"content_function": "reviews.moderation_content",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Moderator rights are staff roles + the core mandate. No allow-list.
|
|
128
|
+
STAPEL_ACCESS = {"ROLES": {
|
|
129
|
+
"moderator": {"clearance": "low", "apps": {"moderation": "mid"}}, # read the queue
|
|
130
|
+
"ts_lead": {"clearance": "mid", "apps": {"moderation": "high"}}, # decide and sanction
|
|
131
|
+
}}
|
|
132
|
+
|
|
133
|
+
# The scheduled half. Without it, long suspensions stop being enforced.
|
|
134
|
+
from stapel_moderation.tasks import get_moderation_beat_schedule
|
|
135
|
+
CELERY_BEAT_SCHEDULE = {**get_moderation_beat_schedule()}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## How a case moves
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
listing.submitted ──▶ open_case ──▶ comm-Task "moderation.screen"
|
|
142
|
+
│
|
|
143
|
+
┌────────────────────┼────────────────────┐
|
|
144
|
+
▼ ▼ ▼
|
|
145
|
+
rules hit llm.complete unavailable
|
|
146
|
+
(no LLM billed) (schema-constrained) (retry ×3, then
|
|
147
|
+
│ │ ON_SCREENING_FAILURE)
|
|
148
|
+
└────────┬───────────┘ │
|
|
149
|
+
▼ ▼
|
|
150
|
+
approved / rejected needs_review ──▶ human queue
|
|
151
|
+
│ │
|
|
152
|
+
▼ ▼
|
|
153
|
+
emit moderation.completed ◀────────────── moderator verdict
|
|
154
|
+
│ (+ optional sanction)
|
|
155
|
+
▼
|
|
156
|
+
the target module blocks itself
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## The switches that ship closed
|
|
160
|
+
|
|
161
|
+
Every setting that trades safety for availability is off by default, and the
|
|
162
|
+
three that matter print a startup warning when a host turns them on — because
|
|
163
|
+
each one is *invisible at runtime*, and the predecessor system had two of them
|
|
164
|
+
silently enabled for years.
|
|
165
|
+
|
|
166
|
+
| setting | default | what opening it costs |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `ON_SCREENING_FAILURE` | `"hold"` | `"approve"` publishes content nobody screened; `"reject"` removes content nobody screened. Either prints `W001`. |
|
|
169
|
+
| `AUTO_RESOLVE_STALE_QUEUE` | `None` | A number makes unreviewed cases approve themselves on a clock. Prints `W002`. |
|
|
170
|
+
| `ALLOW_ANONYMOUS_REPORTS` | `False` | Requires a contact address and a captcha; without one, a complaint flood is a denial-of-service against the queue. Prints `W003`. |
|
|
171
|
+
| `APPEAL_REQUIRES_DIFFERENT_ACTOR` | `True` | Off, the moderator who decided also hears the appeal. |
|
|
172
|
+
|
|
173
|
+
## What a ban actually does
|
|
174
|
+
|
|
175
|
+
`Sanction` is a row with a kind, a scope, a reason, a clock, an appeal and an
|
|
176
|
+
audit trail — not a boolean. Its teeth are `stapel-core`'s cross-service user
|
|
177
|
+
blacklist, which DRF authentication, the middleware (twice), channels and the
|
|
178
|
+
auth refresh endpoint all already check on every request, and which **had no
|
|
179
|
+
producer anywhere in the fleet** until this module. Deactivating the account
|
|
180
|
+
instead would touch no live session at all: `is_active` is only consulted when
|
|
181
|
+
a new token is issued.
|
|
182
|
+
|
|
183
|
+
Two operational consequences, stated rather than discovered:
|
|
184
|
+
|
|
185
|
+
- the blacklist is a cache key with a TTL, so `rearm_active_sanctions` must be
|
|
186
|
+
scheduled — otherwise a thirty-day suspension quietly stops being enforced
|
|
187
|
+
after two hours while the row still reads `active`. `W004` says so;
|
|
188
|
+
- core fails **closed** when that cache is unreachable, so a Redis outage
|
|
189
|
+
locks everybody out, not just the sanctioned. That is a property of core's
|
|
190
|
+
blacklist, and it belongs in the runbook.
|
|
191
|
+
|
|
192
|
+
## Notice-and-action artefacts
|
|
193
|
+
|
|
194
|
+
The compliance surface is generated from the registries, not maintained as
|
|
195
|
+
prose beside them:
|
|
196
|
+
|
|
197
|
+
- `GET /moderation/api/v1/policy` — public, and assembled from the reason
|
|
198
|
+
registry, the rule registry and the actual screening settings, so it cannot
|
|
199
|
+
describe a system other than the one running;
|
|
200
|
+
- every takedown carries a statement of reasons and an appeal link;
|
|
201
|
+
- every complainant gets an acknowledgement and, later, the outcome;
|
|
202
|
+
- an appeal reopens and re-decides its case rather than filing a letter — the
|
|
203
|
+
one backward edge in the state machine exists for exactly that.
|
|
204
|
+
|
|
205
|
+
## The Django admin is read-only, on purpose
|
|
206
|
+
|
|
207
|
+
Moderators *are* Django staff here, so the usual "different audience" argument
|
|
208
|
+
does not apply. The reason is path integrity: in the predecessor, admin bulk
|
|
209
|
+
actions flipped report statuses through `queryset.update()` — no audit row, no
|
|
210
|
+
timestamp, and the reviewed content was never actually hidden. A second
|
|
211
|
+
resolution path existed, invisible to the audit log. Read-only registration
|
|
212
|
+
makes that path impossible by construction, and `CaseEvent` is declared
|
|
213
|
+
`@access.ops`, whose mutations are `FORBIDDEN` even for a superuser.
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
MIT — see [LICENSE](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE).
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<!-- Generated by stapel-readme from docs/readme.md + docs/*.json. Do not edit this file; edit docs/readme.md and re-run `make readme`. -->
|
|
2
|
+
|
|
3
|
+
# stapel-moderation
|
|
4
|
+
|
|
5
|
+
[](https://github.com/usestapel/stapel-moderation/actions/workflows/ci.yml?query=branch%3Amain)
|
|
6
|
+
[](https://app.codecov.io/gh/usestapel/stapel-moderation)
|
|
7
|
+
[](https://pypi.org/project/stapel-moderation/)
|
|
8
|
+
[](https://pepy.tech/project/stapel-moderation)
|
|
9
|
+
[](https://pypi.org/project/stapel-moderation/)
|
|
10
|
+
[](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE)
|
|
11
|
+
[](https://github.com/usestapel/stapel-moderation/blob/main/docs/llms.txt)
|
|
12
|
+
|
|
13
|
+
> The fleet's single producer of moderation verdicts: one target-generic queue over listings, reviews, chat messages and profiles, keyed by an opaque (target_type, target_key) and driven by a host-registered policy per type. One Case per target however many people complain, one status vocabulary in the whole module, an append-only Verdict and an append-only CaseEvent audit trail whose mutations are FORBIDDEN by mandate declaration. Screening is a comm-Task (deterministic rules, then schema-constrained llm.complete) with a closed hold-for-a-human default when the automation cannot answer. Resolution ACTS on the target by emitting moderation.completed, which stapel-listings and stapel-reviews already consume. Sanctions carry a kind, a scope, a reason, a clock, an appeal and an audit trail, and they bite through core's cross-service user blacklist — the hook every request path already checked and nobody had ever called. Plus DSA artefacts generated from the registries: a public policy disclosure, a statement of reasons on every takedown, an acknowledgement to every complainant, and an internal appeal heard by a different moderator.
|
|
14
|
+
|
|
15
|
+
Part of the [Stapel framework](https://github.com/usestapel) — composable Django apps that deploy as a monolith or as microservices without changing module code.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install stapel-moderation
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## At a glance
|
|
24
|
+
|
|
25
|
+
| Fact | Value |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Version | `0.1.0` |
|
|
28
|
+
| Python | `>=3.11` (3.11, 3.12, 3.13) |
|
|
29
|
+
| HTTP operations | 18 |
|
|
30
|
+
| Config axes | 11 |
|
|
31
|
+
| Usage surface | 56 |
|
|
32
|
+
| Extension points | 6 |
|
|
33
|
+
| Error codes | 69 |
|
|
34
|
+
| Fleet dependencies | [`stapel-agent`](https://github.com/usestapel/stapel-agent) (optional) · [`stapel-auth`](https://github.com/usestapel/stapel-auth) (optional) · [`stapel-cdn`](https://github.com/usestapel/stapel-cdn) (optional) · [`stapel-core`](https://github.com/usestapel/stapel-core) · [`stapel-notifications`](https://github.com/usestapel/stapel-notifications) (optional) |
|
|
35
|
+
|
|
36
|
+
## Documentation
|
|
37
|
+
|
|
38
|
+
[OpenAPI](https://github.com/usestapel/stapel-moderation/blob/main/docs/schema.json) · [capabilities.json](https://github.com/usestapel/stapel-moderation/blob/main/docs/capabilities.json) · [llms.txt (for agents)](https://github.com/usestapel/stapel-moderation/blob/main/docs/llms.txt)
|
|
39
|
+
|
|
40
|
+
## What this is
|
|
41
|
+
|
|
42
|
+
One **moderation queue** for everything a product publishes. A listing, a
|
|
43
|
+
review, a chat message and an avatar are the same kind of work item here, and
|
|
44
|
+
one moderator console works all of them.
|
|
45
|
+
|
|
46
|
+
Three decisions carry the whole design.
|
|
47
|
+
|
|
48
|
+
**The unit of work is a Case, not a complaint.** Forty people reporting one
|
|
49
|
+
listing produce forty `Report` rows hanging off **one** `Case` with
|
|
50
|
+
`report_count = 40`. The system this replaced kept forty queue rows, which is
|
|
51
|
+
why its moderators saw the same listing forty times and why its queue page
|
|
52
|
+
read two whole tables into memory before it could show anybody anything.
|
|
53
|
+
|
|
54
|
+
**There is one status vocabulary in the module: `Case.state`.** A `Report` has
|
|
55
|
+
no status of its own and inherits its case's; a `Verdict` has none because it
|
|
56
|
+
is an append-only fact; a `Sanction` has an orthogonal lifecycle that never
|
|
57
|
+
mixes with a case state. The predecessor had three near-identical unrelated
|
|
58
|
+
status enums plus two more free copies in a serializer and an HTML template,
|
|
59
|
+
and they could and did disagree.
|
|
60
|
+
|
|
61
|
+
**Moderation never calls a host back to mutate it.** Resolving a case emits
|
|
62
|
+
`moderation.completed`, and the target module applies the verdict to itself —
|
|
63
|
+
[stapel-listings](https://github.com/usestapel/stapel-listings) 0.4.0 and
|
|
64
|
+
[stapel-reviews](https://github.com/usestapel/stapel-reviews) 0.2.0 already
|
|
65
|
+
consume it. The action IS the fact, so a new kind of moderated thing is a
|
|
66
|
+
registry entry plus a consumer in its own repository, never a branch in here.
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install stapel-moderation
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
INSTALLED_APPS = [
|
|
76
|
+
# ...
|
|
77
|
+
"stapel_moderation",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# urls.py
|
|
81
|
+
path("moderation/", include("stapel_moderation.urls")) # -> /moderation/api/v1/...
|
|
82
|
+
|
|
83
|
+
# Declare what may be moderated. The module ships knowing NO target types.
|
|
84
|
+
STAPEL_MODERATION = {
|
|
85
|
+
"TARGET_TYPES": {
|
|
86
|
+
"listing": {
|
|
87
|
+
"intake_events": ["listing.submitted"],
|
|
88
|
+
"id_field": "listing_id",
|
|
89
|
+
"content_function": "listings.moderation_content",
|
|
90
|
+
"notification_types": {"content_blocked": "listing_blocked"},
|
|
91
|
+
},
|
|
92
|
+
"review": {
|
|
93
|
+
"id_field": "review_id",
|
|
94
|
+
"content_function": "reviews.moderation_content",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Moderator rights are staff roles + the core mandate. No allow-list.
|
|
100
|
+
STAPEL_ACCESS = {"ROLES": {
|
|
101
|
+
"moderator": {"clearance": "low", "apps": {"moderation": "mid"}}, # read the queue
|
|
102
|
+
"ts_lead": {"clearance": "mid", "apps": {"moderation": "high"}}, # decide and sanction
|
|
103
|
+
}}
|
|
104
|
+
|
|
105
|
+
# The scheduled half. Without it, long suspensions stop being enforced.
|
|
106
|
+
from stapel_moderation.tasks import get_moderation_beat_schedule
|
|
107
|
+
CELERY_BEAT_SCHEDULE = {**get_moderation_beat_schedule()}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## How a case moves
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
listing.submitted ──▶ open_case ──▶ comm-Task "moderation.screen"
|
|
114
|
+
│
|
|
115
|
+
┌────────────────────┼────────────────────┐
|
|
116
|
+
▼ ▼ ▼
|
|
117
|
+
rules hit llm.complete unavailable
|
|
118
|
+
(no LLM billed) (schema-constrained) (retry ×3, then
|
|
119
|
+
│ │ ON_SCREENING_FAILURE)
|
|
120
|
+
└────────┬───────────┘ │
|
|
121
|
+
▼ ▼
|
|
122
|
+
approved / rejected needs_review ──▶ human queue
|
|
123
|
+
│ │
|
|
124
|
+
▼ ▼
|
|
125
|
+
emit moderation.completed ◀────────────── moderator verdict
|
|
126
|
+
│ (+ optional sanction)
|
|
127
|
+
▼
|
|
128
|
+
the target module blocks itself
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## The switches that ship closed
|
|
132
|
+
|
|
133
|
+
Every setting that trades safety for availability is off by default, and the
|
|
134
|
+
three that matter print a startup warning when a host turns them on — because
|
|
135
|
+
each one is *invisible at runtime*, and the predecessor system had two of them
|
|
136
|
+
silently enabled for years.
|
|
137
|
+
|
|
138
|
+
| setting | default | what opening it costs |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `ON_SCREENING_FAILURE` | `"hold"` | `"approve"` publishes content nobody screened; `"reject"` removes content nobody screened. Either prints `W001`. |
|
|
141
|
+
| `AUTO_RESOLVE_STALE_QUEUE` | `None` | A number makes unreviewed cases approve themselves on a clock. Prints `W002`. |
|
|
142
|
+
| `ALLOW_ANONYMOUS_REPORTS` | `False` | Requires a contact address and a captcha; without one, a complaint flood is a denial-of-service against the queue. Prints `W003`. |
|
|
143
|
+
| `APPEAL_REQUIRES_DIFFERENT_ACTOR` | `True` | Off, the moderator who decided also hears the appeal. |
|
|
144
|
+
|
|
145
|
+
## What a ban actually does
|
|
146
|
+
|
|
147
|
+
`Sanction` is a row with a kind, a scope, a reason, a clock, an appeal and an
|
|
148
|
+
audit trail — not a boolean. Its teeth are `stapel-core`'s cross-service user
|
|
149
|
+
blacklist, which DRF authentication, the middleware (twice), channels and the
|
|
150
|
+
auth refresh endpoint all already check on every request, and which **had no
|
|
151
|
+
producer anywhere in the fleet** until this module. Deactivating the account
|
|
152
|
+
instead would touch no live session at all: `is_active` is only consulted when
|
|
153
|
+
a new token is issued.
|
|
154
|
+
|
|
155
|
+
Two operational consequences, stated rather than discovered:
|
|
156
|
+
|
|
157
|
+
- the blacklist is a cache key with a TTL, so `rearm_active_sanctions` must be
|
|
158
|
+
scheduled — otherwise a thirty-day suspension quietly stops being enforced
|
|
159
|
+
after two hours while the row still reads `active`. `W004` says so;
|
|
160
|
+
- core fails **closed** when that cache is unreachable, so a Redis outage
|
|
161
|
+
locks everybody out, not just the sanctioned. That is a property of core's
|
|
162
|
+
blacklist, and it belongs in the runbook.
|
|
163
|
+
|
|
164
|
+
## Notice-and-action artefacts
|
|
165
|
+
|
|
166
|
+
The compliance surface is generated from the registries, not maintained as
|
|
167
|
+
prose beside them:
|
|
168
|
+
|
|
169
|
+
- `GET /moderation/api/v1/policy` — public, and assembled from the reason
|
|
170
|
+
registry, the rule registry and the actual screening settings, so it cannot
|
|
171
|
+
describe a system other than the one running;
|
|
172
|
+
- every takedown carries a statement of reasons and an appeal link;
|
|
173
|
+
- every complainant gets an acknowledgement and, later, the outcome;
|
|
174
|
+
- an appeal reopens and re-decides its case rather than filing a letter — the
|
|
175
|
+
one backward edge in the state machine exists for exactly that.
|
|
176
|
+
|
|
177
|
+
## The Django admin is read-only, on purpose
|
|
178
|
+
|
|
179
|
+
Moderators *are* Django staff here, so the usual "different audience" argument
|
|
180
|
+
does not apply. The reason is path integrity: in the predecessor, admin bulk
|
|
181
|
+
actions flipped report statuses through `queryset.update()` — no audit row, no
|
|
182
|
+
timestamp, and the reviewed content was never actually hidden. A second
|
|
183
|
+
resolution path existed, invisible to the audit log. Read-only registration
|
|
184
|
+
makes that path impossible by construction, and `CaseEvent` is declared
|
|
185
|
+
`@access.ops`, whose mutations are `FORBIDDEN` even for a superuser.
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT — see [LICENSE](https://github.com/usestapel/stapel-moderation/blob/main/LICENSE).
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
<sub>This page is assembled by `stapel-readme` from `docs/readme.md` plus the contract artifacts in `docs/`. Edit the prose in `docs/readme.md`; the badges, facts and links above and below it are generated — do not hand-edit `README.md`.</sub>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""stapel-moderation — the fleet's single producer of moderation verdicts.
|
|
2
|
+
|
|
3
|
+
A target-generic queue over every kind of moderated thing (listings, reviews,
|
|
4
|
+
chat messages, profiles): one ``Case`` per target, one status vocabulary, an
|
|
5
|
+
append-only audit trail, LLM-first screening with a human always in the loop,
|
|
6
|
+
and sanctions with a reason, a scope, a clock and an appeal.
|
|
7
|
+
|
|
8
|
+
Public API (lazily exported, PEP 562 — importing this package never pulls in
|
|
9
|
+
Django or requires configured settings):
|
|
10
|
+
|
|
11
|
+
- ``moderation_settings`` — resolved app settings (``stapel_moderation.conf``);
|
|
12
|
+
- ``register_target_type`` / ``register_reason`` / ``register_rule`` — the
|
|
13
|
+
three merge-registries (``stapel_moderation.registry``);
|
|
14
|
+
- ``NotSanctioned`` — the DRF permission a HOST hangs on its own write views
|
|
15
|
+
to refuse a sanctioned user (``stapel_moderation.authz``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"moderation_settings",
|
|
20
|
+
"register_reason",
|
|
21
|
+
"register_rule",
|
|
22
|
+
"register_target_type",
|
|
23
|
+
"NotSanctioned",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# name -> submodule that defines it. Resolution is deferred until first
|
|
27
|
+
# attribute access so that `import stapel_moderation` stays Django-free.
|
|
28
|
+
_LAZY_EXPORTS = {
|
|
29
|
+
"moderation_settings": ".conf",
|
|
30
|
+
"register_target_type": ".registry",
|
|
31
|
+
"register_reason": ".registry",
|
|
32
|
+
"register_rule": ".registry",
|
|
33
|
+
"NotSanctioned": ".authz",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def __getattr__(name):
|
|
38
|
+
if name in _LAZY_EXPORTS:
|
|
39
|
+
from importlib import import_module
|
|
40
|
+
|
|
41
|
+
value = getattr(import_module(_LAZY_EXPORTS[name], __name__), name)
|
|
42
|
+
globals()[name] = value # cache for subsequent lookups
|
|
43
|
+
return value
|
|
44
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def __dir__():
|
|
48
|
+
return sorted(set(globals()) | set(__all__))
|