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:
ixgadmin 2026-08-07 14:19:49 +00:00
parent 08096e168f
commit 6508cc25ef
25 changed files with 651 additions and 1 deletions

View file

@ -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'),
]

View file

@ -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
View file

11
apps/guides/admin.py Normal file
View 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
View 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"

View file

View 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
View 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,
)

View 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'],
},
),
]

View file

57
apps/guides/models.py Normal file
View 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
View 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(
"![Dashboard](/buyer-guide/buyer-dashboard.png)",
"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
View 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

View file

@ -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'),
]

View file

@ -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'

View file

@ -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"

View 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">&larr; 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View file

@ -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>

View file

@ -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>