diff --git a/apps/guides/markdown.py b/apps/guides/markdown.py index 28a1bbe..499ce47 100644 --- a/apps/guides/markdown.py +++ b/apps/guides/markdown.py @@ -47,15 +47,44 @@ EXTENSIONS = ["tables", "fenced_code", "sane_lists"] WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/") +# Links authored in the wiki are absolute: `/en/user-guides/` is a guide that +# this help centre mirrors, while any other `/en/` is a wiki-only page. +WIKI_GUIDE_PREFIX = "/en/user-guides/" +WIKI_PAGE_PREFIX = "/en/" + _IMAGE_SRC_RE = re.compile(r'(]*?\bsrc=")([^"]*)(")', re.IGNORECASE) _ANCHOR_HREF_RE = re.compile(r'(]*?\bhref=")([^"]*)(")', re.IGNORECASE) +def _rewrite_absolute_anchor(prefix, href, suffix, help_root): + """Point an absolute wiki link at somewhere that actually resolves. + + These hrefs cannot be left alone. Every app host serves only its own + portal (/portal/, /ops/, /admin/) and has no `/en/` route, so an untouched + `/en/...` link is a 404 for whoever clicks it inside the help centre. + + - `/en/user-guides/` is mirrored in-app -> the in-app help path. + - any other `/en/` exists only in the wiki -> the wiki public base. + """ + path = href.split("#", 1)[0].split("?", 1)[0] + if path.startswith(WIKI_GUIDE_PREFIX): + target = path[len(WIKI_GUIDE_PREFIX):].strip("/") + if target: + return f'{prefix}{help_root}{target}/{suffix}' + return f'{prefix}{help_root}{suffix}' + if path.startswith(WIKI_PAGE_PREFIX): + return f'{prefix}{settings.WIKI_PUBLIC_BASE}{path}{suffix}' + return None + + def _rewrite_anchor(match, base_folder, help_root): prefix, href, suffix = match.groups() parsed = urlparse(href) - if parsed.scheme or href.startswith(("#", "/")): + if parsed.scheme or href.startswith("#"): return match.group(0) + if href.startswith("/"): + rewritten = _rewrite_absolute_anchor(prefix, href, suffix, help_root) + return rewritten if rewritten is not None else match.group(0) resolved = posixpath.normpath(posixpath.join(base_folder, href)) if resolved.startswith("user-guides/"): suffix_path = resolved[len("user-guides/"):] diff --git a/apps/guides/models.py b/apps/guides/models.py index c9e3c35..b386d81 100644 --- a/apps/guides/models.py +++ b/apps/guides/models.py @@ -50,8 +50,25 @@ class GuidePage(models.Model): return None -AUDIENCES_BY_ROLE = { - "buyer": {GuidePage.Audience.SHARED, GuidePage.Audience.BUYER}, - "ops": {GuidePage.Audience.SHARED, GuidePage.Audience.OPS}, - "admin": set(GuidePage.Audience.values), +# The help centre is scoped by PORTAL first, then by role. Which guides are +# listed depends on which portal you are standing in, not only on who you are: +# an admin browsing the buyer portal sees the buyer guides, because it is the +# buyer portal. The admin-only guides never surface there. +AUDIENCES_BY_PORTAL = { + "portal": frozenset({GuidePage.Audience.SHARED, GuidePage.Audience.BUYER}), + "ops": frozenset({GuidePage.Audience.SHARED, GuidePage.Audience.OPS}), } + +# Extra audiences unlocked by ROLE, per portal. Admin guides are ops-side +# material, so they are only ever added inside the ops portal. +ADMIN_EXTRA_AUDIENCES = { + "ops": frozenset({GuidePage.Audience.ADMIN}), +} + + +def allowed_audiences(portal, role=None, is_superuser=False): + """Guide audiences visible in `portal` ("portal" or "ops") for this user.""" + allowed = set(AUDIENCES_BY_PORTAL.get(portal, frozenset())) + if is_superuser or role == "admin": + allowed |= ADMIN_EXTRA_AUDIENCES.get(portal, frozenset()) + return allowed diff --git a/apps/guides/tests.py b/apps/guides/tests.py index 9ddd2f2..6fa8057 100644 --- a/apps/guides/tests.py +++ b/apps/guides/tests.py @@ -6,7 +6,7 @@ from django.test import TestCase, override_settings from django.utils import timezone from .markdown import render_markdown -from .models import GuidePage +from .models import GuidePage, allowed_audiences User = get_user_model() @@ -60,14 +60,80 @@ class GuideAudienceFilterTests(TestCase): self.assertNotContains(resp, self.buyer.title) self.assertNotContains(resp, self.admin.title) - def test_admin_index_sees_all(self): + def test_admin_index_on_portal_lists_buyer_guides_only(self): + """Portal scoping wins over role: the buyer portal shows buyer guides.""" self.client.force_login(self.admin_user) - for url in ("/portal/help/", "/ops/help/"): - resp = self.client.get(url) - self.assertEqual(resp.status_code, 200, msg=url) - self.assertContains(resp, self.buyer.title, msg_prefix=url) - self.assertContains(resp, self.ops.title, msg_prefix=url) - self.assertContains(resp, self.admin.title, msg_prefix=url) + resp = self.client.get("/portal/help/") + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, self.shared.title) + self.assertContains(resp, self.buyer.title) + self.assertContains(resp, self.buyer2.title) + self.assertNotContains(resp, self.ops.title) + self.assertNotContains(resp, self.admin.title) + + def test_admin_index_on_ops_lists_ops_and_admin_guides(self): + self.client.force_login(self.admin_user) + resp = self.client.get("/ops/help/") + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, self.shared.title) + self.assertContains(resp, self.ops.title) + self.assertContains(resp, self.admin.title) + self.assertNotContains(resp, self.buyer.title) + + def test_superuser_follows_the_same_portal_scoping(self): + superuser = make_user("root@ixg.local", "admin") + superuser.is_superuser = True + superuser.save(update_fields=["is_superuser"]) + self.client.force_login(superuser) + resp = self.client.get("/portal/help/") + self.assertNotContains(resp, self.ops.title) + resp = self.client.get("/ops/help/") + self.assertContains(resp, self.admin.title) + + def test_ops_only_user_is_refused_at_the_portal_gate(self): + """Only buyer/admin roles reach /portal/ at all (BuyerRequiredMixin). + + So portal scoping here matters for exactly two kinds of viewer: real + buyers, and admins who can reach both portals. + """ + self.client.force_login(self.ops_user) + self.assertEqual(self.client.get("/portal/help/").status_code, 403) + + def test_portal_audiences_helper(self): + """The scoping rule itself, independent of view wiring.""" + A = GuidePage.Audience + self.assertEqual( + allowed_audiences("portal", role="buyer"), + {A.SHARED, A.BUYER}, + ) + self.assertEqual( + allowed_audiences("portal", role="admin"), + {A.SHARED, A.BUYER}, + msg="admin does not gain ops/admin guides inside the buyer portal", + ) + self.assertEqual( + allowed_audiences("ops", role="ops"), + {A.SHARED, A.OPS}, + ) + self.assertEqual( + allowed_audiences("ops", role="admin"), + {A.SHARED, A.OPS, A.ADMIN}, + ) + self.assertEqual( + allowed_audiences("ops", role="buyer", is_superuser=True), + {A.SHARED, A.OPS, A.ADMIN}, + msg="superuser is treated like the admin role", + ) + + def test_admin_cannot_reach_ops_guide_through_portal(self): + self.client.force_login(self.admin_user) + resp = self.client.get("/portal/help/ops/batches/") + self.assertEqual(resp.status_code, 404) + resp = self.client.get("/portal/help/admin/overview/") + self.assertEqual(resp.status_code, 404) + # ...but the same guides are reachable through the ops help centre + self.assertEqual(self.client.get("/ops/help/ops/batches/").status_code, 200) + self.assertEqual(self.client.get("/ops/help/admin/overview/").status_code, 200) def test_buyer_cannot_access_ops_or_admin_guide(self): self.client.force_login(self.buyer_user) @@ -143,15 +209,60 @@ class GuideMarkdownTests(TestCase): ) self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html) - def test_external_and_anchor_links_left_alone(self): + def test_anchor_external_and_mailto_links_left_alone(self): html = render_markdown( - "[Plan](/en/home/development/plan)\n\n[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)", + "[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)\n\n" + "[Elsewhere](https://example.com/x)", "user-guides/index", "/portal/help/", ) - self.assertIn('href="/en/home/development/plan"', html) self.assertIn('href="#buyer-portal-guides"', html) self.assertIn('href="mailto:a@b.c"', html) + self.assertIn('href="https://example.com/x"', html) + + @override_settings(WIKI_PUBLIC_BASE="http://wiki.test") + def test_absolute_wiki_guide_link_rewritten_to_help_path(self): + """`/en/user-guides/...` is mirrored in-app, so keep the reader in-app. + + This is the link form the wiki itself needs (Wiki.js resolves a relative + link against the page's full path, so `walkthrough/signing-in` on the + page `user-guides/ops/walkthrough` 404s). Rewriting it here is what lets + one page serve both surfaces. + """ + html = render_markdown( + "[Step 3](/en/user-guides/ops/walkthrough/intakes)", + "user-guides/ops/walkthrough", + "/ops/help/", + ) + self.assertIn('href="/ops/help/ops/walkthrough/intakes/"', html) + + @override_settings(WIKI_PUBLIC_BASE="http://wiki.test") + def test_absolute_wiki_guide_link_to_an_index_page(self): + html = render_markdown( + "[Back to the index](/en/user-guides/index)", + "user-guides/buyer/getting-started", + "/portal/help/", + ) + self.assertIn('href="/portal/help/index/"', html) + + @override_settings(WIKI_PUBLIC_BASE="http://wiki.test") + def test_absolute_wiki_page_link_rewritten_to_the_wiki(self): + """A wiki page with no in-app copy must point at the wiki. + + The app hosts serve no /en/ routes, so leaving this href alone is a 404. + """ + html = render_markdown( + "[Development Plan](/en/home/development/plan)", + "user-guides/index", + "/ops/help/", + ) + self.assertIn('href="http://wiki.test/en/home/development/plan"', html) + + def test_absolute_app_paths_left_alone(self): + html = render_markdown( + "[Ops portal](/ops/batches/)", "user-guides/index", "/ops/help/" + ) + self.assertIn('href="/ops/batches/"', html) class FakeCursor: diff --git a/apps/guides/views.py b/apps/guides/views.py index 6770990..71ee3e1 100644 --- a/apps/guides/views.py +++ b/apps/guides/views.py @@ -4,19 +4,31 @@ from django.shortcuts import get_object_or_404 from django.views.generic import DetailView, ListView from .markdown import render_markdown -from .models import AUDIENCES_BY_ROLE, GuidePage +from .models import GuidePage, allowed_audiences class GuideAccessMixin: + """Scope the help centre by PORTAL first, then by role. + + Being an admin does not mean seeing every guide in every portal: the buyer + portal lists buyer guides (the audience of its readers), the ops portal + lists ops guides, and the admin guides only appear in the ops portal for + admin/superuser accounts. See apps.guides.models.allowed_audiences. + """ + + def get_portal(self): + return getattr(self.request.resolver_match, "namespace", None) or "portal" + def get_allowed_audiences(self): user = self.request.user - if user.is_superuser: - return set(GuidePage.Audience.values) - return AUDIENCES_BY_ROLE.get(getattr(user, "role", ""), set()) + return allowed_audiences( + self.get_portal(), + role=getattr(user, "role", None), + is_superuser=user.is_superuser, + ) def get_help_root(self): - namespace = getattr(self.request.resolver_match, "namespace", None) or "portal" - return f"/{namespace}/help/" + return f"/{self.get_portal()}/help/" class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView): @@ -34,12 +46,15 @@ class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView): class GuideDetailView(LoginRequiredMixin, GuideAccessMixin, DetailView): model = GuidePage + # the templates read {{ guide.title }}; without this the object arrives as + # "object" and every guide detail page renders a blank browser title + context_object_name = "guide" def get_object(self, queryset=None): path = f"user-guides/{self.kwargs['page_path']}" page = get_object_or_404(GuidePage, path=path) if page.audience not in self.get_allowed_audiences(): - raise Http404("This guide is not available to your role.") + raise Http404("This guide is not available in this portal.") return page def get_context_data(self, **kwargs): diff --git a/docs/USER_GUIDES_VIEWER_SPEC.md b/docs/USER_GUIDES_VIEWER_SPEC.md new file mode 100644 index 0000000..d60897e --- /dev/null +++ b/docs/USER_GUIDES_VIEWER_SPEC.md @@ -0,0 +1,212 @@ +# 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 `