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.
This commit is contained in:
parent
8f1649aaa1
commit
ee4f7b5232
5 changed files with 407 additions and 23 deletions
|
|
@ -47,15 +47,44 @@ EXTENSIONS = ["tables", "fenced_code", "sane_lists"]
|
||||||
|
|
||||||
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
|
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
|
||||||
|
|
||||||
|
# Links authored in the wiki are absolute: `/en/user-guides/<x>` is a guide that
|
||||||
|
# this help centre mirrors, while any other `/en/<x>` is a wiki-only page.
|
||||||
|
WIKI_GUIDE_PREFIX = "/en/user-guides/"
|
||||||
|
WIKI_PAGE_PREFIX = "/en/"
|
||||||
|
|
||||||
_IMAGE_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
|
_IMAGE_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
|
||||||
_ANCHOR_HREF_RE = re.compile(r'(<a\b[^>]*?\bhref=")([^"]*)(")', re.IGNORECASE)
|
_ANCHOR_HREF_RE = re.compile(r'(<a\b[^>]*?\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/<x>` is mirrored in-app -> the in-app help path.
|
||||||
|
- any other `/en/<x>` 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):
|
def _rewrite_anchor(match, base_folder, help_root):
|
||||||
prefix, href, suffix = match.groups()
|
prefix, href, suffix = match.groups()
|
||||||
parsed = urlparse(href)
|
parsed = urlparse(href)
|
||||||
if parsed.scheme or href.startswith(("#", "/")):
|
if parsed.scheme or href.startswith("#"):
|
||||||
return match.group(0)
|
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))
|
resolved = posixpath.normpath(posixpath.join(base_folder, href))
|
||||||
if resolved.startswith("user-guides/"):
|
if resolved.startswith("user-guides/"):
|
||||||
suffix_path = resolved[len("user-guides/"):]
|
suffix_path = resolved[len("user-guides/"):]
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,25 @@ class GuidePage(models.Model):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
AUDIENCES_BY_ROLE = {
|
# The help centre is scoped by PORTAL first, then by role. Which guides are
|
||||||
"buyer": {GuidePage.Audience.SHARED, GuidePage.Audience.BUYER},
|
# listed depends on which portal you are standing in, not only on who you are:
|
||||||
"ops": {GuidePage.Audience.SHARED, GuidePage.Audience.OPS},
|
# an admin browsing the buyer portal sees the buyer guides, because it is the
|
||||||
"admin": set(GuidePage.Audience.values),
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from .markdown import render_markdown
|
from .markdown import render_markdown
|
||||||
from .models import GuidePage
|
from .models import GuidePage, allowed_audiences
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
@ -60,14 +60,80 @@ class GuideAudienceFilterTests(TestCase):
|
||||||
self.assertNotContains(resp, self.buyer.title)
|
self.assertNotContains(resp, self.buyer.title)
|
||||||
self.assertNotContains(resp, self.admin.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)
|
self.client.force_login(self.admin_user)
|
||||||
for url in ("/portal/help/", "/ops/help/"):
|
resp = self.client.get("/portal/help/")
|
||||||
resp = self.client.get(url)
|
self.assertEqual(resp.status_code, 200)
|
||||||
self.assertEqual(resp.status_code, 200, msg=url)
|
self.assertContains(resp, self.shared.title)
|
||||||
self.assertContains(resp, self.buyer.title, msg_prefix=url)
|
self.assertContains(resp, self.buyer.title)
|
||||||
self.assertContains(resp, self.ops.title, msg_prefix=url)
|
self.assertContains(resp, self.buyer2.title)
|
||||||
self.assertContains(resp, self.admin.title, msg_prefix=url)
|
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):
|
def test_buyer_cannot_access_ops_or_admin_guide(self):
|
||||||
self.client.force_login(self.buyer_user)
|
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)
|
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(
|
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",
|
"user-guides/index",
|
||||||
"/portal/help/",
|
"/portal/help/",
|
||||||
)
|
)
|
||||||
self.assertIn('href="/en/home/development/plan"', html)
|
|
||||||
self.assertIn('href="#buyer-portal-guides"', html)
|
self.assertIn('href="#buyer-portal-guides"', html)
|
||||||
self.assertIn('href="mailto:a@b.c"', 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:
|
class FakeCursor:
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,31 @@ from django.shortcuts import get_object_or_404
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
from .markdown import render_markdown
|
from .markdown import render_markdown
|
||||||
from .models import AUDIENCES_BY_ROLE, GuidePage
|
from .models import GuidePage, allowed_audiences
|
||||||
|
|
||||||
|
|
||||||
class GuideAccessMixin:
|
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):
|
def get_allowed_audiences(self):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
if user.is_superuser:
|
return allowed_audiences(
|
||||||
return set(GuidePage.Audience.values)
|
self.get_portal(),
|
||||||
return AUDIENCES_BY_ROLE.get(getattr(user, "role", ""), set())
|
role=getattr(user, "role", None),
|
||||||
|
is_superuser=user.is_superuser,
|
||||||
|
)
|
||||||
|
|
||||||
def get_help_root(self):
|
def get_help_root(self):
|
||||||
namespace = getattr(self.request.resolver_match, "namespace", None) or "portal"
|
return f"/{self.get_portal()}/help/"
|
||||||
return f"/{namespace}/help/"
|
|
||||||
|
|
||||||
|
|
||||||
class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView):
|
class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView):
|
||||||
|
|
@ -34,12 +46,15 @@ class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView):
|
||||||
|
|
||||||
class GuideDetailView(LoginRequiredMixin, GuideAccessMixin, DetailView):
|
class GuideDetailView(LoginRequiredMixin, GuideAccessMixin, DetailView):
|
||||||
model = GuidePage
|
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):
|
def get_object(self, queryset=None):
|
||||||
path = f"user-guides/{self.kwargs['page_path']}"
|
path = f"user-guides/{self.kwargs['page_path']}"
|
||||||
page = get_object_or_404(GuidePage, path=path)
|
page = get_object_or_404(GuidePage, path=path)
|
||||||
if page.audience not in self.get_allowed_audiences():
|
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
|
return page
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
|
||||||
212
docs/USER_GUIDES_VIEWER_SPEC.md
Normal file
212
docs/USER_GUIDES_VIEWER_SPEC.md
Normal file
|
|
@ -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/<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`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
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.
|
||||||
Loading…
Add table
Reference in a new issue