ixg_platform/docs/USER_GUIDES_VIEWER_SPEC.md
ixgadmin ee4f7b5232 Help centre: portal-scoped guide audiences, and links that resolve
Guide visibility is now scoped by PORTAL first and role second:

- the buyer portal lists shared and buyer guides, even for an admin, because it
  is the buyer portal; admin guides never surface there
- the ops portal lists shared and ops guides, plus admin guides for an
  admin/superuser
- the detail view names its context object, so guide pages get a real browser
  title instead of a blank one

Link rewriting, so one page can serve both the wiki and the in-app help centre:

- absolute /en/user-guides/... links become the in-app help path
- other absolute /en/... links point at the wiki itself; the app hosts serve no
  /en/ routes, so leaving them untouched was a 404 for whoever clicked
- relative links keep their existing behaviour

Tests cover the new rewriting and the portal scoping rules. Also adds
docs/USER_GUIDES_VIEWER_SPEC.md, the spec this viewer was built to.
2026-09-16 16:39:07 +00:00

9 KiB

User Guides Viewer (Help Centre) — Implementation Spec

Status: Ready for implementation (approved by IXG) Author: Hermes (ixg-docs) — for OpenCode to implement Repo: /root/work/ixg_platform (Django 5.2, project package dsp_platform, apps under apps/) Date: 07 Aug 2026


1. Goal

A read-only, role-gated help centre inside the IXG platform that shows the user guides authored in Wiki.js:

  • Buyers: /portal/help/ — the buyer guides
  • Ops users: /ops/help/ — the ops guides (+ admin guides for admin role)
  • Admin users: can reach both portals (admin can already access /portal/ and /ops/ per RBAC), so they see everything.

The Wiki.js pages under user-guides/ are the single source of truth. This feature is a one-way read/sync: the platform never writes to the wiki.


2. Source of truth (Wiki.js)

Pages live in the wikijs PostgreSQL database (same server, port 5432):

  • Table pages — columns of interest: path (e.g. user-guides/buyer/getting-started, no locale prefix), title, content (markdown), description, "isPublished", "isPrivate", "updatedAt".
  • The user-guides tree:
user-guides/index                   (shared landing)
user-guides/buyer/*                 7 buyer guides
user-guides/ops/*                   9 ops guides
user-guides/admin/*                 2 admin guides

Audience mapping (by path prefix):

Prefix Audience
user-guides/index shared
user-guides/buyer/ buyer
user-guides/ops/ ops
user-guides/admin/ admin

3. Architecture

3.1 New app apps/guides

Register in INSTALLED_APPS (settings.py). Contains:

Model GuidePage

Field Type Notes
path CharField, unique wiki path, e.g. user-guides/buyer/getting-started
title CharField from wiki title
audience CharField(choices: shared/buyer/ops/admin) mapped from path
content TextField markdown body from wiki
updated_at DateTimeField wiki updatedAt
synced_at DateTimeField when we last synced

Management command sync_guides (apps/guides/management/commands/sync_guides.py):

  1. Connect to the wikijs DB alias (read-only).
  2. SELECT path, title, content, "updatedAt" FROM pages WHERE path LIKE 'user-guides/%' AND "isPublished" = true AND "isPrivate" = false
  3. Map audience from path prefix; upsert into GuidePage by path.
  4. Delete GuidePage rows whose path no longer exists in the wiki (removed pages disappear from the help centre).
  5. Print a summary (created / updated / removed counts).

Views — role-scoped querysets, both mounted under the two portal namespaces:

  • GuideIndexView — lists guides the current user's role may see.
  • GuideDetailView — renders one guide; 404 if the page's audience is not allowed for the current user (defence in depth — the URL routes are already role-gated by RBACMiddleware, but never rely on that alone).

Scoping is by PORTAL first, then by role (revised 16 Sep 2026). Which guides are listed depends on the portal you are standing in, not only on who you are: the buyer portal is for buyers, so it shows buyer guides even to an admin. Admin-only guides are ops-side material and never appear in the buyer portal. Implemented as apps.guides.models.allowed_audiences(portal, role, is_superuser) — the single place the rule lives.

Portal Role Allowed guide audiences
/portal/help/ buyer shared, buyer
/portal/help/ admin / superuser shared, buyer (no change)
/ops/help/ ops shared, ops
/ops/help/ admin / superuser shared, ops, admin

Note the role gate on the portal itself (BuyerRequiredMixin / OpsRequiredMixin): only buyer + admin reach /portal/, only ops + admin reach /ops/. So the two viewers portal scoping actually distinguishes are real buyers and admins-who-can-see-everything.

Markdown rendering (in the view or a small helper apps/guides/markdown.py):

  • markdown.markdown(content, extensions=["tables", "fenced_code", "sane_lists"])
  • Sanitise with bleach.clean(html, tags=[...], attributes=[...], protocols=["http","https","mailto"]) — allowed tags: p br strong em ul ol li table thead tbody tr th td h1 h2 h3 h4 a code pre blockquote hr img span div.
  • Link rewriting: internal guide links in the wiki are relative markdown links like [Compliance guide](compliance) or [Compliance guide](compliance) (same-folder) — rewrite them to the current help root (/portal/help/<page-path-suffix>/ or /ops/help/...). Wiki image paths starting with /buyer-guide/ or /ops-guide/ should be rewritten to the wiki public base URL (setting WIKI_PUBLIC_BASE, default http://194.164.95.50).
  • Note: markdown-it-anchor headings are NOT available in this Wiki.js build, so the guides avoid anchor links — no anchor handling needed.

3.2 Database access to wikijs (read-only)

  • settings.py DATABASES gains a second alias wikijs, populated from env via django-environ (same pattern as the default DB): WIKI_DB_NAME, WIKI_DB_HOST, WIKI_DB_PORT, WIKI_DB_USER, WIKI_DB_PASSWORD.
  • Create a read-only PostgreSQL role (run once as postgres, e.g. via docker exec postgres psql -U postgres):
CREATE ROLE wikijs_ro LOGIN PASSWORD '<generate-a-strong-password>';
GRANT CONNECT ON DATABASE wikijs TO wikijs_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO wikijs_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO wikijs_ro;
  • Put the password ONLY in config/secrets/env.production (never in code, wiki pages, or this spec).

3.3 URL wiring

  • apps/buyer/urls.py (namespace portal): path("help/", GuideIndexView, name="guide_index") path("help/<str:page_path>/", GuideDetailView, name="guide_detail")
  • apps/ops/urls.py (namespace ops): same two routes.
  • Add a Help nav item to both base templates:
    • templates/portal/base_portal.html sidebar (bi bi-book icon) → {% url 'portal:guide_index' %}
    • templates/ops/base_ops.html sidebar (bi bi-book icon) → {% url 'ops:guide_index' %}

3.4 Templates (professional manual look)

  • templates/guides/:
    • index_portal.html — extends portal/base_portal.html, includes shared partial
    • index_ops.html — extends ops/base_ops.html, includes shared partial
    • detail_portal.html / detail_ops.html — same split
    • Shared partials _guide_list.html (index: cards or a clean list with titles + first paragraph preview) and _guide_detail.html (two-column: left = guide list for the role, right = rendered content, with the page title as heading and a breadcrumb/back link).
  • Use existing Bootstrap + stat-card styling so it looks native to each portal.
  • Empty state: "No guides available yet" when the list is empty.

4. Sync scheduling

Primary: host crontab (no app-level scheduler needed):

# every 6 hours
0 */6 * * * docker exec ixg-platform-app python manage.py sync_guides >> /var/log/ixg-guides-sync.log 2>&1

Manual refresh: docker exec ixg-platform-app python manage.py sync_guides.

Optional alternative (do NOT do both): a Celery beat task calling the same command. Pick cron.


5. Acceptance criteria

  1. sync_guides populates GuidePage from the wiki (verified by running it and querying the model).
  2. /portal/help/ lists the 7 buyer guides + the shared landing page, and nothing else — for every role that can reach it, including admin. Opening one renders tables, headings and the embedded screenshots.
  3. /ops/help/ lists the 9 ops guides + the shared landing page; an admin user also sees the 2 admin guides (12 rows).
  4. A buyer user cannot see an ops/admin guide (route + audience filter; 404), and an admin user cannot reach an ops guide through /portal/help/ (404, available via /ops/help/ instead).
  5. Internal guide links resolve within the help centre; images load from the wiki public base.
  6. The sync never writes to the wikijs database (read-only role enforced).
  7. python manage.py test passes (add tests: audience filtering, markdown sanitisation strips <script>, link rewriting, sync command mapping).
  8. Existing portal/ops pages unchanged apart from the added Help nav item.

6. Out of scope

  • Any write path back to the wiki.
  • Public (unauthenticated) access, subdomains, or a separate docs app.
  • Translating guides, theming, or search (nice-to-haves for later).
  • The admin area (/admin/) itself — admins use /ops/help/ for guides.

7. Implementation notes

  • Deps already present: markdown==3.7, bleach==6.2.0, psycopg2-binary.
  • Settings uses django-environ; env file config/secrets/env.production.
  • Project runs in Docker (ixg-platform-app); migrations run via docker exec ixg-platform-app python manage.py migrate.
  • Keep the audience mapping table in one place (model method or constant) so index/detail views share it.
  • Do not hardcode the wiki DB password anywhere; env only.