HomeDeveloper docsCreate a trusted Django integration and server hooks
Implementation tutorials 6 min read

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

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

python

example_workshop/apps.py

python
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

python
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

python
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

python
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

text
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:

powershell
python -m zipfile -c ../server-workshop-1.0.0.zip manifest.json LICENSE.txt example_workshop

Use 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:

javascript
// 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

text
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.