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.
- django_admin_wiki/__init__.py +3 -0
- django_admin_wiki/admin.py +89 -0
- django_admin_wiki/apps.py +57 -0
- django_admin_wiki/conf.py +94 -0
- django_admin_wiki/fields.py +29 -0
- django_admin_wiki/forms.py +84 -0
- django_admin_wiki/management/__init__.py +0 -0
- django_admin_wiki/management/commands/__init__.py +0 -0
- django_admin_wiki/management/commands/import_mkdocs.py +234 -0
- django_admin_wiki/management/commands/refresh_wiki_search_vectors.py +15 -0
- django_admin_wiki/management/commands/setup_wiki_groups.py +21 -0
- django_admin_wiki/media_storage.py +133 -0
- django_admin_wiki/migrations/0001_initial.py +144 -0
- django_admin_wiki/migrations/__init__.py +0 -0
- django_admin_wiki/models.py +75 -0
- django_admin_wiki/permissions.py +61 -0
- django_admin_wiki/render.py +145 -0
- django_admin_wiki/repositories/__init__.py +45 -0
- django_admin_wiki/repositories/base.py +74 -0
- django_admin_wiki/repositories/cloud.py +210 -0
- django_admin_wiki/repositories/local.py +108 -0
- django_admin_wiki/services.py +141 -0
- django_admin_wiki/static/django_admin_wiki/summernote_upload.js +82 -0
- django_admin_wiki/static/django_admin_wiki/wiki.css +231 -0
- django_admin_wiki/templates/admin/django_admin_wiki/wikipage/change_list.html +8 -0
- django_admin_wiki/templates/django_admin_wiki/base.html +50 -0
- django_admin_wiki/templates/django_admin_wiki/edit.html +61 -0
- django_admin_wiki/templates/django_admin_wiki/home.html +24 -0
- django_admin_wiki/templates/django_admin_wiki/includes/nav_items.html +12 -0
- django_admin_wiki/templates/django_admin_wiki/page.html +18 -0
- django_admin_wiki/templates/django_admin_wiki/search.html +22 -0
- django_admin_wiki/templatetags/__init__.py +0 -0
- django_admin_wiki/urls.py +16 -0
- django_admin_wiki/views.py +263 -0
- django_admin_wiki/widgets.py +25 -0
- django_admin_wiki-0.1.0.dist-info/METADATA +251 -0
- django_admin_wiki-0.1.0.dist-info/RECORD +39 -0
- django_admin_wiki-0.1.0.dist-info/WHEEL +4 -0
- django_admin_wiki-0.1.0.dist-info/licenses/LICENSE +29 -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
|
+
)
|