tg-standards
Django development standards for the Tellurium Games World of Darkness application. Use when: creating/editing models (characters, items, locations), implementing CRUD views, building forms, creating
SKILL.md.claude/skills/tg-standards/SKILL.md
GitHubTG standards
The rules a change must follow to match the current architecture of this repository, and
the checklist a reviewer runs against a diff. Each rule names its reason and, where one
exists, the test that enforces it. Details live in references/; explanations live in
the project docs they link to (docs/architecture/, docs/guides/, <app>/docs/).
The code is the source of truth. If a rule here disagrees with the code or a guard test, trust the code, then fix this skill.
When to use
- Before designing a model, field, constraint or any schema change.
- Before adding or changing a view, form, URL, template, route policy or cache decorator.
- Before writing tests for any of the above, or a management command.
- When reviewing a diff, branch or PR in this repo: run the review checklist.
Not for game rules or sheet content (use wod-toolkit) or generic Django refactoring
(use django-simplifier).
Non-negotiable rules
Schema
- Never commit migrations for local apps (
accounts,characters,core,game,items,locations,widgets). Theirmigrations/hold only__init__.py;.gitignoreignores the rest. Why: test and fresh databases build these tables from the current models; generated files are per install. - Ship every change an existing database needs as a
tg_schemamigration: new column, table, index or constraint, rename, data backfill, reordered chargen steps. Number itNNNN_snake_name.pyand depend on the previous one. Why: it is the only committed path that reaches databases created before the change. - In a
tg_schemamigration, import no app model and ignore theappsargument. Look models up withtg_schema.schema.live_model/live_field, or name tables in raw SQL. Why: a later rename must skip the old migration, not crash everymigrate(tg_schema/tests/test_schema_helpers.pyenforces both). - Make it idempotent and self-skipping: add only missing columns
(
add_missing_columns), return when a model, field, table or column is gone, guard DDL by introspection, backfill only rows that still need it, reverse withRunPython.noop. - Declare the same field or constraint on the model, nullable or with a default. Why: fresh databases get the schema from the model; the migration only patches old ones, whose rows need a value.
- Test every
tg_schemamigration intg_schema/tests/test_<name>.py.
Models
- Extend an existing tree:
Human(orCharacterModel) subclasses for characters,Groupfor player groups,ItemModel,LocationModel,core.models.Modelfor other owned or approvable objects and most reference data,ValidatedSaveMixin+models.Modelfor plain records. Why: ownership, approval, visibility, public cards and routing are built on these bases. - Set
type(snake_case, unique in its app) andgameline(a key ofsettings.GAMELINES) as class attributes on everycore.models.Modelsubclass. Why:GenericCharacterDetailViewandcharacters.chargen.get_workflowdispatch ontype; theming, headings and scoped ST roles usegameline. - Take choices from settings and constants:
settings.GAMELINE_CHOICES/settings.GAMELINESfor gamelines,core.constants.CharacterStatus/ImageStatus/XPApprovalStatusfor statuses. Never hard-code a gameline or status list. - Validate in
clean()(collect a field-keyed dict, raise oneValidationError);save()runsfull_clean(). Usesave(skip_validation=True)orQuerySet.update()only in migrations, fixtures and bulk jobs. on_delete=SET_NULL(withnull=True) for links to rows that live on their own;CASCADEonly for rows that exist for their parent. Name every constraint.- Give routable models
get_absolute_url,get_update_urlandget_creation_url. Items and locations inherit them from the registry (RegistryURLMixin); reference models may useURLMethodsMixin. - Lock before changing XP, freebies or point pools:
select_for_update()insidetransaction.atomic().
Authorization
- Every routed view has exactly one access policy: an entry in
core/route_policy_manifest.pyor its registryActionSpec.policy, never both, and no stale entries. Why:AuthorizationMiddlewaredenies undeclared views, andcore/tests/security/test_route_policies.pyfails on missing, doubled or stale ones. - Choose the narrowest policy. Reference data:
PUBLIC_READto read,STAFF_WRITEto write. Player objects:OBJECT_LIST,OBJECT_DETAIL,OBJECT_CREATE,OBJECT_WRITE,OBJECT_ST_WRITE,CHARGEN_STEP. One-object state changes:ACTION. - Decide object access in
PermissionManager(always passrequest=), throughcore.mixinsor an action'spermission/has_permission, never in view bodies or templates. Templates read theobject_permsbooleans. - Never reveal what a user may not see. A hidden or missing object gets the same
404; a player object the user cannot fully view gets a card only when
visibilityadmits it (PUB, orCHRin a readable chronicle), never its private fields;PRIrequires full access; a 403 only follows a successful view check. Why: a 403 or a different 404 confirms the object exists. - Only staff change
owner,chronicle,gameline,status,npc,xp,freebies_approved,approved,approved_bythrough a write route; everyone else, storytellers included, goes through a dedicated action or service. Owner-editable update views useScopedEditFormMixinwith alimited_form_class. Why: theOBJECT_*field guard incore/access_policy.pyrejects the change anyway.
Views, forms and URLs
- Detail views never handle POST; each state change is its own POST endpoint
(
core.actions.ObjectActionView), and no view dispatches on posted button names (core/tests/test_action_guard.py). form_invalidnever callsform_valid(characters/tests/test_view_rules_guard.py).- Keep rules out of views: services (
characters/services/,core/services/,game/) and models make the change; views authorize, bind and render. - Fetch with
get_object_or_404,select_related/prefetch_relatedand.with_polymorphic_ctype(); stay inside the query ceilings (core/tests/test_query_budgets.py). - Create views for
core.models.Modelsubclasses includeMessageMixin(or come from the registry). Why: it runsprepare_created_object, which sets the owner, checks the chronicle and sets the starting status. - List editable fields explicitly:
fields = [...]or a form'sMeta.fields; never"__all__",excludeor introspection. Character CRUD lists live incharacters/forms/core/crud_fields.pyand are pinned by a baseline. - Register every concrete item and location model in
items/registry.py/locations/registry.py(core/tests/test_model_registry.py). New routes use<int:pk>and a name in the app's gameline and action namespaces (characters:vampire:update:clan).
Templates and caching
- Build on Spread: extend
core/tl_base.html,core/form.html,core/object.htmlor an area shell. No Bootstrap, jQuery,tg-card, inline scripts, inlinestyle="..."or<style>blocks (the last two are ratcheted bycore/tests/test_template_policy.py). CSS goes incore/static/core/tl/tl.css, JavaScript in<app>/static/<app>/, page data injson_scriptordata-*. - Theme with
{% block gameline %}{{ object|gameline_code }}{% endblock %}, render fields withcore/tl/field.html, dots and tracks with{% load tl %}tags. - Escape user text:
|sanitize_htmlfor rich text, never|safe. - Cache pages only with
core.cache.cache_page_per_visitor(orCachedDetailView/CachedListView), never Django'scache_page, and only pages that read no query string and render no CSRF token unless they hold a form. Why:cache_pageserves one visitor's page to the next, and a CSRF token keeps a page out of the shared anonymous copy.
Tests and style
- Every change ships its tests in
<app>/tests/, mirroring the source path: models, forms, views, a denial test for each new route, a migration test for eachtg_schemamigration. - Change a guard's allowlist, ceiling or baseline only on purpose, in the same diff, with the reason in the commit. Why: these files are the ratchets that keep the architecture in place.
- Format with black and lint with ruff (100 columns; ruff is the only import sorter) through pre-commit, and keep ruff clean repo-wide.
Review checklist
Run the sections that match the diff. Every unchecked box is a finding.
Any model change
- Right base class;
typeunique andgamelinevalid on polymorphic subclasses. -
Meta.verbose_name,verbose_name_plural,orderingwhere lists need it;__str__. - Choices from settings or
core.constants; no hard-coded gameline or status lists. -
clean()validates cross-field rules; constraints are named and have messages. -
on_deleteandrelated_namechosen deliberately; hot FKs indexed. - URL methods present or supplied by the registry.
Schema change
- No files under a local app's
migrations/besides__init__.py. - A new
tg_schemamigration, linear dependency, no app-model imports,appsunused. - Skips cleanly when its model, field, table or column is gone; runs twice safely.
- Backfills touch only rows that still need it; destructive SQL explained in the docstring.
- Model declares the same field or constraint; new columns nullable or defaulted.
-
tg_schema/tests/test_<name>.pycovers: adds when missing, no SQL when present, backfill result, skip on rename.
New or changed route
- Exactly one policy (manifest or registry), the narrowest that fits; removed views removed from the manifest.
- Hidden objects give the same 404 as missing ones; visibility gates non-full cards.
- No POST on detail views; no button-name dispatch; actions use
ObjectActionView. - Protected fields cannot be changed by non-staff; update views scope their form.
- Queries bounded:
select_related/prefetch_related, no per-row queries in templates. - Denial tests added (anonymous, other player, wrong-scope ST).
Form
- Explicit field list; chronicle choices limited to
readable_chronicles(user). - No Bootstrap widget classes; errors reach
core/tl/field.html. - Character CRUD field lists come from
crud_fields.py; baseline JSON updated on purpose.
Template
- Extends a Spread shell; sets the
gamelineblock; usestltags and partials. - No inline styles,
<style>, inline scripts, Bootstrap, jQuery ortg-card. - Assets through
{% static %}; user text through|sanitize_html. - Permission-dependent controls read
object_perms, not role logic. - htmx fragments marked (
mark_fragment) and varied (vary_on_htmx).
Cache
- Only
cache_page_per_visitoror the cached view classes; no query-string reads; no{% csrf_token %}on a cached page without a form.
Tests and style
- Tests for each behaviour and each denial; guard ratchets untouched or justified.
- New character type: query ceiling, chargen golden fixture, detail router and CRUD baseline updated.
-
pre-commit run --all-filesclean.
References
| File | Read it when |
|---|---|
| models.md | Adding or changing any model, field, manager or relation |
| schema-changes.md | Anything that changes tables, columns, constraints or stored data |
| validation.md | Writing clean(), constraints, transactions, status changes |
| permissions.md | Choosing a route policy, checking object access, limited forms |
| views.md | Writing a view, action endpoint, router or htmx fragment |
| forms.md | Writing a form or a character CRUD field list |
| urls.md | Adding a route or a URL name |
| registry.md | Adding or changing an item or location type |
| templates.md | Writing a template, partial, tag use, htmx markup |
| spread.md | Spread blocks, components, tokens and page structure |
| caching.md | Caching a page, a function result or a reference list |
| testing.md | Writing or placing tests, fixtures, budgets, browser tests |
| code-style.md | Formatting, linting, imports, pre-commit |
| commands.md | Writing a management command |
| character-templates.md | Working on CharacterTemplate and its views |
| model-inventory.md | Finding where a model family lives and its base |
| domain.md | Game terms mapped to models, codes and statuses |
| deployment.md | Making a change deployable (settings, static, schema) |