Skip to content

Wagtail integration - #707

Closed
nofurtherinformation wants to merge 48 commits into
devfrom
wagtail-cutover
Closed

Wagtail integration#707
nofurtherinformation wants to merge 48 commits into
devfrom
wagtail-cutover

Conversation

@nofurtherinformation

Copy link
Copy Markdown
Collaborator

Agentic rough out of CMS/DMS.

nofurtherinformation and others added 30 commits June 10, 2026 19:52
Prep for the Wagtail CMS service sharing this database:

- districtrmaps_to_groups and districtrmap_overlays get a surrogate
  integer id PK (composite PKs block row editing in Django admin
  forms); the original column pairs remain as UNIQUE constraints.
- alembic env.py include_object now ignores the `admin` schema and
  django_/auth_/wagtail/taggit_/token_blacklist_ table prefixes so
  autogenerate never proposes dropping CMS-owned tables. Also removes
  a stray debug print.
- Declare idx_districtrmap_overlays_overlay_id on the model so
  autogenerate stops proposing to drop it (pre-existing drift).

Verified: alembic upgrade on local PostGIS db; autogenerate produces
no junction-table or django-table ops (tested with decoy django_*/
admin-schema tables); full backend suite 256 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New top-level cms/ Django 5.2 + Wagtail 7.0 LTS project, deployed as a
third Fly app (districtr-v2-cms[-dev]) sharing the existing Postgres:

- All Django/Wagtail tables live in a dedicated `admin` schema via
  search_path (bootstrap_schema management command creates it before
  migrate; runs in the Fly release command and compose entrypoint).
  Verified: 63 tables in admin, zero in public, and backend alembic
  autogenerate stays clean with the Wagtail tables present.
- Locales configured for en/es/zh/vi/ht/pt (wagtail-localize).
- /health endpoint with DB check for Fly http checks and compose.
- Prod image: gunicorn + whitenoise (collectstatic verified in build);
  dev: runserver via docker-compose `cms` service on :8001.
- CI: test-cms.yml (postgis service, checks, migrate, missing-migration
  gate, tests) and fly-deploy-cms.yml mirroring the api workflows.

Part of the Wagtail cutover (plan: CMS + user management replacing
Auth0, data admin over existing tables, content migration).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cms/authapi replaces Auth0 as the token issuer:

- KidTokenBackend adds the RFC 7638 thumbprint of the verifying key as
  the JWT kid header (PyJWKClient in the FastAPI backend selects keys
  by kid; SimpleJWT emits none by default).
- /.well-known/jwks.json serves the active verifying key (+ optional
  JWT_NEXT_VERIFYING_KEY during rotation).
- Token claims: sub, email, name, roles, and a space-delimited scope
  claim derived from group membership (admin/editor/reviewer/partner,
  created by data migration) mirroring backend TokenScope verbatim.
- /api/token/ (throttled 10/min) + /api/token/refresh/ with rotation
  and blacklist; 10-min access, 14-day refresh.
- Management commands: generate_jwt_keys, provision_users (CSV +
  password-setup email; Wagtail has no invite flow), issue_service_token
  (replaces the Auth0 client-credentials path).
- Contract tests replicate the FastAPI verifier exactly (kid-based JWKS
  key selection, audience/issuer, scope claims): 11 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Settings: AUTH0_DOMAIN/AUTH0_API_AUDIENCE/AUTH0_ISSUER/AUTH0_ALGORITHMS
  become AUTH_JWKS_URL/AUTH_AUDIENCE/AUTH_ISSUER/AUTH_ALGORITHMS — the
  verifier is issuer-agnostic (any JWKS + RS256 + scope claim).
- Remove the Auth0 gty=client-credentials scope bypass: service tokens
  now carry explicit scopes (cms issue_service_token) and are checked
  like any other token.
- Cross-service contract test (tests/test_auth_contract.py) mints
  tokens exactly as cms/authapi does — same claim layout, RFC 7638 kid,
  PyJWKClient kid-matching kept in the loop — and asserts scope
  enforcement, expiry, audience/issuer, unknown-kid and bad-signature
  rejection. Mirror lives in cms/authapi/tests.py.
- Env files and test-backend.yml updated to the new variable names.

cms/, comments/, thumbnails/ endpoints unchanged — scopes flow through
SecurityScopes exactly as before. Full backend suite: 266 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- @auth0/nextjs-auth0 removed; next-auth v5 (beta.31) added.
- src/auth.ts: Credentials provider POSTs to the CMS /api/token/;
  jwt callback silently refreshes via /api/token/refresh/ 60s before
  expiry, persisting BOTH rotated tokens; refresh failure sets an
  error flag that forces re-login.
- New /auth/login credentials page (Radix UI, sanitized returnTo) and
  /auth/logout route preserve the existing URL contract
  (useAuthRoutes); NextAuth endpoints live under the same /auth
  basePath.
- lib/auth0.ts replaced by lib/auth.ts exporting the same
  ClientSession shape — factory.ts, cms.ts, generateThumbnail.ts,
  Providers, cmsFormStore needed only import-line changes;
  PermissionGuard (scope-claim parsing) untouched.
- proxy.ts gates /admin on the NextAuth session; roles come from the
  token's roles claim (AuthButton, admin layout updated).
- Env: CMS_URL / NEXT_PUBLIC_CMS_URL / AUTH_SECRET replace AUTH0_* in
  .env.docker.example.

Verified: bun run build passes with zero type errors; no @auth0
imports remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AUTH_SECRET is a Fly runtime secret; the deploy workflow now injects
NEXT_PUBLIC_CMS_URL/CMS_URL per branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cms/datastore mirrors six Alembic-owned public tables with
managed=False models (Django never issues DDL; Alembic stays sole
schema owner): districtrmap, gerrydbtable, map_group, overlay, and the
two junction tables (using their new surrogate id PKs).

- Wagtail "Data" menu (SnippetViewSetGroup): DistrictrMap with
  filters/search and panelled edit form (uuid read-only), full CRUD for
  overlays/groups/junctions, read-only GerryDB tables via a
  deny-all-writes permission policy.
- Postgres enums mirrored as TextChoices verbatim from
  backend/app/models.py (incl. GeoUnitType's "bg" value); JSONB →
  JSONField, arrays → ArrayField; all FKs DO_NOTHING + db_constraint
  False. Partitioned/document-schema tables deliberately unmapped.
- check_mirror_drift command compares mirrors against
  information_schema (names + nullability, per-table excluded-columns
  allowlist); wired into test-cms.yml after running the backend's
  alembic migrations, with backend schema paths added to the workflow
  triggers.
- Permissions: admin group granted all datastore perms via data
  migration; other groups none by default.

Verified: 26 cms tests green, drift check passes against the live dev
db, makemigrations --check clean, authenticated smoke test of all six
admin views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /api/admin/gerrydb/import (scope create:districtr_maps) lets the
CMS admin trigger GeoPackage imports over HTTP, replacing the
engineer-only CLI flow. Reuses management.load_data.import_gerrydb_view
verbatim via BackgroundTasks; the task owns its session (request
sessions close at teardown). Early 422 validation restricts layer and
table names to SQL-identifier-safe characters and requires a .gpkg
path.

Also fixes a latent circular import: management/load_data.py imported
get_session from app.main (it lives in app.core.db) — mounting the new
router surfaced the cycle through cli.py.

19 new tests (auth required, scheduling args, identifier validation,
session ownership, failure logging); full suite 285 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cms/content replaces the legacy FastAPI cms module's authoring model:

- StreamField blocks mirror the TipTap custom nodes verbatim
  (boilerplateNode, sectionHeaderNode, planGalleryNode, formNode,
  mapCreateButtonsNode, commentGalleryNode) with the exact camelCase
  attribute names from app/src/app/constants/cms.ts, so the existing
  React components consume block values as props unchanged. Map-slug
  attrs are lazy ChoiceBlocks over the datastore mirrors.
- TagPage/PlacePage under per-locale TagsIndexPage/PlacesIndexPage;
  lookup = type + slug + locale, matching the legacy unique key.
  PlacePage keeps districtr_map_slugs as ArrayField (round-trips the
  legacy varchar[]). wagtail-localize translations share
  translation_key; Wagtail live/draft replaces published/draft columns.
- Public compat API: GET /api/content/{tags|places}/slug/{slug}
  (live-only, en fallback, available_languages, CORS) and /list,
  mirroring the legacy response semantics.
- migrate_tiptap command: reads cms.tags_content/places_content via
  raw SQL, converts ProseMirror docs (custom nodes -> blocks; prose
  runs -> rich_text HTML), en page canonical + localized translations,
  published -> live revision + draft -> unpublished revision,
  idempotent by (type, slug, locale). --dry-run emits per-row node/
  block counts and a normalized text diff; any text loss fails the run.

Verified: 61 cms tests green; live 4-row fixture migration with
dry-run gate, compat API curls (en/es/fallback/404), and idempotent
re-run (0 created, 4 unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- utils/api/cmsContent.ts: typed client for the CMS content API
  (server-side CMS_URL for SSR-in-docker, NEXT_PUBLIC_CMS_URL in the
  browser); body typed as a discriminated union over the StreamField
  block types, gallery/button values typed as the existing component
  prop types.
- StreamRenderer maps blocks to the SAME components the TipTap
  renderer used: rich_text/boilerplate HTML through the existing
  html-react-parser pipeline + domNodeReplacers, section_header ->
  ContentHeader (keeps HeaderSecondTierNav h2 anchor scan working),
  plan_gallery/comment_gallery/form/map_create_buttons -> existing
  React components with camelCase props passed through.
- tag/place detail + tags/places index pages switched to the new
  client; LanguagePicker fed by available_languages; en fallback
  delegated to the CMS; URL structure and revalidate unchanged.
- Legacy cms.ts/RichTextRenderer/TipTap editor left untouched for the
  decommission workstream. Known follow-up: PlaceMap/utils.tsx still
  uses the legacy list client (needs per-place districtr_map_slugs,
  not exposed by the new list endpoint yet).

Verified: bun run build zero type errors; rendered HTML greps of
/tag/fair-maps-co (en + es cookie), /place/massachusetts, index pages,
and draft-only 404 against the live dev stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wagtail admin gains two pages inside the Data menu (admin group only):

- Import GeoPackage: upload a .gpkg (or point at an existing s3://
  path) + layer/table name; streams to gerrydb-uploads/ in the bucket
  (R2-vs-S3 conditional mirrors the backend's env contract) and
  schedules the import via POST /api/admin/gerrydb/import using an
  in-process 15-minute service token (scope create:districtr_maps).
- Thumbnails: trigger map/document thumbnail regeneration via the
  existing backend endpoints (scope create:content).

Also fixes two latent provision_users bugs that would have broken
cutover-day account setup emails: PasswordResetForm silently skips
unusable-password users (exactly what provisioning creates) and
crashes without a request when contrib.sites is absent — now uses an
AccountSetupForm subclass and derives the link domain from
WAGTAILADMIN_BASE_URL. Branded password-setup email templates added.

BACKEND_API_URL added to settings/env/fly.toml. 97 cms tests green;
end-to-end mint->POST->verify wiring proven against the live compose
backend (expected 403 with an ephemeral dev key).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New cms/galleries app replicating the power-user fork's gallery
sections (consultant drafts, public gallery, works in progress, COI
gallery):

- Gallery snippet (Workflow+DraftState+Revision mixins, Clusterable)
  with section, visibility (public|group_only), optional MapGroup FK
  scoping hook, rich-text description, and orderable entries
  referencing Document public_ids.
- Own "Galleries" admin menu (Data group is admin-only and would never
  render for partners). Permission split via data migration: partners
  add/change (draft-only — no publish_gallery perm), editors/admins
  publish; wagtailadmin.access_admin granted to all four groups (was
  granted nowhere; provision_users expected it).
- Public API: GET /api/galleries/{slug} (live only; group_only
  requires a valid Districtr-issued Bearer token — role/group matching
  left as the documented next step) and a list endpoint with
  section filtering + entry counts. CORS like the content API.
- Next.js /gallery/[slug] page renders title/description and the
  existing PlanGallery component with entry public_ids.

Verified: 115 cms tests green (18 new); live API curls (200/403/404,
CORS, token from /api/token/); bun build clean; SSR page renders the
demo gallery and 404s unknown slugs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Partial state from the decommission workstream, interrupted by a usage
limit. Done so far: backend/app/cms/ deleted, router import removed
from main.py, env.py drops the model imports and now skips the cms
schema in autogenerate (legacy cms.tags_content/places_content must
survive for migrate_tiptap), test_cms.py deleted.

NOT yet done/verified: backend suite run after deletion, autogenerate
check, cms list-endpoint extension (districtr_map_slugs), app-side
removal (admin/cms pages, TipTap editor machinery, legacy cms.ts
client + PlaceMap/utils.tsx switch, admin/config.ts Wagtail link,
unused @tiptap deps), bun build, lint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Completes the decommission started in the WIP commit (backend cms
module deletion — verified: 266 passed, exactly the 19 deleted cms
tests fewer; autogenerate ignores the cms schema so the legacy
tags_content/places_content tables survive for migrate_tiptap).

App side:
- /admin/cms pages, ContentEditor, ContentList, ContentPreviewModal,
  RichTextEditor + TipTap node/nodeview machinery, RichTextRenderer
  (TipTap-JSON path), useCmsEditorConfig, and the legacy utils/api/cms
  client are deleted. Kept (used by StreamRenderer and static pages):
  PlanGallery, CommentGallery, MapCreateButtons, BoilerplateContent/
  NodeRenderer (now HTML-string-only, no @tiptap), HeaderSecondTierNav,
  DomNodeRenderers.
- cmsFormStore trimmed to the session slice (name kept to avoid churn
  in review/thumbnails/PermissionGuard/Providers/AuthButton).
- PlaceMap homepage store now uses the CMS list endpoint, which gains
  districtr_map_slug(s) fields (cms/content/api.py + tests).
- Admin landing CMS card links to the Wagtail admin
  (NEXT_PUBLIC_CMS_URL/admin/).
- All 9 tiptap packages removed from package.json.

Verified: cms suite 116 green; bun run build zero type errors (after
clearing stale .next route types); homepage 200; pre-commit Passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /api/admin/districtr-map/compose (scope create:districtr_maps)
builds a complete DistrictrMap from layers already registered in
gerrydbtable, replacing the multi-step CLI flow: shatterable view
(when a child layer is given, named <slug>_shatterable), create map
(hidden by default), extent, parent-child edges, optional group
assignment — chained in a background task that owns its session,
mirroring the gerrydb import endpoint.

In-request validation: slug/layer regexes (422), unknown layer/group
(404), duplicate slug (409) — nothing schedules on failure.

+28 tests asserting status codes, exact step order/kwargs for both
shatterable and single-layer variants, and logging. Full backend
suite: 294 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two new Data-menu pages in the Wagtail admin:

- Upload overlay (perm datastore.add_overlay): .geojson/.pmtiles file
  (or existing s3/http path) -> S3 overlays/ prefix -> public URL via
  OVERLAY_PUBLIC_URL_BASE (tilesets CDN in prod) -> Overlay row +
  selected map attachments in one transaction. pmtiles requires
  source_layer; custom_style JSON validated.
- Compose map module (perm datastore.add_districtrmap): existing
  gerrydb layers as parent/child dropdowns + slug/districts/tiles/
  group; calls the new backend compose endpoint with a service token;
  modules are created hidden until reviewed.

Plus three external menu links (Comment review, District comments,
Thumbnails) pointing at the Next.js admin via FRONTEND_URL, shown to
reviewer/editor/admin — one pane of glass for all review processes.

46 new tests; full cms suite 162 green; both pages and menu items
smoke-tested live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reviewers can now be limited to specific comment tags, managed in the
Wagtail admin and enforced by the backend:

- cms: ReviewTagAssignment (user + tag_slug, admin-managed "Review tag
  scopes" snippet). Tokens mint a sorted review_tags claim when the
  user has assignments (absent = unrestricted, back-compat for
  internal reviewers); scopes_for_user strips read:read-all for
  assigned non-admin users so the claim actually bites; rides the
  refresh token through silent refresh.
- backend: allowed_review_tags helper (read:read-all or absent claim =
  unrestricted). /admin/list applies an at-least-one-allowed-tag
  EXISTS filter (the existing tags param uses AND semantics and would
  have over-restricted multi-tag reviewers; untagged comments are
  invisible to restricted reviewers) and intersects requested tags.
  /admin/review 403s on out-of-scope tags/comments and on commenters
  (not tag-scoped). District comments are tag-less: restricted
  reviewers get an explicit 403.

Tests: +6 cms (claim minting, scope stripping, refresh propagation),
+12 backend (list filtering, intersections, read-all override, review
403s, district-comments policy), contract tests carry the new claim.
cms suite 168 green; backend 306 passed; lint clean.

Note: 3 pre-existing order-dependent TestCommenterEndpoint failures
reproduce on a targeted subset run without these changes; they pass in
the full suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes the 10 verified findings from the branch review, most-severe
first:

- Refresh-token races (forced logout every 10 min): CMS keeps rotation
  but drops BLACKLIST_AFTER_ROTATION (RSCs cannot persist rotated
  cookies, so single-use refresh tokens deterministically brick
  sessions); frontend adds a single-flight refresh guard and narrows
  the middleware matcher to /admin only.
- Editors locked out of all content pages: data migration grants
  GroupPagePermission rows (add/change/publish on root) to editor and
  admin; own-content-only editing recorded as a deferred product
  decision.
- Empty published gallery listed EVERY document (drafts included):
  empty-state render + getPlans empty-array guard.
- Rich text authored in Wagtail served unexpanded <a linktype>/<embed>:
  FrontendRichTextBlock applies expand_db_html in the API
  representation (rich_text + boilerplate.customContent); dead
  'underline' feature removed.
- Admin store froze a 10-minute token: new /auth/token route (runs the
  refresh in a cookie-writable context) + 4-minute session polling in
  Providers.
- group_only galleries 404'd for everyone: gallery pages are dynamic
  now and forward the session bearer with no-store (never cached
  shared).
- Stale /admin/cms redirect and footer link -> /admin.
- Non-English-only pages vanished from indexes: listCMSContent no
  longer forces language=en; list endpoint contract pinned by test.
- Wagtail review links now match each page's scopes (editor loses
  comment-review links, reviewer loses thumbnails; tag-scoped
  reviewers don't see District comments).
- Alembic include_object: prefix regex removed in favor of the schema
  contract (it would have silently excluded future public tables named
  content_*/auth_*/etc.).

Plus verified cleanups: review-tag enforcement now flows through a
review_auth dependency (unforgettable for future endpoints); admin_ops
session-branch duplication collapsed via nullcontext; PermissionGuard
uses a shared base64url-safe decodeJwtPayload; dead scope plumbing,
duplicate sanitizeReturnTo, unused listGalleries, and the store's
unused subscribeWithSelector removed.

Verified: backend 306 passed; cms 175 tests green (+7); frontend build
zero type errors with runtime smoke (gallery/tag/admin redirects);
alembic autogenerate clean against live Wagtail tables; lint passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _post_backend collapses the four backend-POST functions; all of them
  now surface the backend's JSON detail in errors (previously only
  compose did).
- _upload collapses the gpkg/overlay upload duplication.
- mint_service_token moves to its canonical home in authapi.tokens;
  the management command and datastore services delegate to it.
- Shared cms/core/api.py provides the CORS/JSON response helpers used
  by both the content and galleries public APIs.

175 cms tests green; no behavior changes beyond improved error detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hand-off notes for future sessions: deferred product decisions
(editor own-content-only, group_only gallery enforcement, refresh
blacklist trade-off), small functional follow-ups, measured perf
items, infra consistency work, and the operational cutover checklist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rge head

- place/portal generateMetadata used the legacy getCMSContent(slug, lang, type)
  arg order and published_content shape; align with the Wagtail client
- TestReviewTagScoping still sent recaptcha_token (renamed to turnstile_token on dev)
- test_config monkeypatched ACCOUNT_ID, which no longer exists in Settings
- alembic merge revision for the dev + wagtail-cutover heads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ening

Review findings: scoping was enforced by enumerating dangerous views, and
Wagtail has more object-level endpoints than were enumerated — anything not
on the list defaulted to open.

- Pages: register before_unpublish/copy/move_page and before_bulk_action
  (bulk delete/publish/unpublish fire ONLY the bulk hook)
- Galleries: scoped Copy view (stock CopyView prefills via bare
  get_object_or_404 with an unrestricted map_group chooser) and the generic
  before_unpublish hook (UnpublishView checks only model-level publish)
- Datastore: History/Usage views get the same 404 guard as Inspect
- PlacePageForm no longer silently drops other teams' maps (or their curated
  order) from shared place pages
- content/galleries APIs: negative limit no longer 500s (shared clamped
  pagination in core/api.py); available_languages deduped
- Trim: TeamScopedViewSetMixin + instance_in_scope collapse the per-app
  scoping copies; 47-line Wagtail base.html copy replaced with a recursive
  same-name extends; dead Team.slug field removed; stale e2e endpoint removed
- Regression tests for each closed path (212 cms tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, StaticPage

Product decisions (Dylan, 2026-08-04):

- Editors edit only their own content: content/0004 revokes the editor
  group's tree-wide change_page; add_page + Wagtail's owner model grant edit
  on owned pages, publish_page applies only to those. migrate_tiptap gains
  --owners "auth0|sub=email,..." to set Page.owner from the legacy author
  column (Auth0 subjects) so pre-cutover content stays editable.
- group_only galleries enforced via Teams: JWT gains a map_groups claim
  (user's teams' MapGroup slugs); the gallery API requires the gallery's
  map_group in that claim or the admin role — any-valid-login no longer opens
  restricted galleries.
- StaticPage type (+ StaticIndexPage) with /api/content/static/... served by
  a Next.js /[slug] catch-all; hardcoded routes take precedence, so static
  pages migrate into the CMS one at a time.
- District-comments 403 for tag-scoped reviewers: decided to keep as-is.

FOLLOWUPS doc updated; cms 220 tests, backend suite, typecheck + build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
listCMSContent pages through the server's 100-row cap so /places, /portals,
and the homepage PlaceMap don't silently truncate once slug x language rows
exceed one page. Remaining files: ruff/prettier auto-fixes from pre-commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
nofurtherinformation and others added 18 commits August 5, 2026 08:26
Narration and duplicated context only — every contract comment (schema split,
JWT blacklist trade-off, alembic exclusion, block naming) stays:
- settings DATABASES / teams docstring / config s3_bucket: deduplicate against
  their module docs
- datastore hooks: drop FK-widget inline comments the module docstring covers
- content forms + hooks docstrings tightened
- Providers: drop a line narrating the guard clause

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m infra

Fly is being deprecated; the CMS now deploys to ECS like everything else:
- infra/cms.ts: one small Fargate task (512/1024, no autoscaling — ~20 admins,
  ISR-shielded public reads) + cms-migrate release task (bootstrap_schema +
  migrate), task-role S3 access to the GPKG bucket
- ALB: cms target group (health /healthz), host rule for cmsDomain, cert SAN,
  DNS record in the dnsRecords output; cms SG + RDS ingress from it
- backend/frontend task envs swapped from Auth0 to the CMS issuer
  (AUTH_JWKS_URL/AUTH_ISSUER/AUTH_AUDIENCE; frontend: AUTH_SECRET + CMS_URL);
  auth0* config keys removed from config.ts and both stack files
- cms: /healthz middleware answers ALB probes before host validation (static
  200, no DB — mirrors the backend TG decision); get_s3_client falls back to
  the task role via AWS_USE_DEFAULT_CREDENTIALS
- .github/workflows/deploy-cms.yml mirrors deploy-api.yml
- FOLLOWUPS checklist step 2 rewritten AWS-first

Pending human steps: pulumi config set --secret djangoSecretKey, jwtSigningKey,
jwtVerifyingKey, authSecret, resendApiKey (per stack) and the cms.* DNS records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New cms/moderation app: comment review, district-comment review, and the
under-construction toggle are thin Wagtail views over the existing FastAPI
admin endpoints, authenticated with a short-lived per-user JWT
(authapi.serializers.mint_user_access_token) so scope and review_tags
enforcement stays in the backend. Replaces the external "Comment review"
menu link (FRONTEND_URL is gone from settings/env/infra) and the entire
app/src/app/admin tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
authapi.0007 retires editor and reviewer (members move to partner): partner
now covers the whole partner-org role — own-content page editing (root
add_page, owner-model edits, no publish: that goes through admin review),
gallery drafting, and comment moderation (scopes.py grants
create:content_review). New super_partner adds the datastore map-module
permissions; GPKG import splits onto its own add_gerrydbtable gate and
stays admin-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Map submissions (feature list: partners manage map submissions): the
backend admin comments list gains a has_document filter, and a new
Wagtail queue lists plan-carrying comments with approve/reject plus
one-click add-to-gallery — the plan lands as a draft revision (entries
live in revision content) scoped to the user's team galleries.

Review//approve: content.0007 provisions the "Admin approval" Workflow
(GroupApprovalTask for admin) on the page tree and Gallery snippets, so
partner drafts ship via Submit for moderation. content.0006 pre-provisions
the six content-language Locale rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
545e708aeb30 accidentally dropped the ON DELETE CASCADE that dc0216fef023
put on the districtrmaps_to_groups FKs (its own downgrade restores it), so
deleting a DistrictrMap or MapGroup died with an IntegrityError from the
link table; c8f3a1d92e47 restores both cascades. e5b7c2f81a93 does the same
for parentchildedges (derived per-map rows; the empty partition left behind
is cosmetic). The document FK keeps NO ACTION deliberately — maps with
saved plans must not be deletable — and the Wagtail delete/bulk-delete
paths now refuse those with a message instead of a 500.

Also: BACKEND_API_URL in cms/.env.docker (moderation views were calling the
cms container itself, hence Django 404s on /api/comments/admin/list) and
fix the multi-line {# #} template comment in the admin base override that
rendered as literal header text (Django comments are single-line).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The org/access boundary moves off MapGroup (districtr_v2-i06): Team gains
a slug (authapi.0008) minted as the JWT `teams` claim; galleries get a
required, real `team` FK (galleries.0003 — structurally closing the
ownerless group_only gallery hole, districtr_v2-rqz); and teams own map
modules directly through TeamDistrictrMap, with existing TeamMapGroup
ownership expanded one row per group member map at migration. The scoping
engine (authapi/teams.py) now keys on team ids/slugs; galleries, pages,
map modules, and the moderation add-to-gallery flow follow through their
per-resource lookups. MapGroup and its junction stay as the product
listing facet only — no access control reads them anymore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arity

migrate_tiptap had a critical silent-loss bug: legacy rows wrap the
ProseMirror doc in {title, subtitle, body}, the converter expected a bare
doc, and every page migrated EMPTY while the fidelity check compared "" to
"". The converter now unwraps (or hard-fails on unknown shapes, naming the
row), maps wrapper title/subtitle onto the page, and the real run lands
54 created / 1 updated with zero text loss and clean idempotent reruns.

Preview and View-live no longer 500 (districtr_v2-nti): content pages set
preview_modes=[] and generate frontend URLs (FRONTEND_URL restored to
settings) — Wagtail never serves these pages. Index pages are provisioned
by migration (content/0008, shared provision.py) and locked with
parent_page_types/max_count. plan_gallery gains curated-gallery wiring: a
gallerySlug choice on the block, frontend fetch of /api/galleries/<slug>,
entry ids as the plan filter. Other blocks verified at parity with the
legacy TipTap components.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n UX

The DistrictrMap edit page now manages everything about a module in one
place: overlay links, map-group listings, and (admin-only) team
assignments as inline formsets saved atomically with the map, plus a
regenerate-thumbnail button. The two link-table snippet listings leave
the menu; the Thumbnails tool is document-only.

New role-aware dashboard panel (core/wagtail_hooks.py) surfaces each
group's next actions; Reports hidden for non-admins; Districtr palette
via insert_global_admin_css. Moderation: "Flagged comments" menu item,
tag-scoped reviewers no longer see the buttons/links the backend 403s by
design, and deleting a Team that owns galleries gets a friendly refusal
instead of a PROTECT 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t import

Menus now say what you do, not what tables exist: "Map modules" (Create map
module first, then Edit map modules / Edit overlays / Map groups / Upload
overlay / Plan thumbnails), one "Review" submenu (comments, flagged queue,
map submissions), and a "Site content" submenu with direct entry points to
edit portal, place, and static pages (place pages had no discoverable path).

Create map module is one page end-to-end: overlay selection rides the
compose request (backend gains overlay_ids on /districtr-map/compose and
attaches the links in the compose task), slug collisions are caught in the
form instead of a backend 4xx, and a team-scoped composer's new module is
auto-assigned to their teams (short poll for the async row; falls back to a
message when composition is slow).

Removed for now: GPKG import UI (raw data uploads deferred; service plumbing
and the backend endpoint stay), the GerryDB tables listing, and district
comment review (portal comment review remains the moderation surface).

content/0010 wraps migrate_tiptap as a REVERSIBLE data migration: forward
no-ops without the legacy tables and is idempotent; reverse deletes exactly
the pages matching legacy (slug, language) rows. Round-tripped against the
dev DB: 55 deleted on reverse, 55 restored on re-apply, hand-made pages
untouched. Also fixes the admin-header comment leak (nested {# #}).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…te buttons

TagPage (portal) map selection is a proper dropdown for every role — admins
included, who previously typed slugs freehand — with team scoping narrowing
the choices as before and saved-but-missing modules kept selectable.
PlacePage gains an orderable multi-select widget (add dropdown + move
up/down/remove, posted as ordered hidden inputs): the saved order is the
display order on the public place page, and a scoped member's reordering
now actually applies, with other teams' modules pinned to their original
positions.

MapCreateButtons blocks render the same MapStartCard grid used on place
pages (megaphone keeps its banner, cards inside), and portal pages get
vertical padding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review is now one flow matching how partners think: pick a portal you have
access to (team-scoped via the portal's map module, further narrowed by
ReviewTagAssignment — a portal's page slug is its comment tag), then review
its submissions with a Comments / Map submissions toggle. The portal
supplies the tag filter; flagged/status/commenter filters and the
add-to-gallery action carry over. The standalone comment, flagged, and
map-submission queues and their menu items are gone.

Menu trims: Plan thumbnails tool and Map groups listing removed (map
thumbnails still regenerate from the module edit page; map_group remains a
backend listing facet); Wagtail's Reports relabelled "Admin analytics" for
admins (still hidden for everyone else).

Wagtail's stock "Welcome to your new Wagtail site!" home page is renamed
"Districtr" (content/0011, reversible) — the level itself is structural.
Static catch-all pages get the same vertical padding as portals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review tag scopes are gone as a concept: the JWT review_tags claim is now
derived from the user's teams' portals (a portal's page slug is its comment
tag slug) — creating a portal IS the assignment. Admins carry no claim;
team-scoped users get their portal slugs; team-less non-admins get an empty
claim, which the backend treats as "allows nothing" — moderation now fails
CLOSED until a partner joins a team. Partner scopes drop read:read-all
(the backend's unrestricted-read escape hatch that would have neutered the
claim). ReviewTagAssignment, its snippet UI, and the two-mechanism split
are deleted; the content API injects the portal slug into comment-form
blocks' mandatory tags so tagging never depends on author discipline.

Galleries are no longer a separate abstraction: the plan_gallery block's
ordered `ids` list IS the curated gallery, living on the portal/static page
itself — page team-scoping owns access, the page workflow owns approval,
and the frontend reads entries straight from the page body (no second
fetch; group_only visibility is retired with the model). The review queue's
"Add to portal gallery" appends to the portal page's own gallery block as a
draft revision. The galleries app survives as migration history only
(authapi/0007 and content/0007 depend on it); galleries/0004 drops the
tables after content/0012 folds existing gallerySlug references into
inline ids (dev's demo gallery converted cleanly). The teams JWT claim,
/api/galleries/*, and the /gallery/[slug] route go with it.

Dashboard shortcuts are now action cards whose labels match the sidebar
actions exactly; the raw Pages tree is hidden for non-admins (Site content
covers it); the shortcuts panel gets padding and loses another multi-line
template comment rendering as text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…text

Overlay dropdowns everywhere now read "Name — line · pmtiles (file)" so the
line/text/fill overlays sharing a data source are tellable apart, and sort
by (name, layer type) so a source's pair sits adjacent. Upload overlay
takes multiple layer types in one submission — the frequent line+text case
creates both overlays from the same source, type-suffixed, each attached
to the selected maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First-pass consolidation before anything ships: 27 migrations across four
apps collapse to 6 that express only the end state — three regenerated
initials (authapi, content, datastore), authapi/0002_provision_roles (the
three groups and every grant), content/0002_provision_site (locales, index
pages, home rename, admin-approval workflow), and
content/0003_import_legacy_content (the reversible tiptap wrapper). The
galleries app is deleted outright — nothing needs its migration history
once the graph is rebuilt — along with the gallery_slug_choices stub kept
only for a serialized migration reference. The intermediate data
migrations (editor/reviewer consolidation, TeamMapGroup conversion,
gallerySlug folding) exist nowhere fresh installs can need them; the dev
database was rebooked with --fake and its stale galleries content-type
rows purged. The full test suite rebuilds the fresh chain every run.

DRY: GroupMenuItem moves to core/menu.py (moderation + content share it);
the per-module test factory copies (PASSWORD, make_user, make_admin_user,
make_team, make_portal, create_mirror_tables) consolidate into
core/testing.py; migration-number prose updated to the squashed names.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…licy

251 -> 235 tests: gone are the assertions that mirror configuration back
at itself — menu label/order/url lists, exact dashboard card enumerations
per role (replaced by one gating test: partner ⊂ super_partner ⊂ admin),
per-view repeats of the same permission gate that ToolAccessTests already
proves once, Django-validator tautologies, historical removed-URL checks,
and redundant tag-scoped UI variants. Added one unit test for the shared
GroupMenuItem gating primitive.

What stays untouched is the half of the suite that is genuinely
mission-critical: the cms<->backend JWT contract, team-scoping enforcement,
converter/migration data fidelity, cross-service payload shapes, delete
guards, and product-decision encodings. The policy is now written down in
AGENTS.md + CLAUDE.md: test boundaries and decisions, never configuration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant