django-admin-wiki 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. django_admin_wiki/__init__.py +3 -0
  2. django_admin_wiki/admin.py +89 -0
  3. django_admin_wiki/apps.py +57 -0
  4. django_admin_wiki/conf.py +94 -0
  5. django_admin_wiki/fields.py +29 -0
  6. django_admin_wiki/forms.py +84 -0
  7. django_admin_wiki/management/__init__.py +0 -0
  8. django_admin_wiki/management/commands/__init__.py +0 -0
  9. django_admin_wiki/management/commands/import_mkdocs.py +234 -0
  10. django_admin_wiki/management/commands/refresh_wiki_search_vectors.py +15 -0
  11. django_admin_wiki/management/commands/setup_wiki_groups.py +21 -0
  12. django_admin_wiki/media_storage.py +133 -0
  13. django_admin_wiki/migrations/0001_initial.py +144 -0
  14. django_admin_wiki/migrations/__init__.py +0 -0
  15. django_admin_wiki/models.py +75 -0
  16. django_admin_wiki/permissions.py +61 -0
  17. django_admin_wiki/render.py +145 -0
  18. django_admin_wiki/repositories/__init__.py +45 -0
  19. django_admin_wiki/repositories/base.py +74 -0
  20. django_admin_wiki/repositories/cloud.py +210 -0
  21. django_admin_wiki/repositories/local.py +108 -0
  22. django_admin_wiki/services.py +141 -0
  23. django_admin_wiki/static/django_admin_wiki/summernote_upload.js +82 -0
  24. django_admin_wiki/static/django_admin_wiki/wiki.css +231 -0
  25. django_admin_wiki/templates/admin/django_admin_wiki/wikipage/change_list.html +8 -0
  26. django_admin_wiki/templates/django_admin_wiki/base.html +50 -0
  27. django_admin_wiki/templates/django_admin_wiki/edit.html +61 -0
  28. django_admin_wiki/templates/django_admin_wiki/home.html +24 -0
  29. django_admin_wiki/templates/django_admin_wiki/includes/nav_items.html +12 -0
  30. django_admin_wiki/templates/django_admin_wiki/page.html +18 -0
  31. django_admin_wiki/templates/django_admin_wiki/search.html +22 -0
  32. django_admin_wiki/templatetags/__init__.py +0 -0
  33. django_admin_wiki/urls.py +16 -0
  34. django_admin_wiki/views.py +263 -0
  35. django_admin_wiki/widgets.py +25 -0
  36. django_admin_wiki-0.1.0.dist-info/METADATA +251 -0
  37. django_admin_wiki-0.1.0.dist-info/RECORD +39 -0
  38. django_admin_wiki-0.1.0.dist-info/WHEEL +4 -0
  39. django_admin_wiki-0.1.0.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,3 @@
1
+ """django-admin-wiki — collaborative staff wiki for Django Admin."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,89 @@
1
+ from django.contrib import admin
2
+ from django.urls import reverse
3
+ from django.utils.html import format_html
4
+
5
+ from django_admin_wiki.forms import get_editor_widget
6
+ from django_admin_wiki.models import WikiPage, WikiRevision
7
+ from django_admin_wiki.render import sanitize_wiki_html
8
+ from django_admin_wiki.repositories import is_cloud_storage
9
+ from django_admin_wiki.services import save_wiki_page
10
+
11
+
12
+ class WikiRevisionInline(admin.TabularInline):
13
+ model = WikiRevision
14
+ extra = 0
15
+ can_delete = False
16
+ readonly_fields = ("title", "author", "created_at", "content_preview")
17
+ fields = ("created_at", "author", "title", "content_preview")
18
+
19
+ def content_preview(self, obj):
20
+ text = obj.content or ""
21
+ return text[:200] + ("…" if len(text) > 200 else "")
22
+
23
+ content_preview.short_description = "Preview"
24
+
25
+
26
+ class WikiPageAdmin(admin.ModelAdmin):
27
+ change_list_template = "admin/django_admin_wiki/wikipage/change_list.html"
28
+ list_display = (
29
+ "title",
30
+ "slug",
31
+ "parent",
32
+ "sort_order",
33
+ "updated_at",
34
+ "updated_by",
35
+ "open_wiki",
36
+ )
37
+ list_filter = ("parent",)
38
+ search_fields = ("title", "slug", "nav_label", "content")
39
+ prepopulated_fields = {"slug": ("title",)}
40
+ readonly_fields = ("created_at", "updated_at", "author", "updated_by")
41
+ inlines = [WikiRevisionInline]
42
+ fieldsets = (
43
+ (
44
+ None,
45
+ {
46
+ "fields": (
47
+ "title",
48
+ "slug",
49
+ "nav_label",
50
+ "parent",
51
+ "sort_order",
52
+ "content",
53
+ ),
54
+ },
55
+ ),
56
+ (
57
+ "Metadata",
58
+ {
59
+ "fields": ("author", "updated_by", "created_at", "updated_at"),
60
+ },
61
+ ),
62
+ )
63
+
64
+ def formfield_for_dbfield(self, db_field, request, **kwargs):
65
+ formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
66
+ if db_field.name == "content" and formfield is not None:
67
+ formfield.widget = get_editor_widget()
68
+ return formfield
69
+
70
+ def open_wiki(self, obj):
71
+ url = reverse("django_admin_wiki:wiki_page", args=[obj.slug])
72
+ return format_html('<a href="{}" target="_blank">Open</a>', url)
73
+
74
+ open_wiki.short_description = "Wiki"
75
+
76
+ def save_model(self, request, obj, form, change):
77
+ obj.content = sanitize_wiki_html(obj.content or "")
78
+ save_wiki_page(obj, user=request.user, create_revision=True)
79
+
80
+
81
+ def _register_admin():
82
+ # In cloud mode, local WikiPage tables are not used — skip ModelAdmin registration.
83
+ if is_cloud_storage():
84
+ return
85
+ if not admin.site.is_registered(WikiPage):
86
+ admin.site.register(WikiPage, WikiPageAdmin)
87
+
88
+
89
+ _register_admin()
@@ -0,0 +1,57 @@
1
+ from django.apps import AppConfig
2
+ from django.core.checks import Warning, register
3
+ from django.db.models.signals import post_migrate
4
+
5
+
6
+ def _ensure_wiki_groups(sender, **kwargs):
7
+ if sender.name != "django_admin_wiki":
8
+ return
9
+ from django_admin_wiki.permissions import ensure_wiki_groups
10
+
11
+ ensure_wiki_groups()
12
+
13
+
14
+ class DjangoAdminWikiConfig(AppConfig):
15
+ default_auto_field = "django.db.models.BigAutoField"
16
+ name = "django_admin_wiki"
17
+ label = "django_admin_wiki"
18
+ verbose_name = "Admin Wiki"
19
+
20
+ def ready(self):
21
+ post_migrate.connect(_ensure_wiki_groups, sender=self)
22
+
23
+
24
+ @register()
25
+ def check_cloud_storage_settings(app_configs, **kwargs):
26
+ from django.conf import settings
27
+
28
+ from django_admin_wiki.conf import wiki_settings
29
+
30
+ errors = []
31
+ storage = (wiki_settings.STORAGE or "local").lower()
32
+ if storage == "cloud":
33
+ if not wiki_settings.CLOUD_API_URL or not wiki_settings.CLOUD_API_KEY:
34
+ errors.append(
35
+ Warning(
36
+ 'STORAGE="cloud" requires CLOUD_API_URL and CLOUD_API_KEY.',
37
+ id="django_admin_wiki.W001",
38
+ )
39
+ )
40
+ if not wiki_settings.CLOUD_API_KEY_READ:
41
+ errors.append(
42
+ Warning(
43
+ 'STORAGE="cloud": set CLOUD_API_KEY_READ so read-only Django users '
44
+ "can load pages (create a read-only key in the Premium dashboard).",
45
+ id="django_admin_wiki.W003",
46
+ )
47
+ )
48
+ migration_modules = getattr(settings, "MIGRATION_MODULES", {}) or {}
49
+ if migration_modules.get("django_admin_wiki") is not None:
50
+ errors.append(
51
+ Warning(
52
+ 'For Premium cloud mode set MIGRATION_MODULES = {"django_admin_wiki": None} '
53
+ "so host migrate does not create local wiki tables.",
54
+ id="django_admin_wiki.W002",
55
+ )
56
+ )
57
+ return errors
@@ -0,0 +1,94 @@
1
+ """Configurable settings for django-admin-wiki.
2
+
3
+ Read from Django settings dict ``DJANGO_ADMIN_WIKI``::
4
+
5
+ DJANGO_ADMIN_WIKI = {
6
+ "SEARCH_BACKEND": "postgres", # or "fallback"
7
+ "SEARCH_CONFIG": "french",
8
+ "EDITOR": "summernote", # "summernote" | "textarea" | "custom"
9
+ "EDITOR_WIDGET": None, # dotted path when EDITOR="custom"
10
+ "ALLOW_MARKDOWN_READ": True,
11
+ "BLEACH_ALLOWED_TAGS": None,
12
+ "BLEACH_ALLOWED_ATTRIBUTES": None,
13
+ "DOCS_DIR": None,
14
+ "MKDOCS_YML": None,
15
+ "MEDIA_ROOT_SUBDIR": "wiki",
16
+ "MEDIA_BACKEND": "local", # "local" | "django" (default_storage / S3)
17
+ "MEDIA_MAX_UPLOAD_BYTES": 5 * 1024 * 1024,
18
+ "MEDIA_ALLOWED_CONTENT_TYPES": None, # None → image/* defaults
19
+ "HOME_SLUG": "index",
20
+ "URL_PREFIX": "wiki/",
21
+ "MAX_NAV_DEPTH": 3, # root + up to 2 nested levels
22
+ "STORAGE": "local", # or "cloud" for Premium (no local wiki tables)
23
+ "CLOUD_API_URL": "https://django-admin-wiki.enprod.fr",
24
+ "CLOUD_API_KEY": "daw_live_...", # write key
25
+ "CLOUD_API_KEY_READ": "daw_live_...", # read-only key (readers)
26
+ }
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from pathlib import Path
32
+
33
+ from django.conf import settings
34
+
35
+ DEFAULTS = {
36
+ "SEARCH_BACKEND": "postgres",
37
+ "SEARCH_CONFIG": "french",
38
+ "EDITOR": "summernote",
39
+ "EDITOR_WIDGET": None,
40
+ "ALLOW_MARKDOWN_READ": True,
41
+ "BLEACH_ALLOWED_TAGS": None,
42
+ "BLEACH_ALLOWED_ATTRIBUTES": None,
43
+ "DOCS_DIR": None,
44
+ "MKDOCS_YML": None,
45
+ "MEDIA_ROOT_SUBDIR": "wiki",
46
+ "MEDIA_BACKEND": "local", # "local" | "django" (use Django STORAGES / Cellar)
47
+ "MEDIA_MAX_UPLOAD_BYTES": 5 * 1024 * 1024,
48
+ "MEDIA_ALLOWED_CONTENT_TYPES": None,
49
+ "HOME_SLUG": "index",
50
+ "URL_PREFIX": "wiki/",
51
+ "MAX_NAV_DEPTH": 3,
52
+ "STORAGE": "local", # "local" | "cloud"
53
+ "CLOUD_API_URL": "",
54
+ "CLOUD_API_KEY": "",
55
+ "CLOUD_API_KEY_READ": "",
56
+ }
57
+
58
+
59
+ class WikiSettings:
60
+ """Lazy accessor for ``DJANGO_ADMIN_WIKI`` with defaults."""
61
+
62
+ def __getattr__(self, name: str):
63
+ if name not in DEFAULTS:
64
+ raise AttributeError(f"Unknown wiki setting: {name}")
65
+ user = getattr(settings, "DJANGO_ADMIN_WIKI", None) or {}
66
+ return user.get(name, DEFAULTS[name])
67
+
68
+ def get_docs_dir(self) -> Path:
69
+ configured = self.DOCS_DIR
70
+ if configured:
71
+ return Path(configured)
72
+ return Path(settings.BASE_DIR) / "wiki_docs"
73
+
74
+ def get_mkdocs_yml(self) -> Path:
75
+ configured = self.MKDOCS_YML
76
+ if configured:
77
+ return Path(configured)
78
+ return self.get_docs_dir() / "mkdocs.yml"
79
+
80
+ def get_media_root(self) -> Path:
81
+ """Directory served under /wiki/media/ (defaults to docs dir)."""
82
+ return self.get_docs_dir()
83
+
84
+ def url_prefix_path(self) -> str:
85
+ """URL prefix without leading slash, with trailing slash (e.g. ``wiki/``)."""
86
+ prefix = (self.URL_PREFIX or "wiki/").strip("/")
87
+ return f"{prefix}/" if prefix else ""
88
+
89
+ def wiki_url_root(self) -> str:
90
+ """Absolute-ish path root for link rewriting (e.g. ``/wiki/``)."""
91
+ return f"/{self.url_prefix_path()}"
92
+
93
+
94
+ wiki_settings = WikiSettings()
@@ -0,0 +1,29 @@
1
+ """Search-vector field that works on PostgreSQL and other backends."""
2
+
3
+ from django.db import models
4
+
5
+
6
+ class WikiSearchVectorField(models.Field):
7
+ """Maps to ``tsvector`` on PostgreSQL; unused ``text`` elsewhere (fallback search)."""
8
+
9
+ description = "PostgreSQL tsvector (text stub on other backends)"
10
+ empty_strings_allowed = True
11
+
12
+ def __init__(self, *args, **kwargs):
13
+ kwargs.setdefault("null", True)
14
+ kwargs.setdefault("editable", False)
15
+ super().__init__(*args, **kwargs)
16
+
17
+ def db_type(self, connection):
18
+ if connection.vendor == "postgresql":
19
+ return "tsvector"
20
+ return "text"
21
+
22
+ def from_db_value(self, value, expression, connection):
23
+ return value
24
+
25
+ def to_python(self, value):
26
+ return value
27
+
28
+ def get_prep_value(self, value):
29
+ return value
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+
5
+ from django import forms
6
+ from django.urls import NoReverseMatch, reverse
7
+
8
+ from django_admin_wiki.conf import wiki_settings
9
+ from django_admin_wiki.render import content_for_editor, sanitize_wiki_html
10
+ from django_admin_wiki.repositories.base import PageRecord
11
+
12
+
13
+ def get_editor_widget():
14
+ """Return the content widget based on ``DJANGO_ADMIN_WIKI['EDITOR']``.
15
+
16
+ Defaults to Summernote (WYSIWYG). Set ``EDITOR`` to ``"textarea"`` or
17
+ ``"custom"`` (with ``EDITOR_WIDGET``) to override.
18
+ """
19
+ editor = (wiki_settings.EDITOR or "summernote").lower()
20
+ if editor == "textarea":
21
+ return forms.Textarea(attrs={"rows": 18, "class": "wiki-md-editor"})
22
+ if editor == "custom":
23
+ path = wiki_settings.EDITOR_WIDGET
24
+ if not path:
25
+ raise ValueError(
26
+ 'DJANGO_ADMIN_WIKI["EDITOR_WIDGET"] is required when EDITOR="custom"'
27
+ )
28
+ module_path, attr = path.rsplit(".", 1)
29
+ obj = getattr(import_module(module_path), attr)
30
+ return obj() if isinstance(obj, type) else obj
31
+ try:
32
+ from django_admin_wiki.widgets import WikiSummernoteWidget
33
+ except ImportError as exc:
34
+ raise ImportError(
35
+ 'EDITOR="summernote" requires django-summernote. '
36
+ 'Install it or set DJANGO_ADMIN_WIKI["EDITOR"] = "textarea".'
37
+ ) from exc
38
+ upload_url = ""
39
+ try:
40
+ upload_url = reverse("django_admin_wiki:wiki_media_upload")
41
+ except NoReverseMatch:
42
+ pass
43
+ return WikiSummernoteWidget(upload_url=upload_url)
44
+
45
+
46
+ class WikiPageEditForm(forms.Form):
47
+ title = forms.CharField(label="Title", max_length=255)
48
+ slug = forms.SlugField(label="Slug", max_length=255, required=False)
49
+ nav_label = forms.CharField(label="Menu label", max_length=255, required=False)
50
+ parent = forms.ChoiceField(label="Parent", required=False)
51
+ sort_order = forms.IntegerField(label="Sort order", initial=0, required=False)
52
+ content = forms.CharField(label="Content", required=False)
53
+
54
+ def __init__(self, *args, parents=None, page: PageRecord | None = None, **kwargs):
55
+ # Back-compat: parents_qs (ORM) or parents (PageRecord list)
56
+ parents_qs = kwargs.pop("parents_qs", None)
57
+ super().__init__(*args, **kwargs)
58
+ self.fields["content"].widget = get_editor_widget()
59
+
60
+ choices = [("", "— Root —")]
61
+ if parents is not None:
62
+ for p in parents:
63
+ choices.append((p.slug, p.display_label()))
64
+ elif parents_qs is not None:
65
+ for p in parents_qs:
66
+ choices.append((p.slug, getattr(p, "display_label", lambda: p.title)()))
67
+ self.fields["parent"].choices = choices
68
+
69
+ if page is not None:
70
+ self.fields["slug"].widget = forms.HiddenInput()
71
+ self.fields["title"].initial = page.title
72
+ self.fields["nav_label"].initial = page.nav_label
73
+ self.fields["parent"].initial = page.parent_slug or ""
74
+ self.fields["sort_order"].initial = page.sort_order
75
+ self.fields["content"].initial = content_for_editor(page.content)
76
+ else:
77
+ self.fields["slug"].help_text = "Optional — generated from the title if empty"
78
+
79
+ def clean_content(self):
80
+ return sanitize_wiki_html(self.cleaned_data.get("content") or "")
81
+
82
+ def clean_parent(self):
83
+ value = self.cleaned_data.get("parent") or ""
84
+ return value or None
File without changes
File without changes
@@ -0,0 +1,234 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+ from django.core.management.base import BaseCommand
6
+ from django.utils.text import slugify
7
+
8
+ from django_admin_wiki.conf import wiki_settings
9
+ from django_admin_wiki.models import WikiPage
10
+ from django_admin_wiki.render import markdown_to_html, sanitize_wiki_html
11
+ from django_admin_wiki.services import refresh_search_vector
12
+
13
+
14
+ class Command(BaseCommand):
15
+ help = (
16
+ "Import MkDocs Markdown files into WikiPage. "
17
+ "By default converts Markdown to HTML for the WYSIWYG editor."
18
+ )
19
+
20
+ def add_arguments(self, parser):
21
+ parser.add_argument(
22
+ "--docs-dir",
23
+ type=str,
24
+ default="",
25
+ help="Docs directory (default: DJANGO_ADMIN_WIKI.DOCS_DIR or wiki_docs/)",
26
+ )
27
+ parser.add_argument(
28
+ "--mkdocs",
29
+ type=str,
30
+ default="",
31
+ help="Path to mkdocs.yml (default: DOCS_DIR/mkdocs.yml)",
32
+ )
33
+ parser.add_argument(
34
+ "--slugs",
35
+ type=str,
36
+ default="",
37
+ help="Comma-separated slugs to import (empty = all pages).",
38
+ )
39
+ parser.add_argument(
40
+ "--keep-markdown",
41
+ action="store_true",
42
+ help="Do not convert to HTML (keep raw Markdown).",
43
+ )
44
+
45
+ def handle(self, *args, **opts):
46
+ docs_dir = Path(opts["docs_dir"] or wiki_settings.get_docs_dir())
47
+ mkdocs_path = Path(opts["mkdocs"] or wiki_settings.get_mkdocs_yml())
48
+ keep_markdown = bool(opts["keep_markdown"])
49
+ only_slugs = {
50
+ slugify(s.strip()) for s in (opts["slugs"] or "").split(",") if s.strip()
51
+ }
52
+
53
+ nav_meta = {}
54
+ if mkdocs_path.exists():
55
+ data = self._load_mkdocs_yaml(mkdocs_path)
56
+ nav_meta = self._flatten_nav(data.get("nav") or [])
57
+
58
+ md_files = sorted(docs_dir.rglob("*.md"))
59
+ if not md_files:
60
+ self.stderr.write(f"No .md files in {docs_dir}")
61
+ return
62
+
63
+ created_count = 0
64
+ updated_count = 0
65
+ skipped_count = 0
66
+ pages_by_file = {}
67
+
68
+ for md_file in md_files:
69
+ rel = md_file.relative_to(docs_dir).with_suffix("")
70
+ file_key = str(rel).replace("\\", "/")
71
+ slug = slugify("-".join(rel.parts)) or slugify(md_file.stem)
72
+ if only_slugs and slug not in only_slugs:
73
+ skipped_count += 1
74
+ continue
75
+ raw = md_file.read_text(encoding="utf-8")
76
+ title = self._extract_title(raw, md_file.stem)
77
+ content = self._rewrite_content(raw)
78
+ if not keep_markdown:
79
+ content = sanitize_wiki_html(markdown_to_html(content))
80
+ meta = nav_meta.get(file_key) or nav_meta.get(md_file.name) or {}
81
+ nav_label = meta.get("label") or ""
82
+ sort_order = meta.get("sort_order", 0)
83
+
84
+ page, created = WikiPage.objects.update_or_create(
85
+ slug=slug,
86
+ defaults={
87
+ "title": title,
88
+ "content": content,
89
+ "nav_label": nav_label,
90
+ "sort_order": sort_order,
91
+ },
92
+ )
93
+ pages_by_file[file_key] = page
94
+ pages_by_file[md_file.name] = page
95
+ refresh_search_vector(page)
96
+ if created:
97
+ created_count += 1
98
+ self.stdout.write(f"Created {slug}")
99
+ else:
100
+ updated_count += 1
101
+ self.stdout.write(f"Updated {slug}")
102
+
103
+ for file_key, meta in nav_meta.items():
104
+ page = pages_by_file.get(file_key)
105
+ if not page:
106
+ continue
107
+ parent_file = meta.get("parent_file")
108
+ if parent_file and parent_file in pages_by_file:
109
+ parent = pages_by_file[parent_file]
110
+ if parent.pk != page.pk:
111
+ page.parent = parent
112
+ page.save(update_fields=["parent"])
113
+ elif meta.get("is_section_root"):
114
+ page.parent = None
115
+ page.save(update_fields=["parent"])
116
+
117
+ fmt = "Markdown" if keep_markdown else "HTML (WYSIWYG)"
118
+ self.stdout.write(
119
+ self.style.SUCCESS(
120
+ f"Done ({fmt}): {created_count} created, {updated_count} updated"
121
+ + (f", {skipped_count} skipped (--slugs)" if skipped_count else "")
122
+ )
123
+ )
124
+
125
+ def _load_mkdocs_yaml(self, mkdocs_path: Path) -> dict:
126
+ text = mkdocs_path.read_text(encoding="utf-8")
127
+ text = re.sub(
128
+ r"^!!python/name:[^\n]+$",
129
+ "null",
130
+ text,
131
+ flags=re.MULTILINE,
132
+ )
133
+
134
+ class SilentLoader(yaml.SafeLoader):
135
+ pass
136
+
137
+ SilentLoader.add_multi_constructor(
138
+ "tag:yaml.org,2002:python/",
139
+ lambda loader, suffix, node: None,
140
+ )
141
+ return yaml.load(text, Loader=SilentLoader) or {}
142
+
143
+ def _extract_title(self, raw: str, stem: str) -> str:
144
+ for line in raw.splitlines():
145
+ if line.startswith("# "):
146
+ return line[2:].strip()
147
+ return stem.replace("-", " ").replace("_", " ").title()
148
+
149
+ def _rewrite_content(self, raw: str) -> str:
150
+ root = wiki_settings.wiki_url_root() # e.g. /wiki/
151
+ media_root = f"{root}media/"
152
+
153
+ content = re.sub(
154
+ r"\]\(([^)/][^)]*?)\.md(#[^)]*)?\)",
155
+ lambda m: f"]({root}{slugify(Path(m.group(1)).stem)}/{m.group(2) or ''})",
156
+ raw,
157
+ )
158
+ content = re.sub(
159
+ rf"\]\((?!https?://|{re.escape(root)}|/static/)([^)]+\.(?:png|jpg|jpeg|gif|svg|webp))\)",
160
+ rf"]({media_root}\1)",
161
+ content,
162
+ )
163
+ return content
164
+
165
+ def _flatten_nav(self, nav, parent_file=None, counter=None):
166
+ if counter is None:
167
+ counter = [0]
168
+ result = {}
169
+ for item in nav:
170
+ if isinstance(item, str):
171
+ file_key = item[:-3] if item.endswith(".md") else item
172
+ counter[0] += 1
173
+ result[file_key] = {
174
+ "label": Path(file_key).name.replace("-", " ").replace("_", " ").title(),
175
+ "sort_order": counter[0],
176
+ "parent_file": parent_file,
177
+ }
178
+ result[Path(item).name if item.endswith(".md") else f"{file_key}.md"] = result[
179
+ file_key
180
+ ]
181
+ elif isinstance(item, dict):
182
+ for label, value in item.items():
183
+ if isinstance(value, str) and value.endswith(".md"):
184
+ file_key = value[:-3]
185
+ counter[0] += 1
186
+ meta = {
187
+ "label": label.strip(),
188
+ "sort_order": counter[0],
189
+ "parent_file": parent_file,
190
+ }
191
+ result[file_key] = meta
192
+ result[Path(value).name] = meta
193
+ elif isinstance(value, list):
194
+ first_md = self._first_md(value)
195
+ section_parent = None
196
+ if first_md:
197
+ file_key = first_md[:-3]
198
+ counter[0] += 1
199
+ meta = {
200
+ "label": label.strip(),
201
+ "sort_order": counter[0],
202
+ "parent_file": None,
203
+ "is_section_root": True,
204
+ }
205
+ result[file_key] = meta
206
+ result[Path(first_md).name] = meta
207
+ section_parent = file_key
208
+ child_nav = (
209
+ value[1:] if self._entry_file(value[0]) == first_md else value
210
+ )
211
+ else:
212
+ child_nav = value
213
+ result.update(
214
+ self._flatten_nav(
215
+ child_nav, parent_file=section_parent, counter=counter
216
+ )
217
+ )
218
+ return result
219
+
220
+ def _first_md(self, items):
221
+ for item in items:
222
+ f = self._entry_file(item)
223
+ if f:
224
+ return f
225
+ return None
226
+
227
+ def _entry_file(self, item):
228
+ if isinstance(item, str) and item.endswith(".md"):
229
+ return item
230
+ if isinstance(item, dict):
231
+ for value in item.values():
232
+ if isinstance(value, str) and value.endswith(".md"):
233
+ return value
234
+ return None
@@ -0,0 +1,15 @@
1
+ from django.core.management.base import BaseCommand
2
+
3
+ from django_admin_wiki.models import WikiPage
4
+ from django_admin_wiki.services import refresh_search_vector
5
+
6
+
7
+ class Command(BaseCommand):
8
+ help = "Rebuild PostgreSQL search vectors for all wiki pages."
9
+
10
+ def handle(self, *args, **options):
11
+ count = 0
12
+ for page in WikiPage.objects.iterator():
13
+ refresh_search_vector(page)
14
+ count += 1
15
+ self.stdout.write(self.style.SUCCESS(f"Refreshed search vectors for {count} page(s)."))
@@ -0,0 +1,21 @@
1
+ """Create the standard Wiki readers / Wiki editors groups."""
2
+
3
+ from django.core.management.base import BaseCommand
4
+
5
+ from django_admin_wiki.permissions import GROUP_EDITORS, GROUP_READERS, ensure_wiki_groups
6
+
7
+
8
+ class Command(BaseCommand):
9
+ help = (
10
+ "Create or refresh Django groups "
11
+ f"'{GROUP_READERS}' (view) and '{GROUP_EDITORS}' (view+add+change)."
12
+ )
13
+
14
+ def handle(self, *args, **options):
15
+ readers, editors = ensure_wiki_groups()
16
+ self.stdout.write(
17
+ self.style.SUCCESS(
18
+ f"OK — groups ready: '{readers.name}', '{editors.name}'. "
19
+ "Assign users in Django Admin → Users / Groups."
20
+ )
21
+ )