Create a trusted Django integration and server hooks
Complete Python app with authenticated routes, native mutations and after-commit signals.
What this package does
Build a generic trusted server integration with authorized actor reads, native-command renaming and after-commit event observation. Python code is owner-activated for the installation, separately from campaign browser modules. It is not a separately installed RPG rules companion. A new system remains one package using the requested host contracts.
1. Choose browser or server correctly
Use browser code when an action fits the public domain API. Use Django only for an explicitly trusted integration requiring server routes/services. A Django manifest cannot contain entry, system or locales. Browser modules cannot ask the host to import Python or execute SQL. This example creates no models, migrations, extra dependencies or startup writes.
2. Create server-workshop with these files
manifest.json
{
"id": "example.server-workshop",
"name": "Server integration workshop",
"version": "1.0.0",
"sdk": {
"requires": ">=1.0.0 <2.0.0",
"tested": "1.0.0"
},
"description": "Authenticated campaign reads, native actor commands and bounded process-local event counters.",
"author": "Gravewright documentation",
"license": "MIT",
"django": {
"apps": [
"example_workshop.apps.ExampleWorkshopConfig"
]
}
}example_workshop/init.py
example_workshop/apps.py
from django.apps import AppConfig
class ExampleWorkshopConfig(AppConfig):
name = "example_workshop"
label = "example_workshop"
gravewright_urlconf = "example_workshop.urls"
def ready(self):
from api.events import resource_changed
from .hooks import observe
resource_changed.connect(observe, dispatch_uid="example.server-workshop.observe", weak=False)example_workshop/hooks.py
from collections import Counter
from threading import Lock
counts = Counter()
lock = Lock()
def observe(sender, change, **kwargs):
# Only fixed resource names are retained, never campaign/user/document IDs.
if change.resource not in {"actors", "tokens", "maps", "journals", "items", "combat", "cards", "audio", "compendiums"}:
return
with lock:
counts[change.resource] += 1
def snapshot():
with lock:
return dict(counts)example_workshop/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("api/tutorials/campaign/<uuid:campaign_id>/actors/", views.read_actors),
path("api/tutorials/campaign/<uuid:campaign_id>/rename/", views.rename_actor),
path("api/tutorials/event-counts/", views.event_counts),
]example_workshop/views.py
import json
from uuid import UUID, uuid4
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_GET, require_POST
from api import Context, actors
from api.errors import AuthError, JournalError, MapError
from .hooks import snapshot
def failure(error):
code = getattr(error, "code", "invalid_data")
code = {"not_a_member": "permission_denied", "forbidden": "permission_denied", "invalid_input": "invalid_data"}.get(code, code)
status = {"permission_denied": 403, "not_found": 404, "conflict": 409}.get(code, getattr(error, "status", 400))
return JsonResponse({"error": code}, status=status)
@login_required
@require_GET
def read_actors(request, campaign_id):
try:
context = Context.from_request(request, campaign_id)
value = actors.state(context.campaign_id, context.user_id)
return JsonResponse({"actors": value["actors"], "actorTypes": value["actorTypes"]})
except (AuthError, JournalError, MapError) as error:
return failure(error)
@login_required
@require_POST
def rename_actor(request, campaign_id):
# Normal Django CSRF middleware remains active; never mark this csrf_exempt.
if len(request.body) > 8192:
return JsonResponse({"error": "invalid_data"}, status=400)
try:
body = json.loads(request.body)
if not isinstance(body, dict) or set(body) != {"id", "version", "name"}:
raise ValueError("Invalid payload")
identifier = str(UUID(body["id"]))
if not str(body["version"]).isdigit():
raise ValueError("Invalid version")
if not isinstance(body["name"], str) or not 2 <= len(body["name"].strip()) <= 120:
raise ValueError("Invalid name")
context = Context.from_request(request, campaign_id)
result = actors.command(context.campaign_id, context.user_id, "actor.update", {
"id": identifier, "version": body["version"], "name": body["name"].strip(),
}, uuid4())
return JsonResponse({"result": result})
except (ValueError, TypeError, KeyError, AttributeError):
return JsonResponse({"error": "invalid_data"}, status=400)
except (AuthError, JournalError, MapError) as error:
return failure(error)
@login_required
@require_GET
def event_counts(request):
if request.user.role != "owner":
return JsonResponse({"error": "permission_denied"}, status=403)
return JsonResponse({"processLocal": True, "resources": snapshot()})LICENSE.txt
MIT License
Copyright (c) 2026 Gravewright contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.3. Package and activate on a development host
Download the complete ZIP or save the files and archive manifest.json, LICENSE.txt and example_workshop together. From the project root:
python -m zipfile -c ../server-workshop-1.0.0.zip manifest.json LICENSE.txt example_workshopUse python3 on Linux/macOS. As owner, install through Inside → Modules → Install module → Install from ZIP. Choose installation activation rather than the table’s Activate action. The Runner stages selection and restarts through managed supervision; await completion and reload. managed_start_required on a directly launched server means activation must use the Runner. Do not bypass it through a browser module import.
4. Test authenticated reads and writes
Open /api/tutorials/campaign/YOUR_UUID/actors/ in an authenticated tab with your disposable campaign UUID. The native service filters the list for that caller. As campaign GM, use a disposable actor and execute in that host’s Console:
// Run on your own development host in an authenticated browser tab.
const campaignId = "REPLACE_WITH_YOUR_TEST_CAMPAIGN_UUID";
const state = await fetch('/api/tutorials/campaign/'+campaignId+'/actors/').then(r=>r.json());
const actor = state.actors.find(row=>row.canOpen);
const csrf = await fetch('/__gravewright/csrf').then(r=>r.json());
const response = await fetch('/api/tutorials/campaign/'+campaignId+'/rename/', {
method:'POST',
headers:{'Content-Type':'application/json',[csrf.header]:csrf.token},
body:JSON.stringify({id:actor.id,version:actor.version,name:'Original workshop navigator'}),
});
console.log(response.status,await response.json());A successful response is 200. Inspect the persisted name in Actors. An old version yields conflict; reread before deciding another change. Passing a GM UUID does not grant GM access: Context.from_request uses authenticated identity and membership. Middleware rejects POST without valid CSRF.
5. Observe events without exposing data
AppConfig.ready connects observe once. The receiver only counts nine fixed resource names without storing user/campaign/document IDs or querying the database. As owner, open /api/tutorials/event-counts/. A completed public command may increment its counter. Counts are process-local and reset on restart; they are neither durable audit history nor multi-worker totals. Chat/dice writes do not all emit this signal.
An after-commit receiver failure does not undo a committed transaction. Do not perform irreversible external work expecting rollback. Durable integrations require their own transactional queue in a trusted app; the browser SDK does not supply it.
6. Use the remaining Python facades
Authenticate Context, invoke the public facade and let its service enforce permissions/revisions. Signatures differ; consult the Python API table: actors/tokens/maps/journals; items/combat/cards/audio/compendiums; resources as a generic dispatcher; chat/dice/table for messages, rolls and search; events for signals. Private imports and direct ORM writes do not replace native commands.
Family recipes in the implementation index supply concrete action payloads. Server command(campaign_id,user_id,action,data,request_id) uses native action names declared in frontend.json, with state for reads. For example journals.update uses action update, while combat.nextTurn uses next. Do not pass JavaScript method names as native actions without checking the registry.
7. Test before distribution
Check owner, GM, player, nonmember, anonymous session, missing CSRF, malformed body, stale revision and another campaign’s actor. GET routes must not write or import operator apps. Inspect the ZIP and publish a new version for changed bytes. Owner deactivation also uses the Runner and restart; do not remove active package files forcibly.
How to use AI for server integration
Start from Server integration workshop for [generic integration] on Gravewright 0.3.0. Use Context.from_request and public api facades; preserve authentication, CSRF, permissions and revisions. No ready/import queries. Document after-commit signals, process-local limits and Runner activation. Do not introduce a system companion or require a newer host. Deliver complete files and cross-account/campaign tests.