Add User Guides Viewer (Help Centre) - role-gated wiki help pages
- New apps/guides app with GuidePage model and audience mapping by wiki path - sync_guides management command: one-way read-only sync from wikijs DB - Read-only wikijs_ro DB role + WIKI_DB_* env config (secrets, gitignored) - Markdown rendering with bleach sanitisation; rewrites internal guide links to the portal help root and wiki image paths to WIKI_PUBLIC_BASE - /portal/help/ (buyer) and /ops/help/ (ops) index + detail views with role-based audience filtering (404 on cross-role guides) - Help nav item added to both portal base templates - Tests: audience filtering, markdown sanitisation, link rewriting, sync command
This commit is contained in:
parent
08096e168f
commit
6508cc25ef
25 changed files with 651 additions and 1 deletions
|
|
@ -12,4 +12,6 @@ urlpatterns = [
|
|||
path('documents/', views.DocumentListView.as_view(), name='document_list'),
|
||||
path('subscription/', views.SubscriptionView.as_view(), name='subscription'),
|
||||
path('compliance/', views.ComplianceView.as_view(), name='compliance'),
|
||||
path('help/', views.GuideIndexView.as_view(), name='guide_index'),
|
||||
path('help/<path:page_path>/', views.GuideDetailView.as_view(), name='guide_detail'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ from apps.shipments.models import Shipment
|
|||
from apps.documents.models import Document
|
||||
from apps.esg.models import ESGFieldReport
|
||||
from apps.subscriptions.models import Subscription
|
||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
||||
|
||||
|
||||
class BuyerRequiredMixin(UserPassesTestMixin):
|
||||
|
|
@ -130,3 +132,11 @@ class ComplianceView(BuyerRequiredMixin, TemplateView):
|
|||
).select_related("batch", "quality_approved_by").order_by("-published_at")
|
||||
ctx["eudr_cases"] = DueDiligenceCase.objects.select_related("batch", "approved_by").order_by("-updated_at")
|
||||
return ctx
|
||||
|
||||
|
||||
class GuideIndexView(BuyerRequiredMixin, BaseGuideIndexView):
|
||||
template_name = "guides/index_portal.html"
|
||||
|
||||
|
||||
class GuideDetailView(BuyerRequiredMixin, BaseGuideDetailView):
|
||||
template_name = "guides/detail_portal.html"
|
||||
|
|
|
|||
0
apps/guides/__init__.py
Normal file
0
apps/guides/__init__.py
Normal file
11
apps/guides/admin.py
Normal file
11
apps/guides/admin.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import GuidePage
|
||||
|
||||
|
||||
@admin.register(GuidePage)
|
||||
class GuidePageAdmin(admin.ModelAdmin):
|
||||
list_display = ["path", "title", "audience", "updated_at", "synced_at"]
|
||||
list_filter = ["audience"]
|
||||
search_fields = ["path", "title"]
|
||||
readonly_fields = ["synced_at"]
|
||||
7
apps/guides/apps.py
Normal file
7
apps/guides/apps.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GuidesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.guides"
|
||||
verbose_name = "User Guides"
|
||||
0
apps/guides/management/__init__.py
Normal file
0
apps/guides/management/__init__.py
Normal file
0
apps/guides/management/commands/__init__.py
Normal file
0
apps/guides/management/commands/__init__.py
Normal file
75
apps/guides/management/commands/sync_guides.py
Normal file
75
apps/guides/management/commands/sync_guides.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.db import connections
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from apps.guides.models import GuidePage
|
||||
|
||||
SELECT_PAGES_SQL = (
|
||||
'SELECT path, title, content, "updatedAt" FROM pages '
|
||||
'WHERE path LIKE %s AND "isPublished" = true AND "isPrivate" = false'
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "One-way sync of Wiki.js user-guides pages into GuidePage (read-only on wikijs)."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
created = 0
|
||||
updated = 0
|
||||
stale = []
|
||||
|
||||
existing = {page.path: page for page in GuidePage.objects.all()}
|
||||
known_paths = set()
|
||||
|
||||
with connections["wikijs"].cursor() as cursor:
|
||||
cursor.execute(SELECT_PAGES_SQL, ["user-guides/%"])
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for path, title, content, updated_at_raw in rows:
|
||||
audience = GuidePage.map_audience(path)
|
||||
if audience is None:
|
||||
continue
|
||||
known_paths.add(path)
|
||||
parsed = parse_datetime(updated_at_raw) if updated_at_raw else None
|
||||
updated_at = parsed or timezone.now()
|
||||
|
||||
page = existing.pop(path, None)
|
||||
if page is None:
|
||||
GuidePage.objects.create(
|
||||
path=path,
|
||||
title=title,
|
||||
audience=audience,
|
||||
content=content,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
changed = (
|
||||
page.title != title
|
||||
or page.content != content
|
||||
or page.audience != audience
|
||||
or page.updated_at != updated_at
|
||||
)
|
||||
if changed:
|
||||
page.title = title
|
||||
page.content = content
|
||||
page.audience = audience
|
||||
page.updated_at = updated_at
|
||||
page.save(update_fields=["title", "content", "audience", "updated_at", "synced_at"])
|
||||
updated += 1
|
||||
|
||||
removed = 0
|
||||
for path in existing:
|
||||
if path not in known_paths:
|
||||
stale.append(path)
|
||||
if stale:
|
||||
removed = GuidePage.objects.filter(path__in=stale).delete()[0]
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"sync_guides complete: {created} created, {updated} updated, {removed} removed."
|
||||
)
|
||||
)
|
||||
self.stdout.write(f"Total GuidePage rows: {GuidePage.objects.count()}.")
|
||||
83
apps/guides/markdown.py
Normal file
83
apps/guides/markdown.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import posixpath
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import bleach
|
||||
from django.conf import settings
|
||||
from markdown import markdown
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
"a": ["href", "title"],
|
||||
"img": ["src", "alt", "title"],
|
||||
"th": ["align"],
|
||||
"td": ["align"],
|
||||
}
|
||||
|
||||
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
||||
|
||||
EXTENSIONS = ["tables", "fenced_code", "sane_lists"]
|
||||
|
||||
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
|
||||
|
||||
_IMAGE_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
|
||||
_ANCHOR_HREF_RE = re.compile(r'(<a\b[^>]*?\bhref=")([^"]*)(")', re.IGNORECASE)
|
||||
|
||||
|
||||
def _rewrite_anchor(match, base_folder, help_root):
|
||||
prefix, href, suffix = match.groups()
|
||||
parsed = urlparse(href)
|
||||
if parsed.scheme or href.startswith(("#", "/")):
|
||||
return match.group(0)
|
||||
resolved = posixpath.normpath(posixpath.join(base_folder, href))
|
||||
if resolved.startswith("user-guides/"):
|
||||
suffix_path = resolved[len("user-guides/"):]
|
||||
return f'{prefix}{help_root}{suffix_path}/{suffix}'
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def _rewrite_image(match):
|
||||
prefix, src, suffix = match.groups()
|
||||
if src.startswith(WIKI_IMAGE_PREFIXES):
|
||||
return f'{prefix}{settings.WIKI_PUBLIC_BASE}{src}{suffix}'
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def render_markdown(content, page_path, help_root):
|
||||
html = markdown(content or "", extensions=EXTENSIONS)
|
||||
base_folder = posixpath.dirname(page_path)
|
||||
html = _ANCHOR_HREF_RE.sub(lambda m: _rewrite_anchor(m, base_folder, help_root), html)
|
||||
html = _IMAGE_SRC_RE.sub(_rewrite_image, html)
|
||||
return bleach.clean(
|
||||
html,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
)
|
||||
29
apps/guides/migrations/0001_initial.py
Normal file
29
apps/guides/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 5.2.12 on 2026-08-07 14:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GuidePage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('path', models.CharField(max_length=255, unique=True)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('audience', models.CharField(choices=[('shared', 'Shared'), ('buyer', 'Buyer'), ('ops', 'Operations'), ('admin', 'Administrator')], max_length=20)),
|
||||
('content', models.TextField(blank=True, default='')),
|
||||
('updated_at', models.DateTimeField()),
|
||||
('synced_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['path'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/guides/migrations/__init__.py
Normal file
0
apps/guides/migrations/__init__.py
Normal file
57
apps/guides/models.py
Normal file
57
apps/guides/models.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
class GuidePage(models.Model):
|
||||
class Audience(models.TextChoices):
|
||||
SHARED = "shared", "Shared"
|
||||
BUYER = "buyer", "Buyer"
|
||||
OPS = "ops", "Operations"
|
||||
ADMIN = "admin", "Administrator"
|
||||
|
||||
AUDIENCE_PREFIXES = (
|
||||
("user-guides/index", Audience.SHARED),
|
||||
("user-guides/buyer/", Audience.BUYER),
|
||||
("user-guides/ops/", Audience.OPS),
|
||||
("user-guides/admin/", Audience.ADMIN),
|
||||
)
|
||||
|
||||
path = models.CharField(max_length=255, unique=True)
|
||||
title = models.CharField(max_length=255)
|
||||
audience = models.CharField(max_length=20, choices=Audience.choices)
|
||||
content = models.TextField(blank=True, default="")
|
||||
updated_at = models.DateTimeField()
|
||||
synced_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["path"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def page_slug(self):
|
||||
return self.path[len("user-guides/"):] if self.path.startswith("user-guides/") else self.path
|
||||
|
||||
def preview(self, max_chars=180):
|
||||
for line in self.content.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith(("#", "-", ">", "|", "*", "![", "```", "<")):
|
||||
continue
|
||||
text = line.replace("**", "").replace("`", "").replace("*", "").strip()
|
||||
if text:
|
||||
return text[:max_chars]
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def map_audience(cls, path):
|
||||
for prefix, audience in cls.AUDIENCE_PREFIXES:
|
||||
if path == prefix or path.startswith(prefix):
|
||||
return audience
|
||||
return None
|
||||
|
||||
|
||||
AUDIENCES_BY_ROLE = {
|
||||
"buyer": {GuidePage.Audience.SHARED, GuidePage.Audience.BUYER},
|
||||
"ops": {GuidePage.Audience.SHARED, GuidePage.Audience.OPS},
|
||||
"admin": set(GuidePage.Audience.values),
|
||||
}
|
||||
216
apps/guides/tests.py
Normal file
216
apps/guides/tests.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from .markdown import render_markdown
|
||||
from .models import GuidePage
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def make_user(email, role):
|
||||
return User.objects.create_user(
|
||||
email=email, password="pass1234", role=role,
|
||||
full_name=email.split("@")[0], is_staff=True,
|
||||
)
|
||||
|
||||
|
||||
def make_page(path, content="# Heading\n\nBody text", title=None):
|
||||
return GuidePage.objects.create(
|
||||
path=path,
|
||||
title=title or path.rsplit("/", 1)[-1],
|
||||
audience=GuidePage.map_audience(path),
|
||||
content=content,
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
class GuideAudienceFilterTests(TestCase):
|
||||
def setUp(self):
|
||||
self.shared = make_page("user-guides/index", "# User Guides\n\nWelcome.", title="Landing Page")
|
||||
self.buyer = make_page("user-guides/buyer/getting-started", title="Getting Started Guide")
|
||||
self.buyer2 = make_page("user-guides/buyer/dashboard", title="Dashboard Guide")
|
||||
self.ops = make_page("user-guides/ops/batches", title="Ops Batches Guide")
|
||||
self.admin = make_page("user-guides/admin/overview", title="Admin Overview Guide")
|
||||
self.buyer_user = make_user("buyer@ixg.local", "buyer")
|
||||
self.ops_user = make_user("ops@ixg.local", "ops")
|
||||
self.admin_user = make_user("admin@ixg.local", "admin")
|
||||
|
||||
def test_buyer_index_shows_shared_and_buyer_only(self):
|
||||
self.client.force_login(self.buyer_user)
|
||||
resp = self.client.get("/portal/help/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
content = resp.content.decode()
|
||||
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_ops_index_shows_shared_and_ops_only(self):
|
||||
self.client.force_login(self.ops_user)
|
||||
resp = self.client.get("/ops/help/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
content = resp.content.decode()
|
||||
self.assertContains(resp, self.shared.title)
|
||||
self.assertContains(resp, self.ops.title)
|
||||
self.assertNotContains(resp, self.buyer.title)
|
||||
self.assertNotContains(resp, self.admin.title)
|
||||
|
||||
def test_admin_index_sees_all(self):
|
||||
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)
|
||||
|
||||
def test_buyer_cannot_access_ops_or_admin_guide(self):
|
||||
self.client.force_login(self.buyer_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)
|
||||
|
||||
def test_ops_cannot_access_buyer_guide(self):
|
||||
self.client.force_login(self.ops_user)
|
||||
resp = self.client.get("/ops/help/buyer/getting-started/")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_buyer_can_access_buyer_guide(self):
|
||||
self.client.force_login(self.buyer_user)
|
||||
resp = self.client.get("/portal/help/buyer/getting-started/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, "<h1")
|
||||
|
||||
def test_admin_can_access_admin_guide_via_ops(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
resp = self.client.get("/ops/help/admin/overview/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_unknown_guide_is_404(self):
|
||||
self.client.force_login(self.buyer_user)
|
||||
resp = self.client.get("/portal/help/nope/missing/")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
|
||||
class GuideMarkdownTests(TestCase):
|
||||
def test_script_tags_are_stripped(self):
|
||||
html = render_markdown(
|
||||
"# Title\n\nHello <script>alert('x')</script> world",
|
||||
"user-guides/buyer/test",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertNotIn("<script", html)
|
||||
|
||||
def test_tables_and_heading_render(self):
|
||||
html = render_markdown(
|
||||
"| A | B |\n|---|---|\n| 1 | 2 |",
|
||||
"user-guides/buyer/test",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertIn("<table", html)
|
||||
self.assertIn("<th", html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_same_folder_link_rewritten_to_help_root(self):
|
||||
html = render_markdown(
|
||||
"[Compliance guide](compliance)",
|
||||
"user-guides/buyer/getting-started",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertIn('href="/portal/help/buyer/compliance/"', html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_index_relative_link_rewritten(self):
|
||||
html = render_markdown(
|
||||
"[Getting started](buyer/getting-started)",
|
||||
"user-guides/index",
|
||||
"/ops/help/",
|
||||
)
|
||||
self.assertIn('href="/ops/help/buyer/getting-started/"', html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_wiki_image_path_rewritten_to_public_base(self):
|
||||
html = render_markdown(
|
||||
"",
|
||||
"user-guides/buyer/test",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html)
|
||||
|
||||
def test_external_and_anchor_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)",
|
||||
"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)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
pass
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class SyncGuidesCommandTests(TestCase):
|
||||
def test_sync_maps_audience_and_upserts(self):
|
||||
rows = [
|
||||
("user-guides/index", "User Guides", "# landing", "2026-08-07T13:14:15.809Z"),
|
||||
("user-guides/buyer/getting-started", "Getting Started", "# gs", "2026-08-07T13:25:30.479Z"),
|
||||
("user-guides/ops/batches", "Batches", "# b", "2026-08-07T13:51:48.332Z"),
|
||||
("user-guides/admin/overview", "Admin Overview", "# a", "2026-08-07T13:52:15.763Z"),
|
||||
]
|
||||
with mock.patch(
|
||||
"apps.guides.management.commands.sync_guides.connections"
|
||||
) as mock_connections:
|
||||
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
|
||||
call_command("sync_guides")
|
||||
|
||||
self.assertEqual(GuidePage.objects.count(), 4)
|
||||
self.assertEqual(
|
||||
GuidePage.objects.get(path="user-guides/index").audience, GuidePage.Audience.SHARED
|
||||
)
|
||||
self.assertEqual(
|
||||
GuidePage.objects.get(path="user-guides/buyer/getting-started").audience,
|
||||
GuidePage.Audience.BUYER,
|
||||
)
|
||||
self.assertEqual(
|
||||
GuidePage.objects.get(path="user-guides/ops/batches").audience, GuidePage.Audience.OPS
|
||||
)
|
||||
self.assertEqual(
|
||||
GuidePage.objects.get(path="user-guides/admin/overview").audience, GuidePage.Audience.ADMIN
|
||||
)
|
||||
|
||||
def test_sync_updates_changed_and_removes_stale(self):
|
||||
make_page("user-guides/buyer/old", content="old content")
|
||||
rows = [
|
||||
("user-guides/buyer/getting-started", "Getting Started", "# fresh", "2026-08-07T13:25:30.479Z"),
|
||||
]
|
||||
with mock.patch(
|
||||
"apps.guides.management.commands.sync_guides.connections"
|
||||
) as mock_connections:
|
||||
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
|
||||
call_command("sync_guides")
|
||||
|
||||
self.assertFalse(GuidePage.objects.filter(path="user-guides/buyer/old").exists())
|
||||
page = GuidePage.objects.get(path="user-guides/buyer/getting-started")
|
||||
self.assertEqual(page.content, "# fresh")
|
||||
51
apps/guides/views.py
Normal file
51
apps/guides/views.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import Http404
|
||||
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
|
||||
|
||||
|
||||
class GuideAccessMixin:
|
||||
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())
|
||||
|
||||
def get_help_root(self):
|
||||
namespace = getattr(self.request.resolver_match, "namespace", None) or "portal"
|
||||
return f"/{namespace}/help/"
|
||||
|
||||
|
||||
class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView):
|
||||
model = GuidePage
|
||||
context_object_name = "guides"
|
||||
|
||||
def get_queryset(self):
|
||||
return GuidePage.objects.filter(audience__in=self.get_allowed_audiences())
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["help_root"] = self.get_help_root()
|
||||
return ctx
|
||||
|
||||
|
||||
class GuideDetailView(LoginRequiredMixin, GuideAccessMixin, DetailView):
|
||||
model = GuidePage
|
||||
|
||||
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.")
|
||||
return page
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
help_root = self.get_help_root()
|
||||
ctx["help_root"] = help_root
|
||||
ctx["guide_html"] = render_markdown(self.object.content, self.object.path, help_root)
|
||||
ctx["guides"] = GuidePage.objects.filter(audience__in=self.get_allowed_audiences())
|
||||
return ctx
|
||||
|
|
@ -21,4 +21,6 @@ urlpatterns = [
|
|||
path('coa/<uuid:pk>/', views.COAReviewDetailView.as_view(), name='coa_review_detail'),
|
||||
path('eudr/', views.EUDRCaseListView.as_view(), name='eudr_list'),
|
||||
path('eudr/<uuid:pk>/', views.EUDRCaseDetailView.as_view(), name='eudr_detail'),
|
||||
path('help/', views.GuideIndexView.as_view(), name='guide_index'),
|
||||
path('help/<path:page_path>/', views.GuideDetailView.as_view(), name='guide_detail'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ from apps.documents.models import Document, CertificateOfAnalysis
|
|||
from apps.esg.models import ESGFieldReport
|
||||
from apps.notifications.models import Notification
|
||||
from apps.compliance.models import DueDiligenceCase
|
||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
||||
|
||||
|
||||
class OpsRequiredMixin(UserPassesTestMixin):
|
||||
|
|
@ -370,3 +372,11 @@ class EUDRCaseDetailView(OpsRequiredMixin, DetailView):
|
|||
except ValueError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(self.request.path)
|
||||
|
||||
|
||||
class GuideIndexView(OpsRequiredMixin, BaseGuideIndexView):
|
||||
template_name = 'guides/index_ops.html'
|
||||
|
||||
|
||||
class GuideDetailView(OpsRequiredMixin, BaseGuideDetailView):
|
||||
template_name = 'guides/detail_ops.html'
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ INSTALLED_APPS = [
|
|||
"apps.notifications",
|
||||
"apps.ops",
|
||||
"apps.buyer",
|
||||
"apps.guides",
|
||||
"encrypted_model_fields",
|
||||
]
|
||||
|
||||
|
|
@ -89,7 +90,15 @@ DATABASES = {
|
|||
"PASSWORD": env("DB_PASSWORD", default=""),
|
||||
"HOST": env("DB_HOST", default="127.0.0.1"),
|
||||
"PORT": env.int("DB_PORT", default=5432),
|
||||
}
|
||||
},
|
||||
"wikijs": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": env("WIKI_DB_NAME", default="wikijs"),
|
||||
"USER": env("WIKI_DB_USER", default=""),
|
||||
"PASSWORD": env("WIKI_DB_PASSWORD", default=""),
|
||||
"HOST": env("WIKI_DB_HOST", default="127.0.0.1"),
|
||||
"PORT": env.int("WIKI_DB_PORT", default=5432),
|
||||
},
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
|
|
@ -106,6 +115,8 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
|
|||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
WIKI_PUBLIC_BASE = env("WIKI_PUBLIC_BASE", default="http://194.164.95.50")
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
|
|
|
|||
28
templates/guides/_guide_detail.html
Normal file
28
templates/guides/_guide_detail.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<div class="page-header">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1">
|
||||
<li class="breadcrumb-item"><a href="{{ help_root }}">Help Centre</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ guide.title }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h2>{{ guide.title }}</h2>
|
||||
<a href="{{ help_root }}" class="btn btn-sm btn-outline-secondary mt-2">← Back to all guides</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Guides</h6>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
{% for item in guides %}
|
||||
<li class="mb-2">
|
||||
<a class="text-decoration-none {% if item.pk == guide.pk %}fw-bold text-primary{% else %}text-secondary{% endif %}" href="{{ help_root }}{{ item.page_slug }}/">{{ item.title }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<div class="stat-card guide-content">{{ guide_html|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
26
templates/guides/_guide_list.html
Normal file
26
templates/guides/_guide_list.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Help Centre</h2>
|
||||
<span class="text-secondary">User guides</span>
|
||||
</div>
|
||||
|
||||
{% if guides %}
|
||||
<div class="row g-3">
|
||||
{% for guide in guides %}
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="mb-0"><a class="text-decoration-none" href="{{ help_root }}{{ guide.page_slug }}/">{{ guide.title }}</a></h6>
|
||||
<span class="badge bg-light text-secondary border">{{ guide.get_audience_display }}</span>
|
||||
</div>
|
||||
{% if guide.preview %}
|
||||
<p class="text-muted mb-0 small">{{ guide.preview }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="stat-card text-center py-5">
|
||||
<p class="text-muted mb-0">No guides available yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
5
templates/guides/detail_ops.html
Normal file
5
templates/guides/detail_ops.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}{{ guide.title }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'guides/_guide_detail.html' %}
|
||||
{% endblock %}
|
||||
5
templates/guides/detail_portal.html
Normal file
5
templates/guides/detail_portal.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'portal/base_portal.html' %}
|
||||
{% block title %}{{ guide.title }} - IXG Buyer Portal{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'guides/_guide_detail.html' %}
|
||||
{% endblock %}
|
||||
5
templates/guides/index_ops.html
Normal file
5
templates/guides/index_ops.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Help Centre - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'guides/_guide_list.html' %}
|
||||
{% endblock %}
|
||||
5
templates/guides/index_portal.html
Normal file
5
templates/guides/index_portal.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'portal/base_portal.html' %}
|
||||
{% block title %}Help Centre - IXG Buyer Portal{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'guides/_guide_list.html' %}
|
||||
{% endblock %}
|
||||
|
|
@ -23,6 +23,11 @@ body { font-size: .875rem; background: #f5f6fa; }
|
|||
.page-header { margin-bottom: 1.5rem; }
|
||||
.page-header h2 { font-weight: 600; }
|
||||
.filter-bar { background: #fff; border-radius: .5rem; padding: 1rem; border: 1px solid #e2e8f0; margin-bottom: 1.5rem; }
|
||||
.guide-content img { max-width: 100%; height: auto; border-radius: .375rem; }
|
||||
.guide-content table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
|
||||
.guide-content th, .guide-content td { border: 1px solid #e2e8f0; padding: .5rem .75rem; }
|
||||
.guide-content pre { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .375rem; padding: .75rem; }
|
||||
.guide-content blockquote { border-left: 4px solid #a7f3d0; padding-left: 1rem; color: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -40,6 +45,7 @@ body { font-size: .875rem; background: #f5f6fa; }
|
|||
<li class=nav-item><a class=nav-link {% if 'capa' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:capa_list' %}><i class=bi bi-exclamation-triangle></i> CAPA Records</a></li>
|
||||
<li class=nav-item><a class=nav-link {% if 'document' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:document_list' %}><i class=bi bi-file-earmark-text></i> Documents</a></li>
|
||||
<li class=nav-item><a class=nav-link {% if 'esg' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:esg_list' %}><i class=bi bi-tree></i> ESG Reports</a></li>
|
||||
<li class=nav-item><a class=nav-link {% if 'guide' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:guide_index' %}><i class=bi bi-book></i> Help</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ body { font-size: .875rem; background: #f0fdf4; }
|
|||
.page-header { margin-bottom: 1.5rem; }
|
||||
.page-header h2 { font-weight: 600; }
|
||||
.filter-bar { background: #fff; border-radius: .5rem; padding: 1rem; border: 1px solid #e2e8f0; margin-bottom: 1.5rem; }
|
||||
.guide-content img { max-width: 100%; height: auto; border-radius: .375rem; }
|
||||
.guide-content table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
|
||||
.guide-content th, .guide-content td { border: 1px solid #e2e8f0; padding: .5rem .75rem; }
|
||||
.guide-content pre { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .375rem; padding: .75rem; }
|
||||
.guide-content blockquote { border-left: 4px solid #a7f3d0; padding-left: 1rem; color: #475569; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -36,6 +41,7 @@ body { font-size: .875rem; background: #f0fdf4; }
|
|||
<li class="nav-item"><a class="nav-link {% if 'compliance' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:compliance' %}"><i class="bi bi-shield-check"></i> Compliance</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'subscription' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:subscription' %}"><i class="bi bi-credit-card"></i> Subscription</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'chat' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:chat' %}"><i class="bi bi-robot"></i> AI Chat</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'guide' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:guide_index' %}"><i class="bi bi-book"></i> Help</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue