# 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//` 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`): ```sql CREATE ROLE wikijs_ro LOGIN 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//", 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 `