This commit is contained in:
lwark
2025-09-17 07:52:16 -05:00
commit a2ff72dda8
584 changed files with 52247 additions and 0 deletions

22
src/manage.py Normal file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'wiki_main.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

1
src/wiki/__about__.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.12.1"

17
src/wiki/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
# This package and all its sub-packages are part of django-wiki,
# except where otherwise stated.
#
# django-wiki is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# django-wiki is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with django-wiki. If not, see <http://www.gnu.org/licenses/>.
default_app_config = "wiki.apps.WikiConfig"

107
src/wiki/admin.py Normal file
View File

@@ -0,0 +1,107 @@
from django import forms
from django.contrib import admin
from django.contrib.contenttypes.admin import GenericTabularInline
from django.utils.translation import gettext_lazy as _
from mptt.admin import MPTTModelAdmin
from . import editors
from . import models
class ArticleObjectAdmin(GenericTabularInline):
model = models.ArticleForObject
extra = 1
max_num = 1
raw_id_fields = ("article",)
class ArticleRevisionForm(forms.ModelForm):
class Meta:
model = models.ArticleRevision
exclude = ()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# TODO: This pattern is too weird
editor = editors.getEditor()
self.fields["content"].widget = editor.get_admin_widget(self.instance)
class ArticleRevisionAdmin(admin.ModelAdmin):
form = ArticleRevisionForm
list_display = ("title", "created", "modified", "user", "ip_address")
class Media:
js = editors.getEditorClass().AdminMedia.js
css = editors.getEditorClass().AdminMedia.css
class ArticleRevisionInline(admin.TabularInline):
model = models.ArticleRevision
form = ArticleRevisionForm
fk_name = "article"
extra = 1
fields = (
"content",
"title",
"deleted",
"locked",
)
class Media:
js = editors.getEditorClass().AdminMedia.js
css = editors.getEditorClass().AdminMedia.css
class ArticleForm(forms.ModelForm):
class Meta:
model = models.Article
exclude = ()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
revisions = models.ArticleRevision.objects.select_related(
"article"
).filter(article=self.instance)
self.fields["current_revision"].queryset = revisions
else:
self.fields[
"current_revision"
].queryset = models.ArticleRevision.objects.none()
self.fields["current_revision"].widget = forms.HiddenInput()
class ArticleAdmin(admin.ModelAdmin):
inlines = [ArticleRevisionInline]
form = ArticleForm
search_fields = ("current_revision__title", "current_revision__content")
class URLPathAdmin(MPTTModelAdmin):
inlines = [ArticleObjectAdmin]
list_filter = (
"site",
"articles__article__current_revision__deleted",
"articles__article__created",
"articles__article__modified",
)
list_display = ("__str__", "article", "get_created")
raw_id_fields = ("article",)
def get_created(self, instance):
return instance.article.created
get_created.short_description = _("created")
def save_model(self, request, obj, form, change):
"""
Ensure that there is a generic relation from the article to the URLPath
"""
obj.save()
obj.article.add_object_relation(obj)
admin.site.register(models.URLPath, URLPathAdmin)
admin.site.register(models.Article, ArticleAdmin)
admin.site.register(models.ArticleRevision, ArticleRevisionAdmin)

30
src/wiki/apps.py Normal file
View File

@@ -0,0 +1,30 @@
from django.apps import AppConfig
from django.core.checks import register
from django.utils.translation import gettext_lazy as _
from wiki.core.plugins.loader import load_wiki_plugins
from . import checks
class WikiConfig(AppConfig):
default_site = "wiki.sites.WikiSite"
name = "wiki"
verbose_name = _("Wiki")
def ready(self):
register(
checks.check_for_required_installed_apps,
checks.Tags.required_installed_apps,
)
register(
checks.check_for_obsolete_installed_apps,
checks.Tags.obsolete_installed_apps,
)
register(
checks.check_for_context_processors, checks.Tags.context_processors
)
register(
checks.check_for_fields_in_custom_user_model,
checks.Tags.fields_in_custom_user_model,
)
load_wiki_plugins()

128
src/wiki/checks.py Normal file
View File

@@ -0,0 +1,128 @@
from django.apps import apps
from django.core.checks import Error
from django.template import Engine
class Tags:
required_installed_apps = "required_installed_apps"
obsolete_installed_apps = "obsolete_installed_apps"
context_processors = "context_processors"
fields_in_custom_user_model = "fields_in_custom_user_model"
REQUIRED_INSTALLED_APPS = (
# module name, package name, error code
("mptt", "django-mptt", "E001"),
("sekizai", "django-sekizai", "E002"),
("django.contrib.humanize", "django.contrib.humanize", "E003"),
("django.contrib.contenttypes", "django.contrib.contenttypes", "E004"),
("django.contrib.sites", "django.contrib.sites", "E005"),
)
OBSOLETE_INSTALLED_APPS = (
# obsolete module name, new module name, error code
("django_notify", "django_nyt", "E006"),
)
REQUIRED_CONTEXT_PROCESSORS = (
# context processor name, error code
("django.contrib.auth.context_processors.auth", "E007"),
("django.template.context_processors.request", "E008"),
("sekizai.context_processors.sekizai", "E009"),
)
FIELDS_IN_CUSTOM_USER_MODEL = (
# check function, field fetcher, required field type, error code
("check_user_field", "USERNAME_FIELD", "CharField", "E010"),
("check_email_field", "get_email_field_name()", "EmailField", "E011"),
)
def check_for_required_installed_apps(app_configs, **kwargs):
errors = []
for app in REQUIRED_INSTALLED_APPS:
if not apps.is_installed(app[0]):
errors.append(
Error(
"needs %s in INSTALLED_APPS" % app[1],
id="wiki.%s" % app[2],
)
)
return errors
def check_for_obsolete_installed_apps(app_configs, **kwargs):
errors = []
for app in OBSOLETE_INSTALLED_APPS:
if apps.is_installed(app[0]):
errors.append(
Error(
"You need to change from %s to %s in INSTALLED_APPS and your urlconfig."
% (app[0], app[1]),
id="wiki.%s" % app[2],
)
)
return errors
def check_for_context_processors(app_configs, **kwargs):
errors = []
# Pattern from django.contrib.admin.checks
try:
default_template_engine = Engine.get_default()
except Exception:
# Skip this non-critical check:
# 1. if the user has a non-trivial TEMPLATES setting and Django
# can't find a default template engine
# 2. if anything goes wrong while loading template engines, in
# order to avoid raising an exception from a confusing location
# Catching ImproperlyConfigured suffices for 1. but 2. requires
# catching all exceptions.
pass
else:
context_processors = default_template_engine.context_processors
for context_processor in REQUIRED_CONTEXT_PROCESSORS:
if context_processor[0] not in context_processors:
errors.append(
Error(
"needs %s in TEMPLATES[*]['OPTIONS']['context_processors']"
% context_processor[0],
id="wiki.%s" % context_processor[1],
)
)
return errors
def check_for_fields_in_custom_user_model(app_configs, **kwargs):
errors = []
from wiki.conf import settings
if not settings.ACCOUNT_HANDLING:
return errors
import wiki.forms_account_handling
from django.contrib.auth import get_user_model
User = get_user_model()
for (
check_function_name,
field_fetcher,
required_field_type,
error_code,
) in FIELDS_IN_CUSTOM_USER_MODEL:
function = getattr(wiki.forms_account_handling, check_function_name)
if not function(User):
errors.append(
Error(
"%s.%s.%s refers to a field that is not of type %s"
% (
User.__module__,
User.__name__,
field_fetcher,
required_field_type,
),
hint="If you have your own login/logout views, turn off settings.WIKI_ACCOUNT_HANDLING",
obj=User,
id="wiki.%s" % error_code,
)
)
return errors

View File

315
src/wiki/conf/settings.py Normal file
View File

@@ -0,0 +1,315 @@
import bleach
from django.conf import settings as django_settings
from django.contrib.messages import constants as messages
from django.core.files.storage import default_storage
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
#: Should urls be case sensitive?
URL_CASE_SENSITIVE = getattr(django_settings, "WIKI_URL_CASE_SENSITIVE", False)
# Non-configurable (at the moment)
WIKI_LANGUAGE = "markdown"
#: The editor class to use -- maybe a 3rd party or your own...? You can always
#: extend the built-in editor and customize it!
EDITOR = getattr(
django_settings, "WIKI_EDITOR", "wiki.editors.markitup.MarkItUp"
)
#: Whether to use Bleach or not. It's not recommended to turn this off unless
#: you know what you're doing and you don't want to use the other options.
MARKDOWN_SANITIZE_HTML = getattr(
django_settings, "WIKI_MARKDOWN_SANITIZE_HTML", True
)
#: Arguments for the Markdown instance, as a dictionary. The "extensions" key
#: should be a list of extra extensions to use besides the built-in django-wiki
#: extensions, and the "extension_configs" should be a dictionary, specifying
#: the keyword-arguments to pass to each extension.
#:
#: For a list of extensions officially supported by Python-Markdown, see:
#: https://python-markdown.github.io/extensions/
#:
#: To set a custom title for table of contents, specify the following in your
#: Django project settings::
#:
#: WIKI_MARKDOWN_KWARGS = {
#: 'extension_configs': {
#: 'wiki.plugins.macros.mdx.toc': {'title': 'Contents of this article'},
#: },
#: }
#:
#: Besides the extensions enabled by the "extensions" key, the following
#: built-in django-wiki extensions can be configured with "extension_configs":
#: "wiki.core.markdown.mdx.codehilite", "wiki.core.markdown.mdx.previewlinks",
#: "wiki.core.markdown.mdx.responsivetable", "wiki.plugins.macros.mdx.macro",
#: "wiki.plugins.macros.mdx.toc", "wiki.plugins.macros.mdx.wikilinks".
MARKDOWN_KWARGS = {
"extensions": [
"markdown.extensions.footnotes",
"markdown.extensions.attr_list",
"markdown.extensions.footnotes",
"markdown.extensions.attr_list",
"markdown.extensions.def_list",
"markdown.extensions.tables",
"markdown.extensions.abbr",
"markdown.extensions.sane_lists",
],
"extension_configs": {
"wiki.plugins.macros.mdx.toc": {"title": _("Contents")}
},
}
MARKDOWN_KWARGS.update(getattr(django_settings, "WIKI_MARKDOWN_KWARGS", {}))
_default_tag_whitelists = bleach.ALLOWED_TAGS.union(
{
"figure",
"figcaption",
"br",
"hr",
"p",
"div",
"img",
"pre",
"span",
"sup",
"table",
"thead",
"tbody",
"th",
"tr",
"td",
"dl",
"dt",
"dd",
}
).union({f"h{n}" for n in range(1, 7)})
#: List of allowed tags in Markdown article contents.
MARKDOWN_HTML_WHITELIST = _default_tag_whitelists
MARKDOWN_HTML_WHITELIST = MARKDOWN_HTML_WHITELIST.union(
getattr(django_settings, "WIKI_MARKDOWN_HTML_WHITELIST", frozenset())
)
_default_attribute_whitelist = bleach.ALLOWED_ATTRIBUTES
for tag in MARKDOWN_HTML_WHITELIST:
if tag not in _default_attribute_whitelist:
_default_attribute_whitelist[tag] = []
_default_attribute_whitelist[tag].append("class")
_default_attribute_whitelist[tag].append("id")
_default_attribute_whitelist[tag].append("target")
_default_attribute_whitelist[tag].append("rel")
_default_attribute_whitelist["img"].append("src")
_default_attribute_whitelist["img"].append("alt")
_default_attribute_whitelist["td"].append("align")
#: Dictionary of allowed attributes in Markdown article contents.
MARKDOWN_HTML_ATTRIBUTES = _default_attribute_whitelist
MARKDOWN_HTML_ATTRIBUTES.update(
getattr(django_settings, "WIKI_MARKDOWN_HTML_ATTRIBUTES", {})
)
#: Allowed inline styles in Markdown article contents, default is no styles
#: (empty list).
MARKDOWN_HTML_STYLES = getattr(
django_settings, "WIKI_MARKDOWN_HTML_STYLES", []
)
_project_defined_attrs = getattr(
django_settings, "WIKI_MARKDOWN_HTML_ATTRIBUTE_WHITELIST", False
)
# If styles are allowed but no custom attributes are defined, we allow styles
# for all kinds of tags.
if MARKDOWN_HTML_STYLES and not _project_defined_attrs:
MARKDOWN_HTML_ATTRIBUTES["*"] = "style"
#: This slug is used in URLPath if an article has been deleted. The children of the
#: URLPath of that article are moved to lost and found. They keep their permissions
#: and all their content.
LOST_AND_FOUND_SLUG = getattr(
django_settings, "WIKI_LOST_AND_FOUND_SLUG", "lost-and-found"
)
#: When True, this blocks new slugs that resolve to non-wiki views, stopping
#: users creating articles that conflict with overlapping URLs from other apps.
CHECK_SLUG_URL_AVAILABLE = getattr(
django_settings, "WIKI_CHECK_SLUG_URL_AVAILABLE", True
)
#: Do we want to log IPs of anonymous users?
LOG_IPS_ANONYMOUS = getattr(django_settings, "WIKI_LOG_IPS_ANONYMOUS", True)
#: Do we want to log IPs of logged in users?
LOG_IPS_USERS = getattr(django_settings, "WIKI_LOG_IPS_USERS", False)
#: Mapping from message.level to bootstrap class names.
MESSAGE_TAG_CSS_CLASS = getattr(
django_settings,
"WIKI_MESSAGE_TAG_CSS_CLASS",
{
messages.DEBUG: "alert alert-info",
messages.ERROR: "alert alert-danger",
messages.INFO: "alert alert-info",
messages.SUCCESS: "alert alert-success",
messages.WARNING: "alert alert-warning",
},
)
#: Weather to append a trailing slash to rendered Wikilinks. Defaults to True
WIKILINKS_TRAILING_SLASH = getattr(
django_settings, "WIKI_WIKILINKS_TRAILING_SLASH", True
)
####################################
# PERMISSIONS AND ACCOUNT HANDLING #
####################################
# NB! None of these callables need to handle anonymous users as they are treated
# in separate settings...
#: A function returning True/False if a user has permission to
#: read contents of an article and plugins.
#: Relevance: Viewing articles and plugins.
CAN_READ = getattr(django_settings, "WIKI_CAN_READ", None)
#: A function returning True/False if a user has permission to
#: change contents, i.e. add new revisions to an article.
#: Often, plugins also use this.
#: Relevance: Editing articles, changing revisions, editing plugins.
CAN_WRITE = getattr(django_settings, "WIKI_CAN_WRITE", None)
#: A function returning True/False if a user has permission to assign
#: permissions on an article.
#: Relevance: Changing owner and group membership.
CAN_ASSIGN = getattr(django_settings, "WIKI_CAN_ASSIGN", None)
#: A function returning True/False if the owner of an article has permission
#: to change the group to a user's own groups.
#: Relevance: Changing group membership.
CAN_ASSIGN_OWNER = getattr(django_settings, "WIKI_ASSIGN_OWNER", None)
#: A function returning True/False if a user has permission to change
#: read/write access for groups and others.
CAN_CHANGE_PERMISSIONS = getattr(
django_settings, "WIKI_CAN_CHANGE_PERMISSIONS", None
)
#: Specifies if a user has access to soft deletion of articles.
CAN_DELETE = getattr(django_settings, "WIKI_CAN_DELETE", None)
#: A function returning True/False if a user has permission to change
#: moderate, ie. lock articles and permanently delete content.
CAN_MODERATE = getattr(django_settings, "WIKI_CAN_MODERATE", None)
#: A function returning True/False if a user has permission to create
#: new groups and users for the wiki.
CAN_ADMIN = getattr(django_settings, "WIKI_CAN_ADMIN", None)
#: Treat anonymous (i.e. non logged in) users as the "other" user group.
ANONYMOUS = getattr(django_settings, "WIKI_ANONYMOUS", True)
#: Globally enable write access for anonymous users, if true anonymous users
#: will be treated as the others_write boolean field on models.Article.
ANONYMOUS_WRITE = getattr(django_settings, "WIKI_ANONYMOUS_WRITE", False)
#: Globally enable create access for anonymous users.
#: Defaults to ``ANONYMOUS_WRITE``.
ANONYMOUS_CREATE = getattr(
django_settings, "WIKI_ANONYMOUS_CREATE", ANONYMOUS_WRITE
)
#: Default setting to allow anonymous users upload access. Used in
#: plugins.attachments and plugins.images, and can be overwritten in
#: these plugins.
ANONYMOUS_UPLOAD = getattr(django_settings, "WIKI_ANONYMOUS_UPLOAD", False)
#: Sign up, login and logout views should be accessible.
ACCOUNT_HANDLING = getattr(django_settings, "WIKI_ACCOUNT_HANDLING", True)
#: Signup allowed? If it's not allowed, logged in superusers can still access
#: the signup page to create new users.
ACCOUNT_SIGNUP_ALLOWED = ACCOUNT_HANDLING and getattr(
django_settings, "WIKI_ACCOUNT_SIGNUP_ALLOWED", True
)
if ACCOUNT_HANDLING:
LOGIN_URL = reverse_lazy("wiki:login")
LOGOUT_URL = reverse_lazy("wiki:logout")
SIGNUP_URL = reverse_lazy("wiki:signup")
else:
LOGIN_URL = getattr(django_settings, "LOGIN_URL", "/")
LOGOUT_URL = getattr(django_settings, "LOGOUT_URL", "/")
SIGNUP_URL = getattr(django_settings, "WIKI_SIGNUP_URL", "/")
##################
# OTHER SETTINGS #
##################
#: Maximum amount of children to display in a menu before showing "+more".
#: NEVER set this to 0 as it will wrongly inform the user that there are no
#: children and for instance that an article can be safely deleted.
SHOW_MAX_CHILDREN = getattr(django_settings, "WIKI_SHOW_MAX_CHILDREN", 20)
#: User Bootstrap's select widget. Switch off if you're not using Bootstrap!
USE_BOOTSTRAP_SELECT_WIDGET = getattr(
django_settings, "WIKI_USE_BOOTSTRAP_SELECT_WIDGET", True
)
#: Dotted name of the class used to construct urlpatterns for the wiki.
#: Default is wiki.urls.WikiURLPatterns. To customize urls or view handlers,
#: you can derive from this.
URL_CONFIG_CLASS = getattr(django_settings, "WIKI_URL_CONFIG_CLASS", None)
#: Seconds of timeout before renewing the article cache. Articles are automatically
#: renewed whenever an edit occurs but article content may be generated from
#: other objects that are changed.
CACHE_TIMEOUT = getattr(django_settings, "WIKI_CACHE_TIMEOUT", 600)
#: Choose the Group model to use for permission handling. Defaults to django's auth.Group.
GROUP_MODEL = getattr(django_settings, "WIKI_GROUP_MODEL", "auth.Group")
###################
# SPAM PROTECTION #
###################
#: Maximum allowed revisions per hour for any given user or IP.
REVISIONS_PER_HOUR = getattr(django_settings, "WIKI_REVISIONS_PER_HOUR", 60)
#: Maximum allowed revisions per minute for any given user or IP.
REVISIONS_PER_MINUTES = getattr(
django_settings, "WIKI_REVISIONS_PER_MINUTES", 5
)
#: Maximum allowed revisions per hour for any anonymous user and any IP.
REVISIONS_PER_HOUR_ANONYMOUS = getattr(
django_settings, "WIKI_REVISIONS_PER_HOUR_ANONYMOUS", 10
)
#: Maximum allowed revisions per minute for any anonymous user and any IP.
REVISIONS_PER_MINUTES_ANONYMOUS = getattr(
django_settings, "WIKI_REVISIONS_PER_MINUTES_ANONYMOUS", 2
)
#: Number of minutes to look back for looking up ``REVISIONS_PER_MINUTES``
#: and ``REVISIONS_PER_MINUTES_ANONYMOUS``.
REVISIONS_MINUTES_LOOKBACK = getattr(
django_settings, "WIKI_REVISIONS_MINUTES_LOOKBACK", 2
)
###########
# STORAGE #
###########
#: Default Django storage backend to use for images, attachments etc.
STORAGE_BACKEND = getattr(
django_settings, "WIKI_STORAGE_BACKEND", default_storage
)
#: Use django-sendfile for sending out files? Otherwise the whole file is
#: first read into memory and than send with a mime type based on the file.
USE_SENDFILE = getattr(django_settings, "WIKI_ATTACHMENTS_USE_SENDFILE", False)

View File

11
src/wiki/core/diff.py Normal file
View File

@@ -0,0 +1,11 @@
import difflib
def simple_merge(txt1, txt2):
"""Merges two texts"""
differ = difflib.Differ(charjunk=difflib.IS_CHARACTER_JUNK)
diff = differ.compare(txt1.splitlines(1), txt2.splitlines(1))
content = "".join([_l[2:] for _l in diff])
return content

View File

@@ -0,0 +1,12 @@
# If no root URL is found, we raise this...
class NoRootURL(Exception):
pass
# If there is more than one...
class MultipleRootURLs(Exception):
pass

59
src/wiki/core/http.py Normal file
View File

@@ -0,0 +1,59 @@
import mimetypes
import os
from datetime import datetime
from django.http import HttpResponse
from django.utils import dateformat
from django.utils.encoding import filepath_to_uri
from django.utils.http import http_date
from wiki.conf import settings
def django_sendfile_response(request, filepath):
from sendfile import sendfile
return sendfile(request, filepath)
def send_file(request, filepath, last_modified=None, filename=None):
fullpath = filepath
# Respect the If-Modified-Since header.
statobj = os.stat(fullpath)
if filename:
mimetype, encoding = mimetypes.guess_type(filename)
else:
mimetype, encoding = mimetypes.guess_type(fullpath)
mimetype = mimetype or "application/octet-stream"
if settings.USE_SENDFILE:
response = django_sendfile_response(request, filepath)
else:
response = HttpResponse(
open(fullpath, "rb").read(), content_type=mimetype
)
if not last_modified:
response["Last-Modified"] = http_date(statobj.st_mtime)
else:
if isinstance(last_modified, datetime):
last_modified = float(dateformat.format(last_modified, "U"))
response["Last-Modified"] = http_date(epoch_seconds=last_modified)
response["Content-Length"] = statobj.st_size
if encoding:
response["Content-Encoding"] = encoding
if filename:
filename_escaped = filepath_to_uri(filename)
if "pdf" in mimetype.lower():
response["Content-Disposition"] = (
"inline; filename=%s" % filename_escaped
)
else:
response["Content-Disposition"] = (
"attachment; filename=%s" % filename_escaped
)
return response

View File

@@ -0,0 +1,117 @@
import bleach
import markdown
from bleach.css_sanitizer import CSSSanitizer
from wiki.conf import settings
from wiki.core.plugins import registry as plugin_registry
class ArticleMarkdown(markdown.Markdown):
def __init__(self, article, preview=False, user=None, *args, **kwargs):
kwargs.update(settings.MARKDOWN_KWARGS)
kwargs["extensions"] = self.get_markdown_extensions()
super().__init__(*args, **kwargs)
self.article = article
self.preview = preview
self.user = user
self.source = None
def core_extensions(self):
"""List of core extensions found in the mdx folder"""
return [
"wiki.core.markdown.mdx.codehilite",
"wiki.core.markdown.mdx.previewlinks",
"wiki.core.markdown.mdx.responsivetable",
]
def get_markdown_extensions(self):
extensions = list(settings.MARKDOWN_KWARGS.get("extensions", []))
extensions += self.core_extensions()
extensions += plugin_registry.get_markdown_extensions()
return extensions
def convert(self, text, *args, **kwargs):
# store source in instance, for extensions which might need it
self.source = text
html = super().convert(text, *args, **kwargs)
if settings.MARKDOWN_SANITIZE_HTML:
tags = settings.MARKDOWN_HTML_WHITELIST.union(
plugin_registry.get_html_whitelist()
)
css_sanitizer = CSSSanitizer(
allowed_css_properties=settings.MARKDOWN_HTML_STYLES
)
attrs = {}
attrs.update(settings.MARKDOWN_HTML_ATTRIBUTES)
attrs.update(plugin_registry.get_html_attributes().items())
html = bleach.clean(
html,
tags=tags,
attributes=attrs,
css_sanitizer=css_sanitizer,
strip=True,
)
return html
def article_markdown(text, article, *args, **kwargs):
md = ArticleMarkdown(article, *args, **kwargs)
return md.convert(text)
def add_to_registry(processor, key, value, location):
"""Utility function to register a key by location to Markdown's registry.
Parameters:
* `processor`: Markdown Registry instance
* `key`: A string used to reference the item.
* `value`: The item being registered.
* `location`: Where to register the new key
location can be one of the strings below:
* _begin (registers the key as the highest priority)
* _end (registers the key as the lowest priority)
* a string that starts with `<` or `>` (sets priority halfway between existing priorities)
Returns: None
Raises: ValueError if location is an invalid string.
"""
if len(processor) == 0:
# This is the first item. Set priority to 50.
priority = 50
elif location == "_begin":
processor._sort()
# Set priority 5 greater than highest existing priority
priority = processor._priority[0].priority + 5
elif location == "_end":
processor._sort()
# Set priority 5 less than lowest existing priority
priority = processor._priority[-1].priority - 5
elif location.startswith("<") or location.startswith(">"):
# Set priority halfway between existing priorities.
i = processor.get_index_for_name(location[1:])
if location.startswith("<"):
after = processor._priority[i].priority
if i > 0:
before = processor._priority[i - 1].priority
else:
# Location is first item`
before = after + 10
else:
# location.startswith('>')
before = processor._priority[i].priority
if i < len(processor) - 1:
after = processor._priority[i + 1].priority
else:
# location is last item
after = before - 10
priority = before - ((before - after) / 2)
else:
raise ValueError(
'Not a valid location: "%s". Location key '
'must start with a ">" or "<".' % location
)
processor.register(value, key, priority)

View File

View File

@@ -0,0 +1,159 @@
from textwrap import dedent
import logging
import re
from markdown.extensions.codehilite import CodeHilite
from markdown.extensions.codehilite import CodeHiliteExtension
from markdown.preprocessors import Preprocessor
from markdown.treeprocessors import Treeprocessor
from wiki.core.markdown import add_to_registry
logger = logging.getLogger(__name__)
def highlight(code, config, tab_length, lang=None):
code = CodeHilite(
code,
linenums=config["linenums"],
guess_lang=config["guess_lang"],
css_class=config["css_class"],
style=config["pygments_style"],
noclasses=config["noclasses"],
tab_length=tab_length,
use_pygments=config["use_pygments"],
lang=lang,
)
html = code.hilite()
html = f"""<div class="codehilite-wrap">{html}</div>"""
return html
class WikiFencedBlockPreprocessor(Preprocessor):
"""
This is a replacement of markdown.extensions.fenced_code which will
directly and without configuration options invoke the vanilla CodeHilite
extension.
"""
FENCED_BLOCK_RE = re.compile(
dedent(
r"""
(?P<fence>^(?:~{3,}|`{3,}))[ ]* # opening fence
((\{(?P<attrs>[^\}\n]*)\})| # (optional {attrs} or
(\.?(?P<lang>[\w#.+-]*)[ ]*)? # optional (.)lang
(hl_lines=(?P<quot>"|')(?P<hl_lines>.*?)(?P=quot)[ ]*)?) # optional hl_lines)
\n # newline (end of opening fence)
(?P<code>.*?)(?<=\n) # the code block
(?P=fence)[ ]*$ # closing fence
"""
),
re.MULTILINE | re.DOTALL | re.VERBOSE,
)
CODE_WRAP = "<pre>%s</pre>"
def __init__(self, md):
super().__init__(md)
self.checked_for_codehilite = False
self.codehilite_conf = {}
def run(self, lines):
"""Match and store Fenced Code Blocks in the HtmlStash."""
text = "\n".join(lines)
while 1:
m = self.FENCED_BLOCK_RE.search(text)
if m:
lang = ""
if m.group("lang"):
lang = m.group("lang")
html = highlight(
m.group("code"), self.config, self.md.tab_length, lang=lang
)
placeholder = self.md.htmlStash.store(html)
text = "{}\n{}\n{}".format(
text[: m.start()],
placeholder,
text[m.end() :],
)
else:
break
return text.split("\n")
class HiliteTreeprocessor(Treeprocessor):
"""Hilight source code in code blocks."""
def code_unescape(self, text):
"""Unescape &, <, > and " characters."""
text = text.replace("&amp;", "&")
text = text.replace("&lt;", "<")
text = text.replace("&gt;", ">")
text = text.replace("&quot;", '"')
return text
def run(self, root):
"""Find code blocks and store in htmlStash."""
blocks = root.iter("pre")
for block in blocks:
if len(block) == 1 and block[0].tag == "code":
html = highlight(
self.code_unescape(block[0].text),
self.config,
self.md.tab_length,
)
placeholder = self.md.htmlStash.store(html)
# Clear codeblock in etree instance
block.clear()
# Change to p element which will later
# be removed when inserting raw html
block.tag = "p"
block.text = placeholder
class WikiCodeHiliteExtension(CodeHiliteExtension):
"""
markdown.extensions.codehilite cannot configure container tags but forces
code to be in <table></table>, so we had to overwrite some of the code
because it's hard to extend...
"""
def extendMarkdown(self, md):
"""Add HilitePostprocessor to Markdown instance."""
hiliter = HiliteTreeprocessor(md)
hiliter.config = self.getConfigs()
if "hilite" in md.treeprocessors:
logger.warning(
"Replacing existing 'hilite' extension - please remove "
"'codehilite' from WIKI_MARKDOWN_KWARGS"
)
# del md.treeprocessors["hilite"]
md.treeprocessors.deregister("hilite")
add_to_registry(md.treeprocessors, "hilite", hiliter, "<inline")
if "fenced_code_block" in md.preprocessors:
logger.warning(
"Replacing existing 'fenced_code_block' extension - please remove "
"'fenced_code_block' or 'extras' from WIKI_MARKDOWN_KWARGS"
)
# del md.preprocessors["fenced_code_block"]
md.preprocessors.deregister("fenced_code_block")
hiliter = WikiFencedBlockPreprocessor(md)
hiliter.config = self.getConfigs()
add_to_registry(
md.preprocessors,
"fenced_code_block",
hiliter,
">normalize_whitespace",
)
md.registerExtension(self)
def makeExtension(*args, **kwargs):
"""Return an instance of the extension."""
return WikiCodeHiliteExtension(*args, **kwargs)

View File

@@ -0,0 +1,28 @@
import markdown
from markdown.treeprocessors import Treeprocessor
from wiki.core.markdown import add_to_registry
class PreviewLinksExtension(markdown.Extension):
"""Markdown Extension that sets all anchor targets to _blank when in preview mode"""
def extendMarkdown(self, md):
add_to_registry(
md.treeprocessors, "previewlinks", PreviewLinksTree(md), "_end"
)
class PreviewLinksTree(Treeprocessor):
def run(self, root):
if self.md.preview:
for a in root.findall(".//a"):
# Do not set target for links like href='#markdown'
if not a.get("href").startswith("#"):
a.set("target", "_blank")
return root
def makeExtension(*args, **kwargs):
"""Return an instance of the extension."""
return PreviewLinksExtension(*args, **kwargs)

View File

@@ -0,0 +1,57 @@
from xml.etree import ElementTree as etree
import markdown
from markdown.treeprocessors import Treeprocessor
from wiki.core.markdown import add_to_registry
class ResponsiveTableExtension(markdown.Extension):
"""Wraps all tables with Bootstrap's table-responsive class"""
def extendMarkdown(self, md):
add_to_registry(
md.treeprocessors,
"responsivetable",
ResponsiveTableTree(md),
"_end",
)
class ResponsiveTableTree(Treeprocessor):
"""
NOTE: If you allow inputting of raw <table> tags rather than Markdown code
for tables, this tree processor will not affect the table, as it gets
stashed and not managed by a Treeprocessor type extension.
"""
def run(self, root):
for table_wrapper in list(root.iter("table")):
table_new = self.create_table_element()
self.convert_to_wrapper(table_wrapper)
self.move_children(table_wrapper, table_new)
table_wrapper.append(table_new)
return root
def create_table_element(self):
"""Create table element with text and tail"""
element = etree.Element("table")
element.text = "\n"
element.tail = "\n"
return element
def move_children(self, element1, element2):
"""Moves children from element1 to element2"""
for child in list(element1):
element2.append(child)
# reversed is needed to safely remove items while iterating
for child in reversed(list(element1)):
element1.remove(child)
def convert_to_wrapper(self, element):
element.tag = "div"
element.set("class", "table-responsive")
def makeExtension(*args, **kwargs):
"""Return an instance of the extension."""
return ResponsiveTableExtension(*args, **kwargs)

View File

@@ -0,0 +1,34 @@
from django.core.paginator import Paginator
class WikiPaginator(Paginator):
def __init__(self, *args, **kwargs):
"""
:param side_pages: How many pages should be shown before and after the current page
"""
self.side_pages = kwargs.pop("side_pages", 4)
super().__init__(*args, **kwargs)
def page(self, number):
# Save last accessed page number for context-based lookup in page_range
self.last_accessed_page_number = number
return super().page(number)
@property
def page_range(self):
left = max(self.last_accessed_page_number - self.side_pages, 2)
right = min(
self.last_accessed_page_number + self.side_pages + 1,
self.num_pages,
)
pages = []
if self.num_pages > 0:
pages = [1]
if left > 2:
pages += [0]
pages += range(left, right)
if right < self.num_pages:
pages += [0]
if self.num_pages > 1:
pages += [self.num_pages]
return pages

View File

@@ -0,0 +1,105 @@
from wiki.conf import settings
###############################
# ARTICLE PERMISSION HANDLING #
###############################
#
# All functions are:
# can_something(article, user)
# => True/False
#
# All functions can be replaced by pointing their relevant
# settings variable in wiki.conf.settings to a callable(article, user)
def can_read(article, user):
if callable(settings.CAN_READ):
return settings.CAN_READ(article, user)
else:
# Deny reading access to deleted articles if user has no delete access
article_is_deleted = (
article.current_revision and article.current_revision.deleted
)
if article_is_deleted and not article.can_delete(user):
return False
# Check access for other users...
if user.is_anonymous and not settings.ANONYMOUS:
return False
elif article.other_read:
return True
elif user.is_anonymous:
return False
if user == article.owner:
return True
if article.group_read:
if (
article.group
and user.groups.filter(id=article.group.id).exists()
):
return True
if article.can_moderate(user):
return True
return False
def can_write(article, user):
if callable(settings.CAN_WRITE):
return settings.CAN_WRITE(article, user)
# Check access for other users...
if user.is_anonymous and not settings.ANONYMOUS_WRITE:
return False
elif article.other_write:
return True
elif user.is_anonymous:
return False
if user == article.owner:
return True
if article.group_write:
if (
article.group
and user
and user.groups.filter(id=article.group.id).exists()
):
return True
if article.can_moderate(user):
return True
return False
def can_assign(article, user):
if callable(settings.CAN_ASSIGN):
return settings.CAN_ASSIGN(article, user)
return not user.is_anonymous and user.has_perm("wiki.assign")
def can_assign_owner(article, user):
if callable(settings.CAN_ASSIGN_OWNER):
return settings.CAN_ASSIGN_OWNER(article, user)
return False
def can_change_permissions(article, user):
if callable(settings.CAN_CHANGE_PERMISSIONS):
return settings.CAN_CHANGE_PERMISSIONS(article, user)
return not user.is_anonymous and (
article.owner == user or user.has_perm("wiki.assign")
)
def can_delete(article, user):
if callable(settings.CAN_DELETE):
return settings.CAN_DELETE(article, user)
return not user.is_anonymous and article.can_write(user)
def can_moderate(article, user):
if callable(settings.CAN_MODERATE):
return settings.CAN_MODERATE(article, user)
return not user.is_anonymous and user.has_perm("wiki.moderate")
def can_admin(article, user):
if callable(settings.CAN_ADMIN):
return settings.CAN_ADMIN(article, user)
return not user.is_anonymous and user.has_perm("wiki.admin")

View File

View File

@@ -0,0 +1,70 @@
from django import forms
from django.utils.translation import gettext as _
"""Base classes for different plugin objects.
* BasePlugin: Create a wiki_plugin.py with a class that inherits from BasePlugin.
* PluginSidebarFormMixin: Mix in this class in the form that should be rendered in the editor sidebar
* PluginSettingsFormMixin: ..and this one for a form in the settings tab.
Please have a look in wiki.models.pluginbase to see where to inherit your
plugin's models.
"""
class BasePlugin:
"""Plugins should inherit from this"""
# Must fill in!
slug = None
# Optional
settings_form = None # A form class to add to the settings tab
urlpatterns = {
# General urlpatterns that will reside in /wiki/plugins/plugin-slug/...
"root": [],
# urlpatterns that receive article_id or urlpath, i.e.
# /wiki/ArticleName/plugin/plugin-slug/...
"article": [],
}
article_tab = None # (_('Attachments'), "fa fa-file")
article_view = None # A view for article_id/plugin/slug/
# A list of notification handlers to be subscribed if the notification
# system is active
notifications = []
# Example
# [{'model': models.AttachmentRevision,
# 'message': lambda obj: _("A file was changed: %s") % obj.get_filename(),
# 'key': ARTICLE_EDIT,
# 'created': True,
# 'get_article': lambda obj: obj.attachment.article}
# ]
markdown_extensions = []
class RenderMedia:
js = []
css = {}
class PluginSidebarFormMixin(forms.ModelForm):
unsaved_article_title = forms.CharField(
widget=forms.HiddenInput(), required=True
)
unsaved_article_content = forms.CharField(
widget=forms.HiddenInput(), required=False
)
def get_usermessage(self):
pass
class PluginSettingsFormMixin:
settings_form_headline = _("Settings for plugin")
settings_order = 1
settings_write_access = False
def get_usermessage(self):
pass

View File

@@ -0,0 +1,5 @@
from django.utils.module_loading import autodiscover_modules
def load_wiki_plugins():
autodiscover_modules("wiki_plugin")

View File

@@ -0,0 +1,77 @@
from importlib import import_module
_cache = {}
_settings_forms = []
_markdown_extensions = []
_article_tabs = []
_sidebar = []
_html_whitelist = []
_html_attributes = {}
def register(PluginClass):
"""
Register a plugin class. This function will call back your plugin's
constructor.
"""
if PluginClass in _cache:
raise Exception("Plugin class already registered")
plugin = PluginClass()
_cache[PluginClass] = plugin
settings_form = getattr(PluginClass, "settings_form", None)
if settings_form:
if isinstance(settings_form, str):
klassname = settings_form.split(".")[-1]
modulename = ".".join(settings_form.split(".")[:-1])
form_module = import_module(modulename)
settings_form = getattr(form_module, klassname)
_settings_forms.append(settings_form)
if getattr(PluginClass, "article_tab", None):
_article_tabs.append(plugin)
if getattr(PluginClass, "sidebar", None):
_sidebar.append(plugin)
_markdown_extensions.extend(
getattr(PluginClass, "markdown_extensions", [])
)
_html_whitelist.extend(getattr(PluginClass, "html_whitelist", []))
_html_attributes.update(getattr(PluginClass, "html_attributes", {}))
def get_plugins():
"""Get loaded plugins - do not call before all plugins are loaded."""
return _cache
def get_markdown_extensions():
"""Get all markdown extension classes from plugins"""
return _markdown_extensions
def get_article_tabs():
"""Get all article tab dictionaries from plugins"""
return _article_tabs
def get_sidebar():
"""Returns plugin classes that should connect to the sidebar"""
return _sidebar
def get_settings_forms():
return _settings_forms
def get_html_whitelist():
"""Returns additional html tags that should be whitelisted"""
return _html_whitelist
def get_html_attributes():
"""Returns additional html attributes that should be whitelisted"""
return _html_attributes

14
src/wiki/core/utils.py Normal file
View File

@@ -0,0 +1,14 @@
from django.http.response import JsonResponse
def object_to_json_response(obj, status=200):
"""
Given an object, returns an HttpResponse object with a JSON serialized
version of that object
"""
return JsonResponse(
data=obj,
status=status,
safe=False,
json_dumps_params={"ensure_ascii": False},
)

204
src/wiki/decorators.py Normal file
View File

@@ -0,0 +1,204 @@
from functools import wraps
from urllib.parse import quote as urlquote
from django.http import Http404
from django.http import HttpResponseForbidden
from django.http import HttpResponseNotFound
from django.http import HttpResponseRedirect
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse
from wiki.conf import settings
from wiki.core.exceptions import NoRootURL
from . import models
def response_forbidden(request, article, urlpath, read_denied=False):
if request.user.is_anonymous:
qs = request.META.get("QUERY_STRING", "")
if qs:
qs = urlquote("?" + qs)
else:
qs = ""
return redirect(settings.LOGIN_URL + "?next=" + request.path + qs)
else:
return HttpResponseForbidden(
render_to_string(
"wiki/permission_denied.html",
context={
"article": article,
"urlpath": urlpath,
"read_denied": read_denied,
},
request=request,
)
)
def which_article(path=None, article_id=None, **kwargs):
# fetch by path
if path is not None:
urlpath = models.URLPath.get_by_path(path, select_related=True)
if urlpath.article:
# urlpath is already smart about prefetching items on article
# (like current_revision), so we don't have to
article = urlpath.article
else:
# Be robust: Somehow article is gone but urlpath exists...
# clean up
urlpath.delete()
raise models.URLPath.DoesNotExist()
# fetch by article.id
elif article_id is not None:
# TODO We should try to grab the article from URLPath so the
# caching is good, and fall back to grabbing it from
# Article.objects if not
article = models.Article.objects.get(id=article_id)
try:
urlpath = models.URLPath.objects.get(articles__article=article)
except (
models.URLPath.DoesNotExist,
models.URLPath.MultipleObjectsReturned,
):
urlpath = None
else:
raise TypeError("You should specify either article_id or path")
return article, urlpath
# TODO: This decorator is too complex (C901)
def get_article( # noqa: max-complexity 19
func=None,
can_read=True,
can_write=False,
deleted_contents=False,
not_locked=False,
can_delete=False,
can_moderate=False,
can_create=False,
):
"""View decorator for processing standard url keyword args: Intercepts the
keyword args path or article_id and looks up an article, calling the decorated
func with this ID.
Will accept a ``func(request, article, *args, **kwargs)``
NB! This function will redirect if an article does not exist, permissions
are missing or the article is deleted.
Arguments:
can_read=True and/or can_write=True: Check that the current request.user
has correct permissions.
can_delete and can_moderate: Verifies with wiki.core.permissions
can_create: Same as can_write but adds an extra global setting for anonymous access (ANONYMOUS_CREATE)
deleted_contents=True: Do not redirect if the article has been deleted.
not_locked=True: Return permission denied if the article is locked
Also see: wiki.views.mixins.ArticleMixin
"""
def wrapper(request, *args, **kwargs):
path = kwargs.pop("path", None)
article_id = kwargs.pop("article_id", None)
try:
article, urlpath = which_article(path, article_id)
except NoRootURL:
return redirect("wiki:root_create")
except models.Article.DoesNotExist:
raise Http404(f"Article id {article_id} not found")
except models.URLPath.DoesNotExist:
try:
pathlist = list(
filter(
lambda x: x != "",
path.split("/"),
)
)
path = "/".join(pathlist[:-1])
parent = models.URLPath.get_by_path(path)
return HttpResponseRedirect(
reverse("wiki:create", kwargs={"path": parent.path})
+ "?slug=%s" % pathlist[-1].lower()
)
except models.URLPath.DoesNotExist:
return HttpResponseNotFound(
render_to_string(
"wiki/error.html",
context={"error_type": "ancestors_missing"},
request=request,
)
)
if not deleted_contents:
# If the article has been deleted, show a special page.
if urlpath:
if urlpath.is_deleted(): # This also checks all ancestors
return redirect("wiki:deleted", path=urlpath.path)
else:
if (
article.current_revision
and article.current_revision.deleted
):
return redirect("wiki:deleted", article_id=article.id)
if article.current_revision.locked and not_locked:
return response_forbidden(request, article, urlpath)
if can_read and not article.can_read(request.user):
return response_forbidden(
request, article, urlpath, read_denied=True
)
if (can_write or can_create) and not article.can_write(request.user):
return response_forbidden(request, article, urlpath)
if can_create and not (
request.user.is_authenticated or settings.ANONYMOUS_CREATE
):
return response_forbidden(request, article, urlpath)
if can_delete and not article.can_delete(request.user):
return response_forbidden(request, article, urlpath)
if can_moderate and not article.can_moderate(request.user):
return response_forbidden(request, article, urlpath)
kwargs["urlpath"] = urlpath
return func(request, article, *args, **kwargs)
if func:
return wrapper
else:
return lambda func: get_article(
func,
can_read=can_read,
can_write=can_write,
deleted_contents=deleted_contents,
not_locked=not_locked,
can_delete=can_delete,
can_moderate=can_moderate,
can_create=can_create,
)
def disable_signal_for_loaddata(signal_handler):
"""
Decorator that turns off signal handlers when loading fixture data.
"""
@wraps(signal_handler)
def wrapper(*args, **kwargs):
if kwargs.get("raw", False):
return
return signal_handler(*args, **kwargs)
return wrapper

View File

@@ -0,0 +1,19 @@
from django.urls import get_callable
from wiki.conf import settings
_EditorClass = None
_editor = None
def getEditorClass():
global _EditorClass
if not _EditorClass:
_EditorClass = get_callable(settings.EDITOR)
return _EditorClass
def getEditor():
global _editor
if not _editor:
_editor = getEditorClass()()
return _editor

26
src/wiki/editors/base.py Normal file
View File

@@ -0,0 +1,26 @@
from django import forms
class BaseEditor:
"""Editors should inherit from this. See wiki.editors for examples."""
# The editor id can be used for conditional testing. If you write your
# own editor class, you can use the same editor_id as some editor
editor_id = "plaintext"
media_admin = ()
media_frontend = ()
def get_admin_widget(self, revision=None):
return forms.Textarea()
def get_widget(self, revision=None):
return forms.Textarea()
class AdminMedia:
css = {}
js = ()
class Media:
css = {}
js = ()

View File

@@ -0,0 +1,59 @@
from django import forms
from wiki.editors.base import BaseEditor
class MarkItUpWidget(forms.Widget):
template_name = "wiki/forms/markitup.html"
def __init__(self, attrs=None):
# The 'rows' and 'cols' attributes are required for HTML correctness.
default_attrs = {
"class": "markItUp",
"rows": "10",
"cols": "40",
}
if attrs:
default_attrs.update(attrs)
super().__init__(default_attrs)
class MarkItUpAdminWidget(MarkItUpWidget):
"""A simplified more fail-safe widget for the backend"""
template_name = "wiki/forms/markitup-admin.html"
class MarkItUp(BaseEditor):
editor_id = "markitup"
def get_admin_widget(self, revision=None):
return MarkItUpAdminWidget()
def get_widget(self, revision=None):
return MarkItUpWidget()
class AdminMedia:
css = {
"all": (
"wiki/markitup/skins/simple/style.css",
"wiki/markitup/sets/admin/style.css",
)
}
js = (
"wiki/markitup/admin.init.js",
"wiki/markitup/jquery.markitup.js",
"wiki/markitup/sets/admin/set.js",
)
class Media:
css = {
"all": (
"wiki/markitup/skins/simple/style.css",
"wiki/markitup/sets/frontend/style.css",
)
}
js = (
"wiki/markitup/frontend.init.js",
"wiki/markitup/jquery.markitup.js",
"wiki/markitup/sets/frontend/set.js",
)

650
src/wiki/forms.py Normal file
View File

@@ -0,0 +1,650 @@
__all__ = [
"UserCreationForm",
"UserUpdateForm",
"WikiSlugField",
"SpamProtectionMixin",
"CreateRootForm",
"MoveForm",
"EditForm",
"SelectWidgetBootstrap",
"TextInputPrepend",
"CreateForm",
"DeleteForm",
"PermissionsForm",
"DirFilterForm",
"SearchForm",
]
from datetime import timedelta
from django import forms
from django.apps import apps
from django.contrib.auth import get_user_model
from django.core import validators
from django.core.validators import RegexValidator
from django.forms.widgets import HiddenInput
from django.shortcuts import get_object_or_404
from django.urls import Resolver404, resolve
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from wiki import models
from wiki.conf import settings
from wiki.core import permissions
from wiki.core.diff import simple_merge
from wiki.core.plugins.base import PluginSettingsFormMixin
from wiki.editors import getEditor
from .forms_account_handling import UserCreationForm, UserUpdateForm
validate_slug_numbers = RegexValidator(
r"^[0-9]+$",
_("A 'slug' cannot consist solely of numbers."),
"invalid",
inverse_match=True,
)
class WikiSlugField(forms.CharField):
"""
In future versions of Django, we might be able to define this field as
the default field directly on the model. For now, it's used in CreateForm.
"""
default_validators = [validators.validate_slug, validate_slug_numbers]
def __init__(self, *args, **kwargs):
self.allow_unicode = kwargs.pop("allow_unicode", False)
if self.allow_unicode:
self.default_validators = [
validators.validate_unicode_slug,
validate_slug_numbers,
]
super().__init__(*args, **kwargs)
def _clean_slug(slug, urlpath):
if slug.startswith("_"):
raise forms.ValidationError(
gettext("A slug may not begin with an underscore.")
)
if slug == "admin":
raise forms.ValidationError(
gettext("'admin' is not a permitted slug name.")
)
if settings.URL_CASE_SENSITIVE:
already_existing_slug = models.URLPath.objects.filter(
slug=slug, parent=urlpath
)
else:
slug = slug.lower()
already_existing_slug = models.URLPath.objects.filter(
slug__iexact=slug, parent=urlpath
)
if already_existing_slug:
already_urlpath = already_existing_slug[0]
if (
already_urlpath.article
and already_urlpath.article.current_revision.deleted
):
raise forms.ValidationError(
gettext('A deleted article with slug "%s" already exists.')
% already_urlpath.slug
)
else:
raise forms.ValidationError(
gettext('A slug named "%s" already exists.')
% already_urlpath.slug
)
if settings.CHECK_SLUG_URL_AVAILABLE:
try:
# Fail validation if URL resolves to non-wiki app
match = resolve(urlpath.path + "/" + slug + "/")
if match.app_name != "wiki":
raise forms.ValidationError(
gettext("This slug conflicts with an existing URL.")
)
except Resolver404:
pass
return slug
User = get_user_model()
Group = apps.get_model(settings.GROUP_MODEL)
class SpamProtectionMixin:
"""Check a form for spam. Only works if properties 'request' and 'revision_model' are set."""
revision_model = models.ArticleRevision
# TODO: This method is too complex (C901)
def check_spam(self): # noqa
"""Check that user or IP address does not perform content edits that
are not allowed.
current_revision can be any object inheriting from models.BaseRevisionMixin
"""
request = self.request
user = None
ip_address = None
if request.user.is_authenticated:
user = request.user
else:
ip_address = request.META.get(
"HTTP_X_REAL_IP", None
) or request.META.get("REMOTE_ADDR", None)
if not (user or ip_address):
raise forms.ValidationError(
gettext(
"Spam protection failed to find both a logged in user and an IP address."
)
)
def check_interval(from_time, max_count, interval_name):
from_time = timezone.now() - timedelta(
minutes=settings.REVISIONS_MINUTES_LOOKBACK
)
revisions = self.revision_model.objects.filter(
created__gte=from_time,
)
if user:
revisions = revisions.filter(user=user)
if ip_address:
revisions = revisions.filter(ip_address=ip_address)
revisions = revisions.count()
if revisions >= max_count:
raise forms.ValidationError(
gettext(
"Spam protection: You are only allowed to create or edit %(revisions)d article(s) per %(interval_name)s."
)
% {"revisions": max_count, "interval_name": interval_name}
)
if not settings.LOG_IPS_ANONYMOUS:
return
if request.user.has_perm("wiki.moderator"):
return
from_time = timezone.now() - timedelta(
minutes=settings.REVISIONS_MINUTES_LOOKBACK
)
if request.user.is_authenticated:
per_minute = settings.REVISIONS_PER_MINUTES
else:
per_minute = settings.REVISIONS_PER_MINUTES_ANONYMOUS
check_interval(
from_time,
per_minute,
_("minute")
if settings.REVISIONS_MINUTES_LOOKBACK == 1
else (_("%d minutes") % settings.REVISIONS_MINUTES_LOOKBACK),
)
from_time = timezone.now() - timedelta(minutes=60)
if request.user.is_authenticated:
per_hour = settings.REVISIONS_PER_MINUTES
else:
per_hour = settings.REVISIONS_PER_MINUTES_ANONYMOUS
check_interval(from_time, per_hour, _("hour"))
class CreateRootForm(forms.Form):
title = forms.CharField(
label=_("Title"),
help_text=_(
"Initial title of the article. May be overridden with revision titles."
),
)
content = forms.CharField(
label=_("Type in some contents"),
help_text=_(
"This is just the initial contents of your article. After creating it, you can use more complex features like adding plugins, meta data, related articles etc..."
),
required=False,
widget=getEditor().get_widget(),
) # @UndefinedVariable
class MoveForm(forms.Form):
destination = forms.CharField(label=_("Destination"))
slug = WikiSlugField(max_length=models.URLPath.SLUG_MAX_LENGTH)
redirect = forms.BooleanField(
label=_("Redirect pages"),
help_text=_("Create a redirect page for every moved article?"),
required=False,
)
def clean(self):
cd = super().clean()
if cd.get("slug"):
dest_path = get_object_or_404(
models.URLPath, pk=self.cleaned_data["destination"]
)
cd["slug"] = _clean_slug(cd["slug"], dest_path)
return cd
class EditForm(forms.Form, SpamProtectionMixin):
title = forms.CharField(
label=_("Title"),
)
content = forms.CharField(
label=_("Contents"), required=False
) # @UndefinedVariable
summary = forms.CharField(
label=pgettext_lazy("Revision comment", "Summary"),
help_text=_(
"Give a short reason for your edit, which will be stated in the revision log."
),
required=False,
)
current_revision = forms.IntegerField(
required=False, widget=forms.HiddenInput()
)
def __init__(self, request, current_revision, *args, **kwargs):
self.request = request
self.no_clean = kwargs.pop("no_clean", False)
self.preview = kwargs.pop("preview", False)
self.initial_revision = current_revision
self.presumed_revision = None
if current_revision:
# For e.g. editing a section of the text: The content provided by the caller is used.
# Otherwise use the content of the revision.
provided_content = True
content = kwargs.pop("content", None)
if content is None:
provided_content = False
content = current_revision.content
initial = {
"content": content,
"title": current_revision.title,
"current_revision": current_revision.id,
}
initial.update(kwargs.get("initial", {}))
# Manipulate any data put in args[0] such that the current_revision
# is reset to match the actual current revision.
data = None
if len(args) > 0:
data = args[0]
args = args[1:]
if data is None:
data = kwargs.get("data", None)
if data:
self.presumed_revision = data.get("current_revision", None)
if not str(self.presumed_revision) == str(
self.initial_revision.id
):
newdata = {}
for k, v in data.items():
newdata[k] = v
newdata["current_revision"] = self.initial_revision.id
# Don't merge if content comes from the caller
if provided_content:
self.presumed_revision = self.initial_revision.id
else:
newdata["content"] = simple_merge(
content, data.get("content", "")
)
newdata["title"] = current_revision.title
kwargs["data"] = newdata
else:
# Always pass as kwarg
kwargs["data"] = data
kwargs["initial"] = initial
super().__init__(*args, **kwargs)
self.fields["content"].widget = getEditor().get_widget(
current_revision
)
def clean_title(self):
title = self.cleaned_data.get("title", None)
title = (title or "").strip()
if not title:
raise forms.ValidationError(
gettext("Article is missing title or has an invalid title")
)
return title
def clean(self):
"""Validates form data by checking for the following
No new revisions have been created since user attempted to edit
Revision title or content has changed
"""
if self.no_clean or self.preview:
return self.cleaned_data
if not str(self.initial_revision.id) == str(self.presumed_revision):
raise forms.ValidationError(
gettext(
"While you were editing, someone else changed the revision. Your contents have been automatically merged with the new contents. Please review the text below."
)
)
if (
"title" in self.cleaned_data
and self.cleaned_data["title"] == self.initial_revision.title
and self.cleaned_data["content"] == self.initial_revision.content
):
raise forms.ValidationError(
gettext("No changes made. Nothing to save.")
)
self.check_spam()
return self.cleaned_data
class SelectWidgetBootstrap(forms.Select):
"""
Formerly, we used Bootstrap 3's dropdowns. They look nice. But to
reduce bugs and reliance on JavaScript, it's now been replaced by
a conventional system platform drop-down.
https://getbootstrap.com/docs/4.4/components/dropdowns/
"""
def __init__(self, attrs=None, choices=()):
if attrs is None:
attrs = {"class": ""}
elif "class" not in attrs:
attrs["class"] = ""
attrs["class"] += " form-control"
super().__init__(attrs, choices)
class TextInputPrepend(forms.TextInput):
template_name = "wiki/forms/text.html"
def __init__(self, *args, **kwargs):
self.prepend = kwargs.pop("prepend", "")
super().__init__(*args, **kwargs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["prepend"] = mark_safe(self.prepend)
return context
class CreateForm(forms.Form, SpamProtectionMixin):
def __init__(self, request, urlpath_parent, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
self.urlpath_parent = urlpath_parent
title = forms.CharField(
label=_("Title"),
)
slug = WikiSlugField(
label=_("Slug"),
help_text=_(
"This will be the address where your article can be found. Use only alphanumeric characters and - or _.<br>Note: If you change the slug later on, links pointing to this article are <b>not</b> updated."
),
max_length=models.URLPath.SLUG_MAX_LENGTH,
)
content = forms.CharField(
label=_("Contents"), required=False, widget=getEditor().get_widget()
) # @UndefinedVariable
summary = forms.CharField(
label=pgettext_lazy("Revision comment", "Summary"),
help_text=_("Write a brief message for the article's history log."),
required=False,
)
def clean_slug(self):
return _clean_slug(self.cleaned_data["slug"], self.urlpath_parent)
def clean(self):
self.check_spam()
return self.cleaned_data
class DeleteForm(forms.Form):
def __init__(self, *args, **kwargs):
self.article = kwargs.pop("article")
self.has_children = kwargs.pop("has_children")
super().__init__(*args, **kwargs)
confirm = forms.BooleanField(required=False, label=_("Yes, I am sure"))
purge = forms.BooleanField(
widget=HiddenInput(),
required=False,
label=_("Purge"),
help_text=_(
"Purge the article: Completely remove it (and all its contents) with no undo. Purging is a good idea if you want to free the slug such that users can create new articles in its place."
),
)
revision = forms.ModelChoiceField(
models.ArticleRevision.objects.all(),
widget=HiddenInput(),
required=False,
)
def clean(self):
if not self.cleaned_data["confirm"]:
raise forms.ValidationError(gettext("You are not sure enough!"))
if self.cleaned_data["revision"] != self.article.current_revision:
raise forms.ValidationError(
gettext(
"While you tried to delete this article, it was modified. TAKE CARE!"
)
)
return self.cleaned_data
class PermissionsForm(PluginSettingsFormMixin, forms.ModelForm):
locked = forms.BooleanField(
label=_("Lock article"),
help_text=_("Deny all users access to edit this article."),
required=False,
)
settings_form_headline = _("Permissions")
settings_order = 5
settings_write_access = False
owner_username = forms.CharField(
required=False,
label=_("Owner"),
help_text=_("Enter the username of the owner."),
)
group = forms.ModelChoiceField(
Group.objects.all(),
empty_label=_("(none)"),
label=_("Group"),
required=False,
widget=forms.Select(attrs={"class": "form-control"}),
)
if settings.USE_BOOTSTRAP_SELECT_WIDGET:
group.widget = SelectWidgetBootstrap()
recursive = forms.BooleanField(
label=_("Inherit permissions"),
help_text=_(
"Check here to apply the above permissions (excluding group and owner of the article) recursively to articles below this one."
),
required=False,
)
recursive_owner = forms.BooleanField(
label=_("Inherit owner"),
help_text=_(
"Check here to apply the ownership setting recursively to articles below this one."
),
required=False,
)
recursive_group = forms.BooleanField(
label=_("Inherit group"),
help_text=_(
"Check here to apply the group setting recursively to articles below this one."
),
required=False,
)
def get_usermessage(self):
if self.changed_data:
return _("Permission settings for the article were updated.")
else:
return _(
"Your permission settings were unchanged, so nothing saved."
)
def __init__(self, article, request, *args, **kwargs):
self.article = article
self.user = request.user
self.request = request
kwargs["instance"] = article
kwargs["initial"] = {"locked": article.current_revision.locked}
super().__init__(*args, **kwargs)
self.can_change_groups = False
self.can_assign = False
if permissions.can_assign(article, request.user):
self.can_assign = True
self.can_change_groups = True
self.fields["group"].queryset = Group.objects.all()
elif permissions.can_assign_owner(article, request.user):
self.fields["group"].queryset = Group.objects.filter(
user=request.user
)
self.can_change_groups = True
else:
# Quick-fix...
# Set the group dropdown to readonly and with the current
# group as only selectable option
self.fields["group"] = forms.ModelChoiceField(
queryset=Group.objects.filter(id=self.instance.group.id)
if self.instance.group
else Group.objects.none(),
empty_label=_("(none)"),
required=False,
widget=SelectWidgetBootstrap(attrs={"disabled": True})
if settings.USE_BOOTSTRAP_SELECT_WIDGET
else forms.Select(attrs={"disabled": True}),
)
self.fields["group_read"].widget = forms.HiddenInput()
self.fields["group_write"].widget = forms.HiddenInput()
if not self.can_assign:
self.fields["owner_username"].widget = forms.TextInput(
attrs={"readonly": "true"}
)
self.fields["recursive"].widget = forms.HiddenInput()
self.fields["recursive_group"].widget = forms.HiddenInput()
self.fields["recursive_owner"].widget = forms.HiddenInput()
self.fields["locked"].widget = forms.HiddenInput()
self.fields["owner_username"].initial = (
getattr(article.owner, User.USERNAME_FIELD)
if article.owner
else ""
)
def clean_owner_username(self):
if self.can_assign:
username = self.cleaned_data["owner_username"]
if username:
try:
kwargs = {User.USERNAME_FIELD: username}
user = User.objects.get(**kwargs)
except User.DoesNotExist:
raise forms.ValidationError(
gettext("No user with that username")
)
else:
user = None
else:
user = self.article.owner
return user
def save(self, commit=True):
article = super().save(commit=False)
# Alter the owner according to the form field owner_username
# TODO: Why not rename this field to 'owner' so this happens
# automatically?
article.owner = self.cleaned_data["owner_username"]
# Revert any changes to group permissions if the
# current user is not allowed (see __init__)
# TODO: Write clean methods for this instead!
if not self.can_change_groups:
article.group = self.article.group
article.group_read = self.article.group_read
article.group_write = self.article.group_write
if self.can_assign:
if self.cleaned_data["recursive"]:
article.set_permissions_recursive()
if self.cleaned_data["recursive_owner"]:
article.set_owner_recursive()
if self.cleaned_data["recursive_group"]:
article.set_group_recursive()
if (
self.cleaned_data["locked"]
and not article.current_revision.locked
):
revision = models.ArticleRevision()
revision.inherit_predecessor(self.article)
revision.set_from_request(self.request)
revision.automatic_log = _("Article locked for editing")
revision.locked = True
self.article.add_revision(revision)
elif (
not self.cleaned_data["locked"]
and article.current_revision.locked
):
revision = models.ArticleRevision()
revision.inherit_predecessor(self.article)
revision.set_from_request(self.request)
revision.automatic_log = _("Article unlocked for editing")
revision.locked = False
self.article.add_revision(revision)
article.save()
class Meta:
model = models.Article
fields = (
"locked",
"owner_username",
"recursive_owner",
"group",
"recursive_group",
"group_read",
"group_write",
"other_read",
"other_write",
"recursive",
)
widgets = {}
class DirFilterForm(forms.Form):
query = forms.CharField(
widget=forms.TextInput(
attrs={"placeholder": _("Filter..."), "class": "search-query"}
),
required=False,
)
class SearchForm(forms.Form):
q = forms.CharField(
widget=forms.TextInput(
attrs={"placeholder": _("Search..."), "class": "search-query"}
),
required=False,
)

View File

@@ -0,0 +1,106 @@
import random
import string
import django.contrib.auth.models
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UserCreationForm
from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields import CharField
from django.db.models.fields import EmailField
from django.utils.translation import gettext_lazy as _
from wiki.conf import settings
def _get_field(model, field):
try:
return model._meta.get_field(field)
except FieldDoesNotExist:
return
User = get_user_model()
def check_user_field(user_model):
return isinstance(
_get_field(user_model, user_model.USERNAME_FIELD), CharField
)
def check_email_field(user_model):
return isinstance(
_get_field(user_model, user_model.get_email_field_name()), EmailField
)
# django parses the ModelForm (and Meta classes) on class creation, which fails with custom models without expected fields.
# We need to check this here, because if this module can't load then system checks can't run.
CustomUser = (
User
if (
settings.ACCOUNT_HANDLING
and check_user_field(User)
and check_email_field(User)
)
else django.contrib.auth.models.User
)
class UserCreationForm(UserCreationForm):
email = forms.EmailField(required=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add honeypots
self.honeypot_fieldnames = "address", "phone"
self.honeypot_class = "".join(
random.choice(string.ascii_uppercase + string.digits)
for __ in range(10)
)
self.honeypot_jsfunction = "f" + "".join(
random.choice(string.ascii_uppercase + string.digits)
for __ in range(10)
)
for fieldname in self.honeypot_fieldnames:
self.fields[fieldname] = forms.CharField(
widget=forms.TextInput(attrs={"class": self.honeypot_class}),
required=False,
)
def clean(self):
super().clean()
for fieldname in self.honeypot_fieldnames:
if self.cleaned_data[fieldname]:
raise forms.ValidationError(
"Thank you, non-human visitor. Please keep trying to fill in the form."
)
return self.cleaned_data
class Meta:
model = CustomUser
fields = (CustomUser.USERNAME_FIELD, CustomUser.get_email_field_name())
class UserUpdateForm(forms.ModelForm):
password1 = forms.CharField(
label="New password", widget=forms.PasswordInput(), required=False
)
password2 = forms.CharField(
label="Confirm password", widget=forms.PasswordInput(), required=False
)
def clean(self):
password1 = self.cleaned_data.get("password1")
password2 = self.cleaned_data.get("password2")
if password1 and password1 != password2:
raise forms.ValidationError(_("Passwords don't match"))
return self.cleaned_data
class Meta:
model = CustomUser
fields = [CustomUser.get_email_field_name()]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
src/wiki/locale/pt Normal file
View File

@@ -0,0 +1 @@
pt_PT

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
src/wiki/locale/zh_Hans Normal file
View File

@@ -0,0 +1 @@
zh_CN

Binary file not shown.

184
src/wiki/managers.py Normal file
View File

@@ -0,0 +1,184 @@
from django.db import models
from django.db.models import Count
from django.db.models import Q
from django.db.models.query import EmptyQuerySet
from django.db.models.query import QuerySet
from mptt.managers import TreeManager
class ArticleQuerySet(QuerySet):
def can_read(self, user):
"""Filter objects so only the ones with a user's reading access
are included"""
if user.has_perm("wiki.moderator"):
return self
if user.is_anonymous:
q = self.filter(other_read=True)
else:
q = self.filter(
Q(other_read=True)
| Q(owner=user)
| (Q(group__user=user) & Q(group_read=True))
).annotate(Count("id"))
return q
def can_write(self, user):
"""Filter objects so only the ones with a user's writing access
are included"""
if user.has_perm("wiki.moderator"):
return self
if user.is_anonymous:
q = self.filter(other_write=True)
else:
q = self.filter(
Q(other_write=True)
| Q(owner=user)
| (Q(group__user=user) & Q(group_write=True))
)
return q
def active(self):
return self.filter(current_revision__deleted=False)
class ArticleEmptyQuerySet(EmptyQuerySet):
def can_read(self, user):
return self
def can_write(self, user):
return self
def active(self):
return self
class ArticleFkQuerySetMixin:
def can_read(self, user):
"""Filter objects so only the ones with a user's reading access
are included"""
if user.has_perm("wiki.moderate"):
return self
if user.is_anonymous:
q = self.filter(article__other_read=True)
else:
# https://github.com/django-wiki/django-wiki/issues/67
q = self.filter(
Q(article__other_read=True)
| Q(article__owner=user)
| (Q(article__group__user=user) & Q(article__group_read=True))
).annotate(Count("id"))
return q
def can_write(self, user):
"""Filter objects so only the ones with a user's writing access
are included"""
if user.has_perm("wiki.moderate"):
return self
if user.is_anonymous:
q = self.filter(article__other_write=True)
else:
# https://github.com/django-wiki/django-wiki/issues/67
q = self.filter(
Q(article__other_write=True)
| Q(article__owner=user)
| (Q(article__group__user=user) & Q(article__group_write=True))
).annotate(Count("id"))
return q
def active(self):
return self.filter(article__current_revision__deleted=False)
class ArticleFkEmptyQuerySetMixin:
def can_read(self, user):
return self
def can_write(self, user):
return self
def active(self):
return self
class ArticleFkQuerySet(ArticleFkQuerySetMixin, QuerySet):
pass
class ArticleFkEmptyQuerySet(ArticleFkEmptyQuerySetMixin, EmptyQuerySet):
pass
class ArticleManager(models.Manager):
def get_empty_query_set(self):
return self.get_queryset().none()
def get_queryset(self):
return ArticleQuerySet(self.model, using=self._db)
def active(self):
return self.get_queryset().active()
def can_read(self, user):
return self.get_queryset().can_read(user)
def can_write(self, user):
return self.get_queryset().can_write(user)
class ArticleFkManager(models.Manager):
def get_empty_query_set(self):
return self.get_queryset().none()
def get_queryset(self):
return ArticleFkQuerySet(self.model, using=self._db)
def active(self):
return self.get_queryset().active()
def can_read(self, user):
return self.get_queryset().can_read(user)
def can_write(self, user):
return self.get_queryset().can_write(user)
class URLPathEmptyQuerySet(EmptyQuerySet, ArticleFkEmptyQuerySetMixin):
def select_related_common(self):
return self
def default_order(self):
return self
class URLPathQuerySet(QuerySet, ArticleFkQuerySetMixin):
def select_related_common(self):
return self.select_related(
"parent", "article__current_revision", "article__owner"
)
def default_order(self):
"""Returns elements by there article order"""
return self.order_by("article__current_revision__title")
class URLPathManager(TreeManager):
def get_empty_query_set(self):
return self.get_queryset().none()
def get_queryset(self):
"""Return a QuerySet with the same ordering as the TreeManager."""
return URLPathQuerySet(self.model, using=self._db).order_by(
self.tree_id_attr, self.left_attr
)
def select_related_common(self):
return self.get_queryset().common_select_related()
def active(self):
return self.get_queryset().active()
def can_read(self, user):
return self.get_queryset().can_read(user)
def can_write(self, user):
return self.get_queryset().can_write(user)

View File

@@ -0,0 +1,455 @@
import django.db.models.deletion
import mptt.fields
from django.conf import settings
from django.db import migrations
from django.db import models
from django.db.models.fields import GenericIPAddressField as IPAddressField
from wiki.conf.settings import GROUP_MODEL
class Migration(migrations.Migration):
dependencies = [
("sites", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0001_initial"),
("auth", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Article",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
auto_created=True,
verbose_name="ID",
),
),
(
"created",
models.DateTimeField(verbose_name="created", auto_now_add=True),
),
(
"modified",
models.DateTimeField(
verbose_name="modified",
auto_now=True,
help_text="Article properties last modified",
),
),
(
"group_read",
models.BooleanField(default=True, verbose_name="group read access"),
),
(
"group_write",
models.BooleanField(
default=True, verbose_name="group write access"
),
),
(
"other_read",
models.BooleanField(
default=True, verbose_name="others read access"
),
),
(
"other_write",
models.BooleanField(
default=True, verbose_name="others write access"
),
),
],
options={
"permissions": (
("moderate", "Can edit all articles and lock/unlock/restore"),
("assign", "Can change ownership of any article"),
("grant", "Can assign permissions to other users"),
),
},
bases=(models.Model,),
),
migrations.CreateModel(
name="ArticleForObject",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
auto_created=True,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField(verbose_name="object ID")),
("is_mptt", models.BooleanField(default=False, editable=False)),
(
"article",
models.ForeignKey(to="wiki.Article", on_delete=models.CASCADE),
),
(
"content_type",
models.ForeignKey(
related_name="content_type_set_for_articleforobject",
verbose_name="content type",
to="contenttypes.ContentType",
on_delete=models.CASCADE,
),
),
],
options={
"verbose_name_plural": "Articles for object",
"verbose_name": "Article for object",
},
bases=(models.Model,),
),
migrations.CreateModel(
name="ArticlePlugin",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
auto_created=True,
verbose_name="ID",
),
),
("deleted", models.BooleanField(default=False)),
("created", models.DateTimeField(auto_now_add=True)),
],
options={},
bases=(models.Model,),
),
migrations.CreateModel(
name="ArticleRevision",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
auto_created=True,
verbose_name="ID",
),
),
(
"revision_number",
models.IntegerField(verbose_name="revision number", editable=False),
),
("user_message", models.TextField(blank=True)),
("automatic_log", models.TextField(blank=True, editable=False)),
(
"ip_address",
IPAddressField(
null=True, verbose_name="IP address", blank=True, editable=False
),
),
("modified", models.DateTimeField(auto_now=True)),
("created", models.DateTimeField(auto_now_add=True)),
("deleted", models.BooleanField(default=False, verbose_name="deleted")),
("locked", models.BooleanField(default=False, verbose_name="locked")),
(
"content",
models.TextField(blank=True, verbose_name="article contents"),
),
(
"title",
models.CharField(
max_length=512,
verbose_name="article title",
help_text="Each revision contains a title field that must be filled out, even if the title has not changed",
),
),
(
"article",
models.ForeignKey(
to="wiki.Article",
verbose_name="article",
on_delete=models.CASCADE,
),
),
(
"previous_revision",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
blank=True,
to="wiki.ArticleRevision",
),
),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
blank=True,
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"get_latest_by": "revision_number",
"ordering": ("created",),
},
bases=(models.Model,),
),
migrations.CreateModel(
name="ReusablePlugin",
fields=[
(
"articleplugin_ptr",
models.OneToOneField(
primary_key=True,
parent_link=True,
to="wiki.ArticlePlugin",
serialize=False,
auto_created=True,
on_delete=models.CASCADE,
),
),
(
"articles",
models.ManyToManyField(
related_name="shared_plugins_set", to="wiki.Article"
),
),
],
options={},
bases=("wiki.articleplugin",),
),
migrations.CreateModel(
name="RevisionPlugin",
fields=[
(
"articleplugin_ptr",
models.OneToOneField(
primary_key=True,
parent_link=True,
to="wiki.ArticlePlugin",
serialize=False,
auto_created=True,
on_delete=models.CASCADE,
),
),
],
options={},
bases=("wiki.articleplugin",),
),
migrations.CreateModel(
name="RevisionPluginRevision",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
auto_created=True,
verbose_name="ID",
),
),
(
"revision_number",
models.IntegerField(verbose_name="revision number", editable=False),
),
("user_message", models.TextField(blank=True)),
("automatic_log", models.TextField(blank=True, editable=False)),
(
"ip_address",
IPAddressField(
null=True, verbose_name="IP address", blank=True, editable=False
),
),
("modified", models.DateTimeField(auto_now=True)),
("created", models.DateTimeField(auto_now_add=True)),
("deleted", models.BooleanField(default=False, verbose_name="deleted")),
("locked", models.BooleanField(default=False, verbose_name="locked")),
(
"plugin",
models.ForeignKey(
related_name="revision_set",
to="wiki.RevisionPlugin",
on_delete=models.CASCADE,
),
),
(
"previous_revision",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
blank=True,
to="wiki.RevisionPluginRevision",
),
),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
blank=True,
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"get_latest_by": "revision_number",
"ordering": ("-created",),
},
bases=(models.Model,),
),
migrations.CreateModel(
name="SimplePlugin",
fields=[
(
"articleplugin_ptr",
models.OneToOneField(
primary_key=True,
parent_link=True,
to="wiki.ArticlePlugin",
serialize=False,
auto_created=True,
on_delete=models.CASCADE,
),
),
(
"article_revision",
models.ForeignKey(
to="wiki.ArticleRevision", on_delete=models.CASCADE
),
),
],
options={},
bases=("wiki.articleplugin",),
),
migrations.CreateModel(
name="URLPath",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
auto_created=True,
verbose_name="ID",
),
),
("slug", models.SlugField(null=True, blank=True, verbose_name="slug")),
("lft", models.PositiveIntegerField(db_index=True, editable=False)),
("rght", models.PositiveIntegerField(db_index=True, editable=False)),
("tree_id", models.PositiveIntegerField(db_index=True, editable=False)),
("level", models.PositiveIntegerField(db_index=True, editable=False)),
(
"article",
models.ForeignKey(
help_text="This field is automatically updated, but you need to populate it when creating a new URL path.",
on_delete=django.db.models.deletion.CASCADE,
to="wiki.Article",
verbose_name="article",
),
),
(
"parent",
mptt.fields.TreeForeignKey(
blank=True,
help_text="Position of URL path in the tree.",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="wiki.URLPath",
),
),
(
"site",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="sites.Site"
),
),
],
options={
"verbose_name_plural": "URL paths",
"verbose_name": "URL path",
},
bases=(models.Model,),
),
migrations.AlterUniqueTogether(
name="urlpath",
unique_together={("site", "parent", "slug")},
),
migrations.AddField(
model_name="revisionplugin",
name="current_revision",
field=models.OneToOneField(
related_name="plugin_set",
null=True,
help_text="The revision being displayed for this plugin. If you need to do a roll-back, simply change the value of this field.",
blank=True,
to="wiki.RevisionPluginRevision",
verbose_name="current revision",
on_delete=models.CASCADE,
),
preserve_default=True,
),
migrations.AlterUniqueTogether(
name="articlerevision",
unique_together={("article", "revision_number")},
),
migrations.AddField(
model_name="articleplugin",
name="article",
field=models.ForeignKey(
to="wiki.Article", verbose_name="article", on_delete=models.CASCADE
),
preserve_default=True,
),
migrations.AlterUniqueTogether(
name="articleforobject",
unique_together={("content_type", "object_id")},
),
migrations.AddField(
model_name="article",
name="current_revision",
field=models.OneToOneField(
related_name="current_set",
null=True,
help_text="The revision being displayed for this article. If you need to do a roll-back, simply change the value of this field.",
blank=True,
to="wiki.ArticleRevision",
verbose_name="current revision",
on_delete=models.CASCADE,
),
preserve_default=True,
),
migrations.AddField(
model_name="article",
name="group",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
help_text="Like in a UNIX file system, permissions can be given to a user according to group membership. Groups are handled through the Django auth system.",
blank=True,
to=GROUP_MODEL,
verbose_name="group",
),
preserve_default=True,
),
migrations.AddField(
model_name="article",
name="owner",
field=models.ForeignKey(
related_name="owned_articles",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
help_text="The owner of the article, usually the creator. The owner always has both read and write access.",
blank=True,
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
preserve_default=True,
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 1.10.7 on 2017-06-06 23:18
import django.db.models.deletion
import mptt.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("wiki", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="urlpath",
name="moved_to",
field=mptt.fields.TreeForeignKey(
blank=True,
help_text="Article path was moved to this location",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moved_from",
to="wiki.URLPath",
verbose_name="Moved to",
),
),
]

View File

@@ -0,0 +1,42 @@
# Upgrades fields changed in django-mptt
# See: https://github.com/django-mptt/django-mptt/pull/578
# Generated by Django 2.2.7 on 2020-02-06 20:36
import django.db.models.deletion
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("wiki", "0002_urlpath_moved_to"),
]
operations = [
migrations.AlterField(
model_name="urlpath",
name="level",
field=models.PositiveIntegerField(editable=False),
),
migrations.AlterField(
model_name="urlpath",
name="lft",
field=models.PositiveIntegerField(editable=False),
),
migrations.AlterField(
model_name="urlpath",
name="rght",
field=models.PositiveIntegerField(editable=False),
),
# Added as a no-op when upgrading to django-mptt 0.13
migrations.AlterField(
model_name="articleforobject",
name="content_type",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="content_type_set_for_%(class)s",
to="contenttypes.contenttype",
verbose_name="content type",
),
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.6 on 2025-09-16 19:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wiki', '0003_mptt_upgrade'),
]
operations = [
migrations.AlterField(
model_name='article',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='articleforobject',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='articleplugin',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='articlerevision',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='revisionpluginrevision',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='urlpath',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

View File

@@ -0,0 +1,49 @@
from django import shortcuts
from django import urls
from django.urls import base
from django.utils.functional import lazy
from .article import * # noqa
from .pluginbase import * # noqa
from .urlpath import * # noqa
original_django_reverse = urls.reverse
def reverse(*args, **kwargs):
"""Now this is a crazy and silly hack, but it is basically here to
enforce that an empty path always takes precedence over an article_id
such that the root article doesn't get resolved to /ID/ but /.
Another crazy hack that this supports is transforming every wiki url
by a function. If _transform_url is set on this function, it will
return the result of calling reverse._transform_url(reversed_url)
for every url in the wiki namespace.
"""
if isinstance(args[0], str) and args[0].startswith("wiki:"):
url_kwargs = kwargs.get("kwargs", {})
path = url_kwargs.get("path", False)
# If a path is supplied then discard the article_id
if path is not False:
url_kwargs.pop("article_id", None)
url_kwargs["path"] = path
kwargs["kwargs"] = url_kwargs
url = original_django_reverse(*args, **kwargs)
if hasattr(reverse, "_transform_url"):
url = reverse._transform_url(url)
else:
url = original_django_reverse(*args, **kwargs)
return url
reverse_lazy = lazy(reverse, str)
# Patch up other locations of the reverse function
base.reverse = reverse
base.reverse_lazy = reverse_lazy
urls.reverse = reverse
urls.reverse_lazy = reverse_lazy
shortcuts.reverse = reverse

510
src/wiki/models/article.py Normal file
View File

@@ -0,0 +1,510 @@
from django.conf import settings as django_settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db import models
from django.db.models.fields import GenericIPAddressField as IPAddressField
from django.db.models.signals import post_save
from django.db.models.signals import pre_delete
from django.db.models.signals import pre_save
from django.urls import reverse
from django.utils import translation
from django.utils.safestring import mark_safe
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from wiki import managers
from wiki.conf import settings
from wiki.core import permissions
from wiki.core.markdown import article_markdown
from wiki.decorators import disable_signal_for_loaddata
__all__ = [
"Article",
"ArticleForObject",
"ArticleRevision",
"BaseRevisionMixin",
]
class Article(models.Model):
objects = managers.ArticleManager()
current_revision = models.OneToOneField(
"ArticleRevision",
verbose_name=_("current revision"),
blank=True,
null=True,
related_name="current_set",
on_delete=models.CASCADE,
help_text=_(
"The revision being displayed for this article. If you need to do a roll-back, simply change the value of this field."
),
)
created = models.DateTimeField(
auto_now_add=True,
verbose_name=_("created"),
)
modified = models.DateTimeField(
auto_now=True,
verbose_name=_("modified"),
help_text=_("Article properties last modified"),
)
owner = models.ForeignKey(
django_settings.AUTH_USER_MODEL,
verbose_name=_("owner"),
blank=True,
null=True,
related_name="owned_articles",
help_text=_(
"The owner of the article, usually the creator. The owner always has both read and write access."
),
on_delete=models.SET_NULL,
)
group = models.ForeignKey(
settings.GROUP_MODEL,
verbose_name=_("group"),
blank=True,
null=True,
help_text=_(
"Like in a UNIX file system, permissions can be given to a user according to group membership. Groups are handled through the Django auth system."
),
on_delete=models.SET_NULL,
)
group_read = models.BooleanField(
default=True, verbose_name=_("group read access")
)
group_write = models.BooleanField(
default=True, verbose_name=_("group write access")
)
other_read = models.BooleanField(
default=True, verbose_name=_("others read access")
)
other_write = models.BooleanField(
default=True, verbose_name=_("others write access")
)
# PERMISSIONS
def can_read(self, user):
return permissions.can_read(self, user)
def can_write(self, user):
return permissions.can_write(self, user)
def can_delete(self, user):
return permissions.can_delete(self, user)
def can_moderate(self, user):
return permissions.can_moderate(self, user)
def can_assign(self, user):
return permissions.can_assign(self, user)
def ancestor_objects(self):
"""NB! This generator is expensive, so use it with care!!"""
for obj in self.articleforobject_set.filter(is_mptt=True):
yield from obj.content_object.get_ancestors()
def descendant_objects(self):
"""NB! This generator is expensive, so use it with care!!"""
for obj in self.articleforobject_set.filter(is_mptt=True):
yield from obj.content_object.get_descendants()
def get_children(self, max_num=None, user_can_read=None, **kwargs):
"""NB! This generator is expensive, so use it with care!!"""
cnt = 0
for obj in self.articleforobject_set.filter(is_mptt=True):
if user_can_read:
objects = (
obj.content_object.get_children()
.filter(**kwargs)
.can_read(user_can_read)
)
else:
objects = obj.content_object.get_children().filter(**kwargs)
for child in objects.order_by(
"articles__article__current_revision__title"
):
cnt += 1
if max_num and cnt > max_num:
return
yield child
# All recursive permission methods will use descendant_objects to access
# generic relations and check if they are using MPTT and have
# INHERIT_PERMISSIONS=True
def set_permissions_recursive(self):
for descendant in self.descendant_objects():
if descendant.INHERIT_PERMISSIONS:
descendant.article.group_read = self.group_read
descendant.article.group_write = self.group_write
descendant.article.other_read = self.other_read
descendant.article.other_write = self.other_write
descendant.article.save()
def set_group_recursive(self):
for descendant in self.descendant_objects():
if descendant.INHERIT_PERMISSIONS:
descendant.article.group = self.group
descendant.article.save()
def set_owner_recursive(self):
for descendant in self.descendant_objects():
if descendant.INHERIT_PERMISSIONS:
descendant.article.owner = self.owner
descendant.article.save()
def add_revision(self, new_revision, save=True):
"""
Sets the properties of a revision and ensures its the current
revision.
"""
assert self.id or save, (
"Article.add_revision: Sorry, you cannot add a"
"revision to an article that has not been saved "
"without using save=True"
)
if not self.id:
self.save()
revisions = self.articlerevision_set.all()
try:
new_revision.revision_number = (
revisions.latest().revision_number + 1
)
except ArticleRevision.DoesNotExist:
new_revision.revision_number = 0
new_revision.article = self
new_revision.previous_revision = self.current_revision
if save:
new_revision.clean()
new_revision.save()
self.current_revision = new_revision
if save:
self.save()
def add_object_relation(self, obj):
return ArticleForObject.objects.get_or_create(
article=self,
content_type=ContentType.objects.get_for_model(obj),
object_id=obj.id,
is_mptt=isinstance(obj, MPTTModel),
)
@classmethod
def get_for_object(cls, obj):
return ArticleForObject.objects.get(
object_id=obj.id,
content_type=ContentType.objects.get_for_model(obj),
).article
def __str__(self):
if self.current_revision:
return self.current_revision.title
obj_name = _("Article without content (%(id)d)") % {"id": self.id}
return str(obj_name)
class Meta:
permissions = (
("moderate", _("Can edit all articles and lock/unlock/restore")),
("assign", _("Can change ownership of any article")),
("grant", _("Can assign permissions to other users")),
)
def render(self, preview_content=None, user=None):
if not self.current_revision:
return ""
if preview_content:
content = preview_content
else:
content = self.current_revision.content
return mark_safe(
article_markdown(
content, self, preview=preview_content is not None, user=user
)
)
def get_cache_key(self):
"""Returns per-article cache key."""
lang = translation.get_language()
key_raw = "wiki-article-{id}-{lang}".format(
id=self.current_revision.id if self.current_revision else self.id,
lang=lang,
)
# https://github.com/django-wiki/django-wiki/issues/1065
return slugify(key_raw, allow_unicode=True)
def get_cache_content_key(self, user=None):
"""Returns per-article-user cache key."""
key_raw = "{key}-{user}".format(
key=self.get_cache_key(),
user=user.get_username() if user else "-anonymous",
)
# https://github.com/django-wiki/django-wiki/issues/1065
return slugify(key_raw, allow_unicode=True)
def get_cached_content(self, user=None):
"""Returns cached version of rendered article.
The cache contains one "per-article" entry plus multiple
"per-article-user" entries. The per-article-user entries contain the
rendered article, the per-article entry contains list of the
per-article-user keys. The rendered article in cache (per-article-user)
is used only if the key is in the per-article entry. To delete
per-article invalidates all article cache entries."""
if user and user.is_anonymous:
user = None
cache_key = self.get_cache_key()
cache_content_key = self.get_cache_content_key(user)
cached_items = cache.get(cache_key, [])
if cache_content_key in cached_items:
cached_content = cache.get(cache_content_key)
if cached_content is not None:
return mark_safe(cached_content)
cached_content = self.render(user=user)
cached_items.append(cache_content_key)
cache.set(cache_key, cached_items, settings.CACHE_TIMEOUT)
cache.set(cache_content_key, cached_content, settings.CACHE_TIMEOUT)
return mark_safe(cached_content)
def clear_cache(self):
cache.delete(self.get_cache_key())
def get_url_kwargs(self):
urlpaths = self.urlpath_set.all()
if urlpaths.exists():
return {"path": urlpaths[0].path}
return {"article_id": self.id}
def get_absolute_url(self):
return reverse("wiki:get", kwargs=self.get_url_kwargs())
class ArticleForObject(models.Model):
objects = managers.ArticleFkManager()
article = models.ForeignKey("Article", on_delete=models.CASCADE)
# Same as django.contrib.comments
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
verbose_name=_("content type"),
related_name="content_type_set_for_%(class)s",
)
object_id = models.PositiveIntegerField(_("object ID"))
content_object = GenericForeignKey("content_type", "object_id")
is_mptt = models.BooleanField(default=False, editable=False)
def __str__(self):
return str(self.article)
class Meta:
verbose_name = _("Article for object")
verbose_name_plural = _("Articles for object")
# Do not allow several objects
unique_together = ("content_type", "object_id")
class BaseRevisionMixin(models.Model):
"""This is an abstract model used as a mixin: Do not override any of the
core model methods but respect the inheritor's freedom to do so itself."""
revision_number = models.IntegerField(
editable=False, verbose_name=_("revision number")
)
user_message = models.TextField(
blank=True,
)
automatic_log = models.TextField(
blank=True,
editable=False,
)
ip_address = IPAddressField(
_("IP address"), blank=True, null=True, editable=False
)
user = models.ForeignKey(
django_settings.AUTH_USER_MODEL,
verbose_name=_("user"),
blank=True,
null=True,
on_delete=models.SET_NULL,
)
modified = models.DateTimeField(auto_now=True)
created = models.DateTimeField(auto_now_add=True)
previous_revision = models.ForeignKey(
"self", blank=True, null=True, on_delete=models.SET_NULL
)
# NOTE! The semantics of these fields are not related to the revision itself
# but the actual related object. If the latest revision says "deleted=True" then
# the related object should be regarded as deleted.
deleted = models.BooleanField(
verbose_name=_("deleted"),
default=False,
)
locked = models.BooleanField(
verbose_name=_("locked"),
default=False,
)
def set_from_request(self, request):
if request.user.is_authenticated:
self.user = request.user
if settings.LOG_IPS_USERS:
self.ip_address = request.META.get("REMOTE_ADDR", None)
elif settings.LOG_IPS_ANONYMOUS:
self.ip_address = request.META.get("REMOTE_ADDR", None)
def inherit_predecessor(self, predecessor):
"""
This is a naive way of inheriting, assuming that ``predecessor`` is in
fact the predecessor and there hasn't been any intermediate changes!
:param: predecessor is an instance of whatever object for which
object.current_revision implements BaseRevisionMixin.
"""
predecessor = predecessor.current_revision
self.previous_revision = predecessor
self.deleted = predecessor.deleted
self.locked = predecessor.locked
self.revision_number = predecessor.revision_number + 1
class Meta:
abstract = True
class ArticleRevision(BaseRevisionMixin, models.Model):
"""This is where main revision data is stored. To make it easier to
copy, do NEVER create m2m relationships."""
objects = managers.ArticleFkManager()
article = models.ForeignKey(
"Article", on_delete=models.CASCADE, verbose_name=_("article")
)
# This is where the content goes, with whatever markup language is used
content = models.TextField(blank=True, verbose_name=_("article contents"))
# This title is automatically set from either the article's title or
# the last used revision...
title = models.CharField(
max_length=512,
verbose_name=_("article title"),
null=False,
blank=False,
help_text=_(
"Each revision contains a title field that must be filled out, even if the title has not changed"
),
)
# TODO:
# Allow a revision to redirect to another *article*. This
# way, we can have redirects and still maintain old content.
# redirect = models.ForeignKey('Article', null=True, blank=True,
# verbose_name=_('redirect'),
# help_text=_('If set, the article will redirect to the contents of another article.'),
# related_name='redirect_set')
def __str__(self):
return "%s (%d)" % (self.title, self.revision_number)
def clean(self):
# Enforce DOS line endings \r\n. It is the standard for web browsers,
# but when revisions are created programatically, they might
# have UNIX line endings \n instead.
self.content = self.content.replace("\r", "").replace("\n", "\r\n")
def inherit_predecessor(self, article):
"""
Inherit certain properties from predecessor because it's very
convenient. Remember to always call this method before
setting properties :)"""
predecessor = article.current_revision
self.article = predecessor.article
self.content = predecessor.content
self.title = predecessor.title
self.deleted = predecessor.deleted
self.locked = predecessor.locked
class Meta:
get_latest_by = "revision_number"
ordering = ("created",)
unique_together = ("article", "revision_number")
######################################################
# SIGNAL HANDLERS
######################################################
# clear the ancestor cache when saving or deleting articles so things like
# article_lists will be refreshed
def _clear_ancestor_cache(article):
for ancestor in article.ancestor_objects():
ancestor.article.clear_cache()
@disable_signal_for_loaddata
def on_article_save_clear_cache(instance, **kwargs):
on_article_delete_clear_cache(instance, **kwargs)
@disable_signal_for_loaddata
def on_article_delete_clear_cache(instance, **kwargs):
_clear_ancestor_cache(instance)
instance.clear_cache()
@disable_signal_for_loaddata
def on_article_revision_pre_save(**kwargs):
instance = kwargs["instance"]
if instance._state.adding:
revision_changed = (
not instance.previous_revision
and instance.article
and instance.article.current_revision
and instance.article.current_revision != instance
)
if revision_changed:
instance.previous_revision = instance.article.current_revision
if not instance.revision_number:
try:
previous_revision = instance.article.articlerevision_set.latest()
instance.revision_number = previous_revision.revision_number + 1
except ArticleRevision.DoesNotExist:
instance.revision_number = 1
@disable_signal_for_loaddata
def on_article_revision_post_save(**kwargs):
instance = kwargs["instance"]
if not instance.article.current_revision:
# If I'm saved from Django admin, then article.current_revision is
# me!
instance.article.current_revision = instance
instance.article.save()
pre_save.connect(on_article_revision_pre_save, ArticleRevision)
post_save.connect(on_article_revision_post_save, ArticleRevision)
post_save.connect(on_article_save_clear_cache, Article)
pre_delete.connect(on_article_delete_clear_cache, Article)

View File

@@ -0,0 +1,343 @@
"""
There are three kinds of plugin base models:
1) SimplePlugin - an object purely associated with an article. Will bump the
article's revision history upon creation, and rolling back an article will
make it go away (not from the database, you can roll forwards again).
2) RevisionPlugin - an object with its own revisions. The object will have its
own history independent of the article. The strategy is that you will provide
different code for the article text while including it, so it will indirectly
affect the article history, but you have the force of rolling back this
object independently.
3) ReusablePlugin - a plugin that can be used on many articles. Please note
that the logics for keeping revisions on such plugins are complicated, so you
have to implement that on your own. Furthermore, you need to be aware of
the permission system!
"""
from django.db import models
from django.db.models import signals
from django.utils.translation import gettext_lazy as _
from wiki.decorators import disable_signal_for_loaddata
from .article import ArticleRevision
from .article import BaseRevisionMixin
__all__ = [
"ArticlePlugin",
"SimplePlugin",
"SimplePluginCreateError",
"ReusablePlugin",
"RevisionPlugin",
"RevisionPluginRevision",
]
class ArticlePlugin(models.Model):
"""This is the mother of all plugins. Extending from it means a deletion
of an article will CASCADE to your plugin, and the database will be kept
clean. Furthermore, it's possible to list all plugins and maintain generic
properties in the future..."""
article = models.ForeignKey(
"wiki.Article", on_delete=models.CASCADE, verbose_name=_("article")
)
deleted = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
# Permission methods - you should override these, if they don't fit your
# logic.
def can_read(self, user):
return self.article.can_read(user)
def can_write(self, user):
return self.article.can_write(user)
def can_delete(self, user):
return self.article.can_delete(user)
def can_moderate(self, user):
return self.article.can_moderate(user)
def purge(self):
"""Remove related contents completely, ie. media files."""
pass
class ReusablePlugin(ArticlePlugin):
"""Extend from this model if you have a plugin that may be related to many
articles. Please note that the ArticlePlugin.article ForeignKey STAYS! This
is in order to maintain an explicit set of permissions.
In general, it's quite complicated to maintain plugin content that's shared
between different articles. The best way to go is to avoid this. For inspiration,
look at wiki.plugins.attachments
You might have to override the permission methods (can_read, can_write etc.)
if you have certain needs for logic in your reusable plugin.
"""
# The article on which the plugin was originally created.
# Used to apply permissions.
ArticlePlugin.article.on_delete = models.SET_NULL
ArticlePlugin.article.verbose_name = _("original article")
ArticlePlugin.article.help_text = _(
"Permissions are inherited from this article"
)
ArticlePlugin.article.null = True
ArticlePlugin.article.blank = True
articles = models.ManyToManyField(
"wiki.Article", related_name="shared_plugins_set"
)
# Since the article relation may be None, we have to check for this
# before handling permissions....
def can_read(self, user):
return self.article.can_read(user) if self.article else False
def can_write(self, user):
return self.article.can_write(user) if self.article else False
def can_delete(self, user):
return self.article.can_delete(user) if self.article else False
def can_moderate(self, user):
return self.article.can_moderate(user) if self.article else False
class SimplePluginCreateError(Exception):
pass
class SimplePlugin(ArticlePlugin):
"""
Inherit from this model and make sure to specify an article when
saving a new instance. This way, a new revision will be created, and
users are able to roll back to the a previous revision (in which your
plugin wasn't related to the article).
Furthermore, your plugin relation is kept when new revisions are created.
Usage:
class YourPlugin(SimplePlugin):
...
Creating new plugins instances:
YourPlugin(article=article_instance, ...) or
YourPlugin.objects.create(article=article_instance, ...)
"""
# The article revision that this plugin is attached to
article_revision = models.ForeignKey(
"wiki.ArticleRevision", on_delete=models.CASCADE
)
def __init__(self, *args, **kwargs):
article = kwargs.pop("article", None)
super().__init__(*args, **kwargs)
if not self.pk and not article:
raise SimplePluginCreateError(
"Keyword argument 'article' expected."
)
elif self.pk:
self.article = self.article_revision.article
else:
self.article = article
def get_logmessage(self):
return _("A plugin was changed")
class RevisionPlugin(ArticlePlugin):
"""
If you want your plugin to maintain revisions, extend from this one,
not SimplePlugin.
This kind of plugin is not attached to article plugins so rolling articles
back and forth does not affect it.
"""
# The current revision of this plugin, if any!
current_revision = models.OneToOneField(
"RevisionPluginRevision",
verbose_name=_("current revision"),
blank=True,
null=True,
on_delete=models.CASCADE,
related_name="plugin_set",
help_text=_(
"The revision being displayed for this plugin. "
"If you need to do a roll-back, simply change the value of this field."
),
)
def add_revision(self, new_revision, save=True):
"""
Sets the properties of a revision and ensures its the current
revision.
"""
assert self.id or save, (
"RevisionPluginRevision.add_revision: Sorry, you cannot add a"
"revision to a plugin that has not been saved "
"without using save=True"
)
if not self.id:
self.save()
revisions = self.revision_set.all()
try:
new_revision.revision_number = (
revisions.latest().revision_number + 1
)
except RevisionPluginRevision.DoesNotExist:
new_revision.revision_number = 0
new_revision.plugin = self
new_revision.previous_revision = self.current_revision
if save:
new_revision.save()
self.current_revision = new_revision
if save:
self.save()
class RevisionPluginRevision(BaseRevisionMixin, models.Model):
"""
If you want your plugin to maintain revisions, make an extra model
that extends from this one.
(this class is very much copied from wiki.models.article.ArticleRevision
"""
plugin = models.ForeignKey(
RevisionPlugin, on_delete=models.CASCADE, related_name="revision_set"
)
class Meta:
# Override this setting with app_label = '' in your extended model
# if it lives outside the wiki app.
get_latest_by = "revision_number"
ordering = ("-created",)
######################################################
# SIGNAL HANDLERS
######################################################
# Look at me. I'm a plane.
# And the plane becomes a metaphor for my life.
# It's my art, when I disguise my body in the shape of a plane.
# (Shellac, 1993)
@disable_signal_for_loaddata
def update_simple_plugins(**kwargs):
"""Every time a new article revision is created, we update all active
plugins to match this article revision"""
instance = kwargs["instance"]
if kwargs.get("created", False):
p_revisions = SimplePlugin.objects.filter(
article=instance.article, deleted=False
)
# TODO: This was breaking things. SimplePlugin doesn't have a revision?
p_revisions.update(article_revision=instance)
@disable_signal_for_loaddata
def on_simple_plugins_pre_save(**kwargs):
instance = kwargs["instance"]
if instance._state.adding:
if not instance.article.current_revision:
raise SimplePluginCreateError(
"Article does not have a current_revision set."
)
new_revision = ArticleRevision()
new_revision.inherit_predecessor(instance.article)
new_revision.automatic_log = instance.get_logmessage()
new_revision.save()
instance.article_revision = new_revision
@disable_signal_for_loaddata
def on_article_plugin_post_save(**kwargs):
articleplugin = kwargs["instance"]
articleplugin.article.clear_cache()
@disable_signal_for_loaddata
def on_reusable_plugin_pre_save(**kwargs):
# Automatically make the original article the first one in the added
# set
instance = kwargs["instance"]
if not instance.article:
articles = instance.articles.all()
if articles.exists():
instance.article = articles[0]
@disable_signal_for_loaddata
def on_revision_plugin_revision_post_save(**kwargs):
# Automatically make the original article the first one in the added
# set
instance = kwargs["instance"]
if not instance.plugin.current_revision:
# If I'm saved from Django admin, then plugin.current_revision is
# me!
instance.plugin.current_revision = instance
instance.plugin.save()
# Invalidate plugin's article cache
instance.plugin.article.clear_cache()
@disable_signal_for_loaddata
def on_revision_plugin_revision_pre_save(**kwargs):
instance = kwargs["instance"]
if instance._state.adding:
update_previous_revision = (
not instance.previous_revision
and instance.plugin
and instance.plugin.current_revision
and instance.plugin.current_revision != instance
)
if update_previous_revision:
instance.previous_revision = instance.plugin.current_revision
if not instance.revision_number:
try:
previous_revision = instance.plugin.revision_set.latest()
instance.revision_number = previous_revision.revision_number + 1
except RevisionPluginRevision.DoesNotExist:
instance.revision_number = 1
@disable_signal_for_loaddata
def on_reusable_plugin_post_save(**kwargs):
reusableplugin = kwargs["instance"]
for article in reusableplugin.articles.all():
article.clear_cache()
signals.post_save.connect(update_simple_plugins, ArticleRevision)
signals.post_save.connect(on_article_plugin_post_save, ArticlePlugin)
signals.post_save.connect(on_reusable_plugin_post_save, ReusablePlugin)
signals.post_save.connect(
on_revision_plugin_revision_post_save, RevisionPluginRevision
)
signals.pre_save.connect(on_reusable_plugin_pre_save, ReusablePlugin)
signals.pre_save.connect(
on_revision_plugin_revision_pre_save, RevisionPluginRevision
)
signals.pre_save.connect(on_simple_plugins_pre_save, SimplePlugin)

464
src/wiki/models/urlpath.py Normal file
View File

@@ -0,0 +1,464 @@
import logging
import warnings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
from django.db import models
from django.db import transaction
from django.db.models.signals import post_save
from django.db.models.signals import pre_delete
from django.urls import reverse
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from mptt.fields import TreeForeignKey
from mptt.models import MPTTModel
from wiki import managers
from wiki.conf import settings
from wiki.core.exceptions import MultipleRootURLs
from wiki.core.exceptions import NoRootURL
from wiki.decorators import disable_signal_for_loaddata
from wiki.models.article import Article
from wiki.models.article import ArticleForObject
from wiki.models.article import ArticleRevision
__all__ = [
"URLPath",
]
log = logging.getLogger(__name__)
class URLPath(MPTTModel):
"""
Strategy: Very few fields go here, as most has to be managed through an
article's revision. As a side-effect, the URL resolution remains slim and swift.
"""
# Tells django-wiki that permissions from a this object's article
# should be inherited to children's articles. In this case, it's a static
# property.. but you can also use a BooleanField.
INHERIT_PERMISSIONS = True
objects = managers.URLPathManager()
# Do not use this because of
# https://github.com/django-mptt/django-mptt/issues/369
# _default_manager = objects
articles = GenericRelation(
ArticleForObject,
content_type_field="content_type",
object_id_field="object_id",
)
# Do NOT modify this field - it is updated with signals whenever
# ArticleForObject is changed.
article = models.ForeignKey(
Article,
on_delete=models.CASCADE,
verbose_name=_("article"),
help_text=_(
"This field is automatically updated, but you need to populate "
"it when creating a new URL path."
),
)
SLUG_MAX_LENGTH = 50
slug = models.SlugField(
verbose_name=_("slug"),
null=True,
blank=True,
max_length=SLUG_MAX_LENGTH,
)
site = models.ForeignKey(Site, on_delete=models.CASCADE)
parent = TreeForeignKey(
"self",
null=True,
blank=True,
on_delete=models.CASCADE,
related_name="children",
help_text=_("Position of URL path in the tree."),
)
moved_to = TreeForeignKey(
"self",
verbose_name=_("Moved to"),
help_text=_("Article path was moved to this location"),
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="moved_from",
)
def __cached_ancestors(self):
"""
This returns the ancestors of this urlpath. These ancestors are hopefully
cached from the article path lookup. Accessing a foreign key included in
add_selecte_related on one of these ancestors will not occur an additional
sql query, as they were retrieved with a select_related.
If the cached ancestors were not set explicitly, they will be retrieved from
the database.
"""
# "not self.pk": HACK needed till PR#591 is included in all supported django-mptt
# versions. Prevent accessing a deleted URLPath when deleting it from the admin
# interface.
if not self.pk or not self.get_ancestors().exists():
self._cached_ancestors = []
if not hasattr(self, "_cached_ancestors"):
self._cached_ancestors = list(
self.get_ancestors().select_related_common()
)
return self._cached_ancestors
def __cached_ancestors_setter(self, ancestors):
self._cached_ancestors = ancestors
# Python 2.5 compatible property constructor
cached_ancestors = property(__cached_ancestors, __cached_ancestors_setter)
def set_cached_ancestors_from_parent(self, parent):
self.cached_ancestors = parent.cached_ancestors + [parent]
@property
def path(self):
if not self.parent:
return ""
# All ancestors except roots
ancestors = list(
filter(
lambda ancestor: ancestor.parent is not None,
self.cached_ancestors,
)
)
slugs = [obj.slug if obj.slug else "" for obj in ancestors + [self]]
return "/".join(slugs) + "/"
def is_deleted(self):
"""
Returns True if this article or any of its ancestors have been deleted
"""
return self.first_deleted_ancestor() is not None
def first_deleted_ancestor(self):
for ancestor in self.cached_ancestors + [self]:
if ancestor.article.current_revision.deleted:
return ancestor
return None
@transaction.atomic
def _delete_subtree(self):
for descendant in self.get_descendants(include_self=True).order_by(
"-level"
):
descendant.article.delete()
def delete_subtree(self):
"""
NB! This deletes this urlpath, its children, and ALL of the related
articles. This is a purged delete and CANNOT be undone.
"""
self._delete_subtree()
@classmethod
def root(cls):
site = Site.objects.get_current()
root_nodes = (
cls.objects.root_nodes().filter(site=site).select_related_common()
)
# We fetch the nodes as a list and use len(), not count() because we need
# to get the result out anyway. This only takes one sql query
no_paths = len(root_nodes)
if no_paths == 0:
raise NoRootURL(
"You need to create a root article on site '%s'" % site
)
if no_paths > 1:
raise MultipleRootURLs(
"Somehow you have multiple roots on %s" % site
)
return root_nodes[0]
class MPTTMeta:
pass
def __str__(self):
path = self.path
return path if path else gettext("(root)")
def delete(self, *args, **kwargs):
assert not (
self.parent and self.get_children()
), "You cannot delete a root article with children."
super().delete(*args, **kwargs)
class Meta:
verbose_name = _("URL path")
verbose_name_plural = _("URL paths")
unique_together = ("site", "parent", "slug")
def clean(self, *args, **kwargs):
if self.slug and not self.parent:
raise ValidationError(
_("Sorry but you cannot have a root article with a slug.")
)
if not self.slug and self.parent:
raise ValidationError(
_("A non-root note must always have a slug.")
)
if not self.parent:
if (
URLPath.objects.root_nodes()
.filter(site=self.site)
.exclude(id=self.id)
):
raise ValidationError(
_("There is already a root node on %s") % self.site
)
@classmethod
def get_by_path(cls, path, select_related=False):
"""
Strategy: Don't handle all kinds of weird cases. Be strict.
Accepts paths both starting with and without '/'
"""
# TODO: Save paths directly in the model for constant time lookups?
# Or: Save the parents in a lazy property because the parents are
# always fetched anyways so it's fine to fetch them here.
path = path.lstrip("/")
path = path.rstrip("/")
# Root page requested
if not path:
return cls.root()
slugs = path.split("/")
level = 1
parent = cls.root()
for slug in slugs:
if settings.URL_CASE_SENSITIVE:
child = (
parent.get_children()
.select_related_common()
.get(slug=slug)
)
child.cached_ancestors = parent.cached_ancestors + [parent]
parent = child
else:
child = (
parent.get_children()
.select_related_common()
.get(slug__iexact=slug)
)
child.cached_ancestors = parent.cached_ancestors + [parent]
parent = child
level += 1
return parent
def get_absolute_url(self):
return reverse("wiki:get", kwargs={"path": self.path})
@classmethod
def create_root(cls, site=None, title="Root", request=None, **kwargs):
if not site:
site = Site.objects.get_current()
root_nodes = cls.objects.root_nodes().filter(site=site)
if not root_nodes:
article = Article()
revision = ArticleRevision(title=title, **kwargs)
if request:
revision.set_from_request(request)
article.add_revision(revision, save=True)
article.save()
root = cls.objects.create(site=site, article=article)
article.add_object_relation(root)
else:
root = root_nodes[0]
return root
@classmethod
@transaction.atomic
def create_urlpath(
cls,
parent,
slug,
site=None,
title="Root",
article_kwargs={},
request=None,
article_w_permissions=None,
**revision_kwargs,
):
"""
Utility function:
Creates a new urlpath with an article and a new revision for the
article
:returns: A new URLPath instance
"""
if not site:
site = Site.objects.get_current()
article = Article(**article_kwargs)
article.add_revision(
ArticleRevision(title=title, **revision_kwargs), save=True
)
article.save()
newpath = cls.objects.create(
site=site, parent=parent, slug=slug, article=article
)
article.add_object_relation(newpath)
return newpath
@classmethod
def _create_urlpath_from_request(
cls,
request,
perm_article,
parent_urlpath,
slug,
title,
content,
summary,
):
"""
Creates a new URLPath, using meta data from ``request`` and copies in
the permissions from ``perm_article``.
This interface is internal because it's rather sloppy
"""
user = None
ip_address = None
if not request.user.is_anonymous:
user = request.user
if settings.LOG_IPS_USERS:
ip_address = request.META.get("REMOTE_ADDR", None)
elif settings.LOG_IPS_ANONYMOUS:
ip_address = request.META.get("REMOTE_ADDR", None)
return cls.create_urlpath(
parent_urlpath,
slug,
title=title,
content=content,
user_message=summary,
user=user,
ip_address=ip_address,
article_kwargs={
"owner": user,
"group": perm_article.group,
"group_read": perm_article.group_read,
"group_write": perm_article.group_write,
"other_read": perm_article.other_read,
"other_write": perm_article.other_write,
},
)
@classmethod
def create_article(cls, *args, **kwargs):
warnings.warn(
"Pending removal: URLPath.create_article renamed to create_urlpath",
DeprecationWarning,
)
return cls.create_urlpath(*args, **kwargs)
def get_ordered_children(self):
"""Return an ordered list of all chilren"""
return self.children.order_by("slug")
######################################################
# SIGNAL HANDLERS
######################################################
# Just get this once
urlpath_content_type = None
@disable_signal_for_loaddata
def on_article_relation_save(**kwargs):
global urlpath_content_type
instance = kwargs["instance"]
if not urlpath_content_type:
urlpath_content_type = ContentType.objects.get_for_model(URLPath)
if instance.content_type == urlpath_content_type:
URLPath.objects.filter(id=instance.object_id).update(
article=instance.article
)
post_save.connect(on_article_relation_save, ArticleForObject)
class Namespace:
# An instance of Namespace simulates "nonlocal variable_name" declaration
# in any nested function, that is possible in Python 3. It allows assigning
# to non local variable without rebinding it local. See PEP 3104.
pass
def on_article_delete(instance, *args, **kwargs):
# If an article is deleted, then throw out its URLPaths
# But move all descendants to a lost-and-found node.
site = Site.objects.get_current()
# Get the Lost-and-found path or create a new one
# Only create the lost-and-found article if it's necessary and such
# that the lost-and-found article can be deleted without being recreated!
ns = Namespace() # nonlocal namespace backported to Python 2.x
ns.lost_and_found = None
def get_lost_and_found():
if ns.lost_and_found:
return ns.lost_and_found
try:
ns.lost_and_found = URLPath.objects.get(
slug=settings.LOST_AND_FOUND_SLUG,
parent=URLPath.root(),
site=site,
)
except URLPath.DoesNotExist:
article = Article(
group_read=True,
group_write=False,
other_read=False,
other_write=False,
)
article.add_revision(
ArticleRevision(
content=_(
"Articles who lost their parents\n"
"===============================\n\n"
"The children of this article have had their parents deleted. You should probably find a new home for them."
),
title=_("Lost and found"),
)
)
ns.lost_and_found = URLPath.objects.create(
slug=settings.LOST_AND_FOUND_SLUG,
parent=URLPath.root(),
site=site,
article=article,
)
article.add_object_relation(ns.lost_and_found)
return ns.lost_and_found
for urlpath in URLPath.objects.filter(
articles__article=instance, site=site
):
# Delete the children
for child in urlpath.get_children():
child.move_to(get_lost_and_found())
# ...and finally delete the path itself
pre_delete.connect(on_article_delete, Article)

View File

View File

@@ -0,0 +1 @@
default_app_config = "wiki.plugins.attachments.apps.AttachmentsConfig"

View File

@@ -0,0 +1,16 @@
from django.contrib import admin
from . import models
class AttachmentRevisionAdmin(admin.TabularInline):
model = models.AttachmentRevision
extra = 1
fields = ("file", "user", "user_message")
class AttachmentAdmin(admin.ModelAdmin):
inlines = [AttachmentRevisionAdmin]
admin.site.register(models.Attachment, AttachmentAdmin)

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class AttachmentsConfig(AppConfig):
name = "wiki.plugins.attachments"
verbose_name = _("Wiki attachments")
label = "wiki_attachments"

View File

@@ -0,0 +1,172 @@
import tempfile
import zipfile
from django import forms
from django.core.files.uploadedfile import File
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from wiki.core.permissions import can_moderate
from wiki.plugins.attachments import models
from wiki.plugins.attachments.models import IllegalFileExtension
class AttachmentForm(forms.ModelForm):
description = forms.CharField(
label=_("Description"),
help_text=_("A short summary of what the file contains"),
required=False,
)
def __init__(self, *args, **kwargs):
self.article = kwargs.pop("article", None)
self.request = kwargs.pop("request", None)
self.attachment = kwargs.pop("attachment", None)
super().__init__(*args, **kwargs)
def clean_file(self):
uploaded_file = self.cleaned_data.get("file", None)
if uploaded_file:
try:
models.extension_allowed(uploaded_file.name)
except IllegalFileExtension as e:
raise forms.ValidationError(e)
return uploaded_file
def save(self, *args, **kwargs):
commit = kwargs.get("commit", True)
attachment_revision = super().save(commit=False)
# Added because of AttachmentArchiveForm removing file from fields
# should be more elegant
attachment_revision.file = self.cleaned_data["file"]
if not self.attachment:
attachment = models.Attachment()
attachment.article = self.article
attachment.original_filename = attachment_revision.get_filename()
if commit:
attachment.save()
attachment.articles.add(self.article)
else:
attachment = self.attachment
attachment_revision.attachment = attachment
attachment_revision.set_from_request(self.request)
if commit:
attachment_revision.save()
return attachment_revision
class Meta:
model = models.AttachmentRevision
fields = (
"file",
"description",
)
class AttachmentReplaceForm(AttachmentForm):
replace = forms.BooleanField(
label=_("Remove previous"),
help_text=_(
"Remove previous attachment revisions and their files (to "
"save space)?"
),
required=False,
)
class AttachmentArchiveForm(AttachmentForm):
file = forms.FileField( # @ReservedAssignment
label=_("File or zip archive"), required=True
)
unzip_archive = forms.BooleanField(
label=_("Unzip file"),
help_text=_(
"Create individual attachments for files in a .zip file - directories do not work."
),
required=False,
)
def clean_file(self):
uploaded_file = self.cleaned_data.get("file", None)
if uploaded_file and self.cleaned_data.get("unzip_archive", False):
try:
self.zipfile = zipfile.ZipFile(uploaded_file.file, mode="r")
for zipinfo in self.zipfile.filelist:
try:
models.extension_allowed(zipinfo.filename)
except IllegalFileExtension as e:
raise forms.ValidationError(e)
except zipfile.BadZipfile:
raise forms.ValidationError(gettext("Not a zip file"))
else:
return super().clean_file()
return uploaded_file
def clean(self):
super().clean()
if not can_moderate(self.article, self.request.user):
raise forms.ValidationError(
gettext("User not allowed to moderate this article")
)
return self.cleaned_data
def save(self, *args, **kwargs):
# This is not having the intended effect
if "file" not in self._meta.fields:
self._meta.fields.append("file")
if self.cleaned_data["unzip_archive"]:
new_attachments = []
try:
uploaded_file = self.cleaned_data.get("file", None)
self.zipfile = zipfile.ZipFile(uploaded_file.file, mode="r")
for zipinfo in self.zipfile.filelist:
f = tempfile.NamedTemporaryFile(mode="w+b")
f.write(self.zipfile.read(zipinfo.filename))
f = File(f, name=zipinfo.filename)
try:
attachment = models.Attachment()
attachment.article = self.article
attachment.original_filename = zipinfo.filename
attachment.save()
attachment.articles.add(self.article)
attachment_revision = models.AttachmentRevision()
attachment_revision.file = f
attachment_revision.description = self.cleaned_data[
"description"
]
attachment_revision.attachment = attachment
attachment_revision.set_from_request(self.request)
attachment_revision.save()
f.close()
except models.IllegalFileExtension:
raise
new_attachments.append(attachment_revision)
except zipfile.BadZipfile:
raise
return new_attachments
else:
return super().save(*args, **kwargs)
class Meta(AttachmentForm.Meta):
fields = [
"description",
]
class DeleteForm(forms.Form):
"""This form is both used for dereferencing and deleting attachments"""
confirm = forms.BooleanField(label=_("Yes I am sure..."), required=False)
def clean_confirm(self):
if not self.cleaned_data["confirm"]:
raise forms.ValidationError(gettext("You are not sure enough!"))
return True
class SearchForm(forms.Form):
query = forms.CharField(
label="",
widget=forms.TextInput(attrs={"class": "search-query form-control"}),
)

View File

@@ -0,0 +1,98 @@
import re
import markdown
from django.contrib.auth.models import AnonymousUser
from django.template.loader import render_to_string
from django.urls import reverse
from wiki.core.markdown import add_to_registry
from wiki.core.permissions import can_read
from wiki.plugins.attachments import models
ATTACHMENT_RE = re.compile(
r"(?P<before>.*)\[( *((attachment\:(?P<id>[0-9]+))|(title\:\"(?P<title>[^\"]+)\")|(?P<size>size)))+\](?P<after>.*)",
re.IGNORECASE,
)
class AttachmentExtension(markdown.Extension):
"""Abbreviation Extension for Python-Markdown."""
def extendMarkdown(self, md):
"""Insert AbbrPreprocessor before ReferencePreprocessor."""
add_to_registry(
md.preprocessors,
"dw-attachments",
AttachmentPreprocessor(md),
">html_block",
)
class AttachmentPreprocessor(markdown.preprocessors.Preprocessor):
"""django-wiki attachment preprocessor - parse text for [attachment:id] references."""
def run(self, lines):
new_text = []
for line in lines:
m = ATTACHMENT_RE.match(line)
if not m:
new_text.append(line)
continue
attachment_id = m.group("id").strip()
title = m.group("title")
size = m.group("size")
before = self.run([m.group("before")])[0]
after = self.run([m.group("after")])[0]
try:
attachment = models.Attachment.objects.get(
articles__current_revision__deleted=False,
id=attachment_id,
current_revision__deleted=False,
articles=self.md.article,
)
url = reverse(
"wiki:attachments_download",
kwargs={
"article_id": self.md.article.id,
"attachment_id": attachment.id,
},
)
# The readability of the attachment is decided relative
# to the owner of the original article.
# I.e. do not insert attachments in other articles that
# the original uploader cannot read, that would be out
# of scope!
article_owner = attachment.article.owner
if not article_owner:
article_owner = AnonymousUser()
if not title:
title = attachment.original_filename
if size:
size = attachment.current_revision.get_size()
attachment_can_read = can_read(self.md.article, article_owner)
html = render_to_string(
"wiki/plugins/attachments/render.html",
context={
"url": url,
"filename": attachment.original_filename,
"title": title,
"size": size,
"attachment_can_read": attachment_can_read,
},
)
line = self.md.htmlStash.store(html)
except models.Attachment.DoesNotExist:
html = (
"""<span class="attachment attachment-deleted">Attachment with ID """
"""#{} is deleted.</span>"""
).format(attachment_id)
line = line.replace(
"[" + m.group(2) + "]", self.md.htmlStash.store(html)
)
new_text.append(before + line + after)
return new_text

View File

@@ -0,0 +1,132 @@
import django.db.models.deletion
import wiki.plugins.attachments.models
from django.conf import settings
from django.db import migrations
from django.db import models
from django.db.models.fields import GenericIPAddressField as IPAddressField
class Migration(migrations.Migration):
dependencies = [
("wiki", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Attachment",
fields=[
(
"reusableplugin_ptr",
models.OneToOneField(
parent_link=True,
serialize=False,
primary_key=True,
to="wiki.ReusablePlugin",
auto_created=True,
on_delete=models.CASCADE,
),
),
(
"original_filename",
models.CharField(
max_length=256,
verbose_name="original filename",
blank=True,
null=True,
),
),
],
options={
"verbose_name": "attachment",
"verbose_name_plural": "attachments",
},
bases=("wiki.reusableplugin",),
),
migrations.CreateModel(
name="AttachmentRevision",
fields=[
(
"id",
models.AutoField(
serialize=False,
primary_key=True,
verbose_name="ID",
auto_created=True,
),
),
(
"revision_number",
models.IntegerField(verbose_name="revision number", editable=False),
),
("user_message", models.TextField(blank=True)),
("automatic_log", models.TextField(editable=False, blank=True)),
(
"ip_address",
IPAddressField(
editable=False, verbose_name="IP address", blank=True, null=True
),
),
("modified", models.DateTimeField(auto_now=True)),
("created", models.DateTimeField(auto_now_add=True)),
("deleted", models.BooleanField(default=False, verbose_name="deleted")),
("locked", models.BooleanField(default=False, verbose_name="locked")),
(
"file",
models.FileField(
max_length=255,
verbose_name="file",
upload_to=wiki.plugins.attachments.models.upload_path,
),
),
("description", models.TextField(blank=True)),
(
"attachment",
models.ForeignKey(
to="wiki_attachments.Attachment", on_delete=models.CASCADE
),
),
(
"previous_revision",
models.ForeignKey(
blank=True,
on_delete=django.db.models.deletion.SET_NULL,
to="wiki_attachments.AttachmentRevision",
null=True,
),
),
(
"user",
models.ForeignKey(
blank=True,
verbose_name="user",
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
null=True,
),
),
],
options={
"ordering": ("created",),
"get_latest_by": "revision_number",
"verbose_name": "attachment revision",
"verbose_name_plural": "attachment revisions",
},
bases=(models.Model,),
),
migrations.AddField(
model_name="attachment",
name="current_revision",
field=models.OneToOneField(
to="wiki_attachments.AttachmentRevision",
blank=True,
verbose_name="current revision",
related_name="current_set",
help_text="The revision of this attachment currently in use (on all articles using the attachment)",
null=True,
on_delete=models.CASCADE,
),
preserve_default=True,
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations
class Migration(migrations.Migration):
atomic = False
dependencies = [
("wiki_attachments", "0001_initial"),
]
operations = [
migrations.AlterModelTable(
name="attachment",
table="wiki_attachments_attachment",
),
migrations.AlterModelTable(
name="attachmentrevision",
table="wiki_attachments_attachmentrevision",
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-09-16 19:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wiki_attachments', '0002_auto_20151118_1816'),
]
operations = [
migrations.AlterField(
model_name='attachmentrevision',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@@ -0,0 +1,250 @@
import os
from django.conf import settings as django_settings
from django.db import models
from django.db.models import signals
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from wiki import managers
from wiki.decorators import disable_signal_for_loaddata
from wiki.models.article import BaseRevisionMixin
from wiki.models.pluginbase import ReusablePlugin
from . import settings
class IllegalFileExtension(Exception):
"""File extension on upload is not allowed"""
pass
class Attachment(ReusablePlugin):
objects = managers.ArticleFkManager()
current_revision = models.OneToOneField(
"AttachmentRevision",
verbose_name=_("current revision"),
blank=True,
null=True,
related_name="current_set",
on_delete=models.CASCADE,
help_text=_(
"The revision of this attachment currently in use (on all articles using the attachment)"
),
)
original_filename = models.CharField(
max_length=256,
verbose_name=_("original filename"),
blank=True,
null=True,
)
def can_write(self, user):
if not settings.ANONYMOUS and (not user or user.is_anonymous):
return False
return ReusablePlugin.can_write(self, user)
def can_delete(self, user):
return self.can_write(user)
class Meta:
verbose_name = _("attachment")
verbose_name_plural = _("attachments")
# Matches label of upcoming 0.1 release
db_table = "wiki_attachments_attachment"
def __str__(self):
from wiki.models import Article
try:
return "{}: {}".format(
self.article.current_revision.title,
self.original_filename,
)
except Article.DoesNotExist:
return "Attachment for non-existing article"
def extension_allowed(filename):
try:
extension = filename.split(".")[-1]
except IndexError:
# No extension
raise IllegalFileExtension(
gettext("No file extension found in filename. That's not okay!")
)
if extension.lower() not in map(
lambda x: x.lower(), settings.FILE_EXTENSIONS
):
raise IllegalFileExtension(
gettext(
"The following filename is illegal: {filename:s}. Extension "
"has to be one of {extensions:s}"
).format(
filename=filename,
extensions=", ".join(settings.FILE_EXTENSIONS),
)
)
return extension
def upload_path(instance, filename):
extension = extension_allowed(filename)
# Has to match original extension filename
if (
instance.id
and instance.attachment
and instance.attachment.original_filename
):
original_extension = instance.attachment.original_filename.split(".")[
-1
]
if not extension.lower() == original_extension:
raise IllegalFileExtension(
"File extension has to be '%s', not '%s'."
% (original_extension, extension.lower())
)
elif instance.attachment:
instance.attachment.original_filename = filename
upload_path = settings.UPLOAD_PATH
upload_path = upload_path.replace(
"%aid", str(instance.attachment.article.id)
)
if settings.UPLOAD_PATH_OBSCURIFY:
import random
import hashlib
m = hashlib.md5(
str(random.randint(0, 100000000000000)).encode("ascii")
)
upload_path = os.path.join(upload_path, m.hexdigest())
if settings.APPEND_EXTENSION:
filename += ".upload"
return os.path.join(upload_path, filename)
class AttachmentRevision(BaseRevisionMixin, models.Model):
attachment = models.ForeignKey("Attachment", on_delete=models.CASCADE)
file = models.FileField(
upload_to=upload_path, # @ReservedAssignment
max_length=255,
verbose_name=_("file"),
storage=settings.STORAGE_BACKEND,
)
description = models.TextField(blank=True)
class Meta:
verbose_name = _("attachment revision")
verbose_name_plural = _("attachment revisions")
ordering = ("created",)
get_latest_by = "revision_number"
# Matches label of upcoming 0.1 release
db_table = "wiki_attachments_attachmentrevision"
def get_filename(self):
"""Used to retrieve the filename of a revision.
But attachment.original_filename should always be used in the frontend
such that filenames stay consistent."""
# TODO: Perhaps we can let file names change when files are replaced?
if not self.file:
return None
filename = self.file.name.split("/")[-1]
return ".".join(filename.split(".")[:-1])
def get_size(self):
"""Used to retrieve the file size and not cause exceptions."""
try:
return self.file.size
except (ValueError, OSError):
return None
def __str__(self):
return "%s: %s (r%d)" % (
self.attachment.article.current_revision.title,
self.attachment.original_filename,
self.revision_number,
)
@disable_signal_for_loaddata
def on_revision_delete(instance, *args, **kwargs):
if not instance.file:
return
# Remove file
path = instance.file.path.split("/")[:-1]
instance.file.delete(save=False)
# Clean up empty directories
# Check for empty folders in the path. Delete the first two.
max_depth = 1
if len(path) != 0:
if len(path[-1]) == 32:
# Path was (most likely) obscurified so we should look 2 levels down
max_depth = 2
for depth in range(0, max_depth):
delete_path = "/".join(path[:-depth] if depth > 0 else path)
try:
if (
len(
os.listdir(
os.path.join(django_settings.MEDIA_ROOT, delete_path)
)
)
== 0
):
os.rmdir(delete_path)
except OSError:
# Raised by os.listdir if directory is missing
pass
@disable_signal_for_loaddata
def on_attachment_revision_pre_save(**kwargs):
instance = kwargs["instance"]
if instance._state.adding:
update_previous_revision = (
not instance.previous_revision
and instance.attachment
and instance.attachment.current_revision
and instance.attachment.current_revision != instance
)
if update_previous_revision:
instance.previous_revision = instance.attachment.current_revision
if not instance.revision_number:
try:
previous_revision = (
instance.attachment.attachmentrevision_set.latest()
)
instance.revision_number = previous_revision.revision_number + 1
# NB! The above should not raise the below exception, but somehow
# it does.
except (AttachmentRevision.DoesNotExist, Attachment.DoesNotExist):
instance.revision_number = 1
@disable_signal_for_loaddata
def on_attachment_revision_post_save(**kwargs):
instance = kwargs["instance"]
if not instance.attachment.current_revision:
# If I'm saved from Django admin, then article.current_revision is
# me!
instance.attachment.current_revision = instance
instance.attachment.save()
signals.pre_delete.connect(on_revision_delete, AttachmentRevision)
signals.pre_save.connect(on_attachment_revision_pre_save, AttachmentRevision)
signals.post_save.connect(on_attachment_revision_post_save, AttachmentRevision)

View File

@@ -0,0 +1,83 @@
from django.conf import settings as django_settings
from django.core.exceptions import ImproperlyConfigured
from wiki.conf import settings as wiki_settings
# Deprecated
APP_LABEL = None
SLUG = "attachments"
# Please see this note about support for UTF-8 files on django/apache:
# https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/modwsgi/#if-you-get-a-unicodeencodeerror
#: Allow anonymous users upload access (not nice on an open network)
#: ``WIKI_ATTACHMENTS_ANONYMOUS`` can override this, otherwise the default
#: in ``wiki.conf.settings`` is used.
ANONYMOUS = getattr(
django_settings,
"WIKI_ATTACHMENTS_ANONYMOUS",
wiki_settings.ANONYMOUS_UPLOAD,
)
# Maximum file sizes: Please use something like LimitRequestBody on
# your web server.
# http://httpd.apache.org/docs/2.2/mod/core.html#LimitRequestBody
#: Where to store article attachments, relative to ``MEDIA_ROOT``.
#: You should NEVER enable directory indexing in ``MEDIA_ROOT/UPLOAD_PATH``!
#: Actually, you can completely disable serving it, if you want. Files are
#: sent to the user through a Django view that reads and streams a file.
UPLOAD_PATH = getattr(
django_settings, "WIKI_ATTACHMENTS_PATH", "wiki/attachments/%aid/"
)
#: Should the upload path be obscurified? If so, a random hash will be
#: added to the path such that someone can not guess the location of files
#: (if you have restricted permissions and the files are still located
#: within the web server's file system).
UPLOAD_PATH_OBSCURIFY = getattr(
django_settings, "WIKI_ATTACHMENTS_PATH_OBSCURIFY", True
)
#: Allowed extensions for attachments, empty to disallow uploads completely.
#: If ``WIKI_ATTACHMENTS_APPEND_EXTENSION`` files are saved with an appended
#: ".upload" to the file to ensure that your web server never actually executes
#: some script. The extensions are case insensitive.
#: You are asked to explicitly enter all file extensions that you want
#: to allow. For your own safety.
#: Note: this setting is called WIKI_ATTACHMENTS_EXTENSIONS not WIKI_ATTACHMENTS_FILE_EXTENTIONS
FILE_EXTENSIONS = getattr(
django_settings,
"WIKI_ATTACHMENTS_EXTENSIONS",
["pdf", "doc", "odt", "docx", "txt"],
)
#: Storage backend to use, default is to use the same as the rest of the
#: wiki, which is set in ``WIKI_STORAGE_BACKEND``, but you can override it
#: with ``WIKI_ATTACHMENTS_STORAGE_BACKEND``.
STORAGE_BACKEND = getattr(
django_settings,
"WIKI_ATTACHMENTS_STORAGE_BACKEND",
wiki_settings.STORAGE_BACKEND,
)
#: Store files always with an appended .upload extension to be sure that
#: something nasty does not get executed on the server. SAFETY FIRST!
APPEND_EXTENSION = getattr(
django_settings, "WIKI_ATTACHMENTS_APPEND_EXTENSION", True
)
#: Important for e.g. S3 backends: If your storage backend does not have a .path
#: attribute for the file, but only a .url attribute, you should use False.
#: This will reveal the direct download URL so it does not work perfectly for
#: files you wish to be kept private.
USE_LOCAL_PATH = getattr(django_settings, "WIKI_ATTACHMENTS_LOCAL_PATH", True)
if (not USE_LOCAL_PATH) and APPEND_EXTENSION:
raise ImproperlyConfigured(
"django-wiki (attachment plugin) not USE_LOCAL_PATH and APPEND_EXTENSION: "
"You have configured to append .upload and not use local paths. That won't "
"work as all your attachments will be stored and sent with a .upload "
"extension. You have to trust your storage backend to be safe for storing"
"the extensions you have allowed."
)

View File

@@ -0,0 +1,63 @@
{% extends "wiki/article.html" %}
{% load wiki_tags i18n humanize %}
{% block wiki_pagetitle %}{% trans "Delete" %} "{{ attachment.current_revision.get_filename }}"{% endblock %}
{% block wiki_contents_tab %}
{% if attachment.article == article %}
<h2>{% trans "Delete" %} "{{ attachment.current_revision.get_filename }}"?</h2>
<p class="lead">
{% trans "The file may be referenced on other articles. Deleting it means that they will loose their references to this file. The following articles reference this file:" %}
</p>
<ul>
{% for a in attachment.articles.all %}
<li style="font-size: 150%;">{{ a.current_revision.title }}</li>
{% endfor %}
</ul>
<hr />
<form method="POST" class="form-horizontal" id="attachment_form" enctype="multipart/form-data">
{% wiki_form form %}
<div class="form-group form-actions">
<div class="col-lg-2"></div>
<div class="col-lg-10">
<a href="{% url 'wiki:attachments_index' path=urlpath.path article_id=article.id %}" class="btn btn-secondary">
<span class="fa fa-arrow-left"></span>
{% trans "Go back" %}
</a>
<button class="btn btn-danger">
<span class="fa fa-remove"></span>
{% trans "Delete it!" %}
</button>
</div>
</div>
</form>
{% else %}
<h2>{% trans "Remove" %} "{{ attachment.current_revision.get_filename }}"?</h2>
<p class="lead">
{% blocktrans with attachment.original_filename as filename trimmed %}
You can remove a reference to a file, but it will retain its references on other articles.
{% endblocktrans %}
</p>
<form method="POST" class="form-horizontal" id="attachment_form" enctype="multipart/form-data">
{% wiki_form form %}
<div class="form-group form-actions">
<div class="col-lg-2"></div>
<div class="col-lg-10">
<a href="{% url 'wiki:attachments_index' path=urlpath.path article_id=article.id %}" class="btn btn-secondary">
<span class="fa fa-arrow-left"></span>
{% trans "Go back" %}
</a>
<button class="btn btn-danger">
<span class="fa fa-remove"></span>
{% trans "Remove reference" %}
</button>
</div>
</div>
</form>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends "wiki/article.html" %}
{% load wiki_tags i18n humanize %}
{% block wiki_pagetitle %}{% trans "History of" %} "{{ attachment.current_revision.get_filename }}"{% endblock %}
{% block wiki_contents_tab %}
<h2>{% trans "History of" %} "{{ attachment.current_revision.get_filename }}"</h2>
<table class="table table-striped table-bordered">
<tr>
<th>{% trans "Date" %}</th>
<th>{% trans "User" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "File" %}</th>
<th>{% trans "Size" %}</th>
<th style="text-align: right">{% trans "Action" %}</th>
</tr>
{% for revision in revisions %}
<tr>
<td>
{{ revision.created }}
{% if revision.deleted %}<span class="badge badge-important">{% trans "deleted" %}</span>{% endif %}
</td>
<td>
{% include "wiki/includes/revision_info.html" with revision=attachment.current_revision hidedate=1 hidenumber=1 %}
</td>
<td>{% if revision.description %}{{ revision.description }}{% else %}<em>{% trans "No description" %}</em>{% endif %}</td>
<td>{{ revision.get_filename }}</td>
<td>{{ revision.get_size|filesizeformat }}</td>
<td style="text-align: right">
<form method="POST" action="{% url 'wiki:attachments_revision_change' path=urlpath.path article_id=article.id attachment_id=attachment.id revision_id=revision.id %}">
{% csrf_token %}
<a href="{% url 'wiki:attachments_download' path=urlpath.path article_id=article.id attachment_id=attachment.id revision_id=revision.id %}" class="btn btn-primary">
<span class="fa fa-download"></span>
{% trans "Download" %}
</a>
{% if revision.attachment.article|can_write:user %}
<button{% if revision == attachment.current_revision %} disabled="disabled"{% endif %} class="btn btn-secondary">
<span class="fa fa-flag"></span>
{% trans "Use this!" %}
</button>
{% endif %}
</form>
</td>
</tr>
{% endfor %}
</table>
<a href="{% url 'wiki:attachments_index' path=urlpath.path article_id=article.id %}"><span class="fa fa-arrow-left"></span> {% trans "Go back" %}</a>
{% endblock %}

View File

@@ -0,0 +1,166 @@
{% extends "wiki/article.html" %}
{% load wiki_tags i18n humanize %}
{% block wiki_pagetitle %}{% trans "Attachments" %}: {{ article.current_revision.title }}{% endblock %}
{% block wiki_contents_tab %}
<div class="row">
<div class="col-lg-8">
<p class="lead">{% trans "The following files are available for this article. Copy the markdown tag to directly refer to a file from the article text." %}</p>
<p> {% blocktrans trimmed %}
Complete markdown code syntax: <code>[attachment:id title:"text" size]</code><br>
&nbsp;&nbsp;title: Link text replacement for the file name.
size: Show file size after the title.
{% endblocktrans %} </p>
{% for attachment in attachments %}
<table class="table table-bordered table-striped">
<tr>
<th colspan="4">
<h4 style="margin-top:0;">
<a href="{% url 'wiki:attachments_download' path=urlpath.path article_id=article.id attachment_id=attachment.id %}">{{ attachment.current_revision.get_filename }}</a>
<span class="badge badge-dark">{{ attachment.current_revision.created|naturaltime }}</span>
{% if attachment.current_revision.deleted %}
<span class="badge badge-important">{% trans "deleted" %}</span>
{% endif %}
</h4>
{{ attachment.current_revision.description }}
</th>
</tr>
<tr>
<th style="width: 25%">{% trans "Markdown tag" %}</th>
<th style="width: 25%">{% trans "Uploaded by" %}</th>
<th style="width: 25%">{% trans "Size" %}</th>
<td style="width: 25%; text-align: right; white-space: nowrap;" rowspan="2">
{% if attachment|can_write:user %}
{% if not attachment.current_revision.deleted %}
<a href="{% url 'wiki:attachments_replace' path=urlpath.path article_id=article.id attachment_id=attachment.id %}" class="btn btn-secondary btn-sm">{% trans "Replace" %}</a>
{% if attachment.article == article %}
<a href="{% url 'wiki:attachments_delete' path=urlpath.path article_id=article.id attachment_id=attachment.id %}" class="btn btn-secondary btn-sm">{% trans "Delete" %}</a>
{% else %}
<a href="{% url 'wiki:attachments_delete' path=urlpath.path article_id=article.id attachment_id=attachment.id %}" class="btn btn-secondary btn-sm">{% trans "Detach" %}</a>
{% endif %}
{% else %}
Deleted
{% endif %}
{% endif %}
<p style="margin-top: 10px; margin-bottom:0;">
<a href="{% url 'wiki:attachments_history' path=urlpath.path article_id=article.id attachment_id=attachment.id %}">
<span class="fa fa-clock"></span>
{% trans "File history" %} ({{ attachment.attachmentrevision_set.all.count }} {% trans "revisions" %})
</a>
</p>
</td>
</tr>
<tr>
<td><code>[attachment:{{ attachment.id }}]</code></td>
<td>
{% include "wiki/includes/revision_info.html" with revision=attachment.current_revision hidedate=1 hidenumber=1 %}
</td>
<td>{{ attachment.current_revision.get_size|filesizeformat }}</td>
</tr>
</table>
{% empty %}
<p style="margin-bottom: 20px;"><em>{% trans "There are no attachments for this article." %}</em></p>
{% endfor %}
</div>
<div class="col-lg-4" style="min-width: 330px;">
{% if article|can_write:user and not article|is_locked %}
<div class="card">
<div class="card-header pb-0">
<a class="card-toggle" href="#collapse_upload" data-toggle="collapse">
<p class="card-title"><span class="fa fa-fw fa-upload"></span> {% trans "Upload new file" %}</p>
</a>
</div>
<div id="collapse_upload" class="card-collapse collapse{% if form.errors %} show{% endif %}">
<div class="card-body">
{% if anonymous_disallowed %}
{% include "wiki/includes/anonymous_blocked.html" %}
{% else %}
<form method="POST" class="form-vertical" id="attachment_form" enctype="multipart/form-data">
{% wiki_form form %}
<button type="submit" name="save" value="1" class="btn btn-primary">
{% trans "Upload file" %}
</button>
</form>
{% endif %}
</div>
</div>
</div>
<div class="card">
<div class="card-header pb-0">
<a class="card-toggle" data-toggle="collapse" href="#collapse_add">
<p class="card-title"><span class="fa fa-fw fa-plus-circle"></span> {% trans "Search and add file" %}</p>
</a>
</div>
<div id="collapse_add" class="card-collapse collapse">
<div class="card-body">
<p>{% trans "You can reuse files from other articles. These files are subject to updates on other articles which may or may not be a good thing." %}</p>
<form method="GET" action="{% url 'wiki:attachments_search' path=urlpath.path article_id=article.id %}" class="wiki-form-block">
<div class="input-group">
{{ search_form.query }}
<span class="input-group-btn">
<button class="btn btn-secondary" type="submit">
<span class="fa fa-search"></span>
</button>
</span>
</div><!-- /input-group -->
</form>
</div>
</div>
</div>
{% if article|can_write:user %}
<div class="card">
<div class="card-header pb-0">
<a class="card-toggle" data-toggle="collapse" href="#collapse_restore">
<p class="card-title"><span class="fa fa-fw fa-trash"></span> {% trans "Restore attachments" %}</p>
</a>
</div>
<div id="collapse_restore" class="card-collapse collapse">
<div class="card-body">
{% for attachment in deleted_attachments %}
<p>
{{ attachment.current_revision.get_filename }}
{% if attachment.current_revision.previous_revision.id %}
<form method="POST" action="{% url 'wiki:attachments_revision_change' path=urlpath.path article_id=article.id attachment_id=attachment.id revision_id=attachment.current_revision.previous_revision.id %}" style="margin:0; padding:0;">
{% csrf_token %}
<button class="btn btn-secondary">
{% trans "Restore" %}
</button>
</form>
{% endif %}
</p>
{% empty %}
<p><em>{% trans "Nothing to restore" %}</em></p>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% else %}
{% if article|is_locked %}
<p class="muted">
<i class="fa fa-lock"></i>
{% trans "The article is currently locked for editing, and therefore no new attachments can be added." %}
</p>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% load i18n %}
{% comment %}
Render an attachment to HTML in the markdown extension
{% endcomment %}
{% if not attachment_can_read %}
<em>{% trans "This attachment is not permitted on this page." %}</em>
{% else %}
<span class="attachment"><a href="{{ url }}" title="{% trans "Click to download" %} {{ filename }}">
{{ title }}{% if size %} [{{ size|filesizeformat }}]{% endif %}
</a>
</span>
{% endif %}

View File

@@ -0,0 +1,49 @@
{% extends "wiki/article.html" %}
{% load wiki_tags i18n humanize %}
{% block wiki_pagetitle %}{% trans "Replace" %} "{{ attachment.current_revision.get_filename }}"{% endblock %}
{% block wiki_contents_tab %}
<h2>{% trans "Replace" %} "{{ attachment.current_revision.get_filename }}"</h2>
{% if attachment.articles.count > 1 %}
<p class="lead">
{% blocktrans with attachment.original_filename as filename trimmed %}
Replacing an attachment means adding a new file that will be used in its place. All references to the file will be replaced by the one you upload and the file will be downloaded as <strong>{{ filename }}</strong>. Please note that this attachment is in use on other articles, you may distort contents. However, do not hestitate to take advantage of this and make replacements for the listed articles where necessary. This way of working is more efficient....
{% endblocktrans %}
</p>
<h3>
{% blocktrans with attachment.current_revision.get_filename as filename trimmed %}
Articles using {{ filename }}
{% endblocktrans %}</h3>
<ul>
{% for a in attachment.articles.all %}<li>{{ a.current_revision.title }}</li>{% endfor %}
</ul>
<hr />
{% else %}
<p class="lead">
{% blocktrans with attachment.original_filename as filename trimmed %}
Replacing an attachment means adding a new file that will be used in its place. All references to the file will be replaced by the one you upload and the file will be downloaded as <strong>{{ filename }}</strong>.
{% endblocktrans %}
</p>
{% endif %}
<form method="POST" class="form-horizontal" id="attachment_form" enctype="multipart/form-data">
{% wiki_form form %}
<div class="form-group form-actions">
<div class="col-lg-2"></div>
<div class="col-lg-10">
<a href="{% url 'wiki:attachments_index' path=urlpath.path article_id=article.id %}" class="btn btn-secondary">
<span class="fa fa-arrow-left"></span>
{% trans "Go back" %}
</a>
<button class="btn btn-primary">
<span class="fa fa-upload"></span>
{% trans "Upload replacement" %}
</button>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "wiki/article.html" %}
{% load wiki_tags i18n humanize %}
{% block wiki_pagetitle %}{% trans "Add file to" %} "{{ article.current_revision.title }}"{% endblock %}
{% block wiki_contents_tab %}
<h2>{% trans "Add attachment from other article" %}</h2>
<form method="GET" action="{% url 'wiki:attachments_search' path=urlpath.path article_id=article.id %}" class="form-search">
<div class="input-group">
{{ search_form.query }}
<span class="input-group-btn">
<button class="btn btn-secondary">
<span class="fa fa-search"></span>
{% trans "Search files and articles" %}
</button>
</span>
</div>
</form>
{% if attachments %}
<table class="table table-striped table-bordered">
<tr>
<th>{% trans "File" %}</th>
<th>{% trans "Main article" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Uploaded by" %}</th>
<th>{% trans "Size" %}</th>
<th style="text-align: right">{% trans "Action" %}</th>
</tr>
{% for attachment in attachments %}
<tr>
<td>
<h4>{{ attachment.original_filename }}</h4>
{{ attachment.current_revision.description|default:_("<em>No description</em>")|safe }}
</td>
<td>
<strong>{{ attachment.article.current_revision.title }}</strong>
</td>
<td>
{{ attachment.current_revision.created }}
{% if attachment.current_revision.deleted %}<span class="badge badge-important">{% trans "deleted" %}</span>{% endif %}
</td>
<td>
{% include "wiki/includes/revision_info.html" with revision=attachment.current_revision hidedate=1 hidenumber=1 %}
</td>
<td>{{ attachment.current_revision.get_size|filesizeformat }}</td>
<td style="text-align: right">
<form method="POST" action="{% url 'wiki:attachments_add' path=urlpath.path article_id=article.id attachment_id=attachment.id %}">
{% csrf_token %}
<a href="{% url 'wiki:attachments_download' path=urlpath.path article_id=article.id attachment_id=attachment.id %}" class="btn btn-secondary">
<span class="fa fa-download"></span>
{% trans "Download" %}
</a>
<button class="btn btn-primary">
<span class="fa fa-plus-circle"></span>
{% trans "Add to article" %}
</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p><em>{% trans "Your search did not return any results" %}</em></p>
{% endif %}
{% with query as appended_value and "query" as appended_key %}
{% include "wiki/includes/pagination.html" %}
{% endwith %}
<p>
<a href="{% url 'wiki:attachments_index' path=urlpath.path article_id=article.id %}"><span class="fa fa-arrow-left"></span> {% trans "Go back" %}</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,46 @@
from django.urls import re_path
from wiki.plugins.attachments import views
urlpatterns = [
re_path(r"^$", views.AttachmentView.as_view(), name="attachments_index"),
re_path(
r"^search/$",
views.AttachmentSearchView.as_view(),
name="attachments_search",
),
re_path(
r"^add/(?P<attachment_id>[0-9]+)/$",
views.AttachmentAddView.as_view(),
name="attachments_add",
),
re_path(
r"^replace/(?P<attachment_id>[0-9]+)/$",
views.AttachmentReplaceView.as_view(),
name="attachments_replace",
),
re_path(
r"^history/(?P<attachment_id>[0-9]+)/$",
views.AttachmentHistoryView.as_view(),
name="attachments_history",
),
re_path(
r"^download/(?P<attachment_id>[0-9]+)/$",
views.AttachmentDownloadView.as_view(),
name="attachments_download",
),
re_path(
r"^delete/(?P<attachment_id>[0-9]+)/$",
views.AttachmentDeleteView.as_view(),
name="attachments_delete",
),
re_path(
r"^download/(?P<attachment_id>[0-9]+)/revision/(?P<revision_id>[0-9]+)/$",
views.AttachmentDownloadView.as_view(),
name="attachments_download",
),
re_path(
r"^change/(?P<attachment_id>[0-9]+)/revision/(?P<revision_id>[0-9]+)/$",
views.AttachmentChangeRevisionView.as_view(),
name="attachments_revision_change",
),
]

View File

@@ -0,0 +1,458 @@
from django.contrib import messages
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from django.http import Http404
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.generic import FormView
from django.views.generic import ListView
from django.views.generic import TemplateView
from django.views.generic import View
from wiki.core.http import send_file
from wiki.core.paginator import WikiPaginator
from wiki.decorators import get_article
from wiki.decorators import response_forbidden
from wiki.plugins.attachments import forms
from wiki.plugins.attachments import models
from wiki.plugins.attachments import settings
from wiki.views.mixins import ArticleMixin
class AttachmentView(ArticleMixin, FormView):
form_class = forms.AttachmentForm
template_name = "wiki/plugins/attachments/index.html"
@method_decorator(get_article(can_read=True))
def dispatch(self, request, article, *args, **kwargs):
if article.can_moderate(request.user):
self.attachments = (
models.Attachment.objects.filter(
articles=article, current_revision__deleted=False
)
.exclude(current_revision__file=None)
.order_by("original_filename")
)
self.form_class = forms.AttachmentArchiveForm
else:
self.attachments = models.Attachment.objects.active().filter(
articles=article
)
# Fixing some weird transaction issue caused by adding commit_manually
# to form_valid
return super().dispatch(request, article, *args, **kwargs)
def form_valid(self, form):
if (
self.request.user.is_anonymous
and not settings.ANONYMOUS
or not self.article.can_write(self.request.user)
or self.article.current_revision.locked
):
return response_forbidden(self.request, self.article, self.urlpath)
attachment_revision = form.save()
if isinstance(attachment_revision, list):
messages.success(
self.request,
_("Successfully added: %s")
% (
", ".join(
[ar.get_filename() for ar in attachment_revision]
)
),
)
else:
messages.success(
self.request,
_("%s was successfully added.")
% attachment_revision.get_filename(),
)
self.article.clear_cache()
return redirect(
"wiki:attachments_index",
path=self.urlpath.path,
article_id=self.article.id,
)
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["article"] = self.article
kwargs["request"] = self.request
return kwargs
def get_context_data(self, **kwargs):
# Needed since Django 1.9 because get_context_data is no longer called
# with the form instance
if "form" not in kwargs:
kwargs["form"] = self.get_form()
kwargs["attachments"] = self.attachments
kwargs["deleted_attachments"] = models.Attachment.objects.filter(
articles=self.article, current_revision__deleted=True
)
kwargs["search_form"] = forms.SearchForm()
kwargs["selected_tab"] = "attachments"
kwargs["anonymous_disallowed"] = (
self.request.user.is_anonymous and not settings.ANONYMOUS
)
return super().get_context_data(**kwargs)
class AttachmentHistoryView(ArticleMixin, TemplateView):
template_name = "wiki/plugins/attachments/history.html"
@method_decorator(get_article(can_read=True))
def dispatch(self, request, article, attachment_id, *args, **kwargs):
if article.can_moderate(request.user):
self.attachment = get_object_or_404(
models.Attachment, id=attachment_id, articles=article
)
else:
self.attachment = get_object_or_404(
models.Attachment.objects.active(),
id=attachment_id,
articles=article,
)
return super().dispatch(request, article, *args, **kwargs)
def get_context_data(self, **kwargs):
kwargs["attachment"] = self.attachment
kwargs[
"revisions"
] = self.attachment.attachmentrevision_set.all().order_by(
"-revision_number"
)
kwargs["selected_tab"] = "attachments"
return super().get_context_data(**kwargs)
class AttachmentReplaceView(ArticleMixin, FormView):
form_class = forms.AttachmentForm
template_name = "wiki/plugins/attachments/replace.html"
@method_decorator(get_article(can_write=True, not_locked=True))
def dispatch(self, request, article, attachment_id, *args, **kwargs):
if request.user.is_anonymous and not settings.ANONYMOUS:
return response_forbidden(
request, article, kwargs.get("urlpath", None)
)
if article.can_moderate(request.user):
self.attachment = get_object_or_404(
models.Attachment, id=attachment_id, articles=article
)
self.can_moderate = True
else:
self.attachment = get_object_or_404(
models.Attachment.objects.active(),
id=attachment_id,
articles=article,
)
self.can_moderate = False
return super().dispatch(request, article, *args, **kwargs)
def get_form_class(self):
if self.can_moderate:
return forms.AttachmentReplaceForm
else:
return forms.AttachmentForm
def form_valid(self, form):
try:
attachment_revision = form.save(commit=True)
attachment_revision.set_from_request(self.request)
attachment_revision.previous_revision = (
self.attachment.current_revision
)
attachment_revision.save()
self.attachment.current_revision = attachment_revision
self.attachment.save()
messages.success(
self.request,
_("%s uploaded and replaces old attachment.")
% attachment_revision.get_filename(),
)
self.article.clear_cache()
except models.IllegalFileExtension as e:
messages.error(
self.request, _("Your file could not be saved: %s") % e
)
return redirect(
"wiki:attachments_replace",
attachment_id=self.attachment.id,
path=self.urlpath.path,
article_id=self.article.id,
)
if self.can_moderate:
if form.cleaned_data["replace"]:
# form has no cleaned_data field unless self.can_moderate is True
try:
most_recent_revision = (
self.attachment.attachmentrevision_set.exclude(
id=attachment_revision.id,
created__lte=attachment_revision.created,
).latest()
)
most_recent_revision.delete()
except ObjectDoesNotExist:
msg = (
"{attachment} does not contain any revisions.".format(
attachment=str(self.attachment.original_filename)
)
)
messages.error(self.request, msg)
return redirect(
"wiki:attachments_index",
path=self.urlpath.path,
article_id=self.article.id,
)
def get_form(self, form_class=None):
form = super().get_form(form_class=form_class)
form.fields["file"].help_text = _(
"Your new file will automatically be renamed to match the file already present. Files with different extensions are not allowed."
)
return form
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["article"] = self.article
kwargs["request"] = self.request
kwargs["attachment"] = self.attachment
return kwargs
def get_initial(self, **kwargs):
return {"description": self.attachment.current_revision.description}
def get_context_data(self, **kwargs):
if "form" not in kwargs:
kwargs["form"] = self.get_form()
kwargs["attachment"] = self.attachment
kwargs["selected_tab"] = "attachments"
return super().get_context_data(**kwargs)
class AttachmentDownloadView(ArticleMixin, View):
@method_decorator(get_article(can_read=True))
def dispatch(self, request, article, attachment_id, *args, **kwargs):
if article.can_moderate(request.user):
self.attachment = get_object_or_404(
models.Attachment, id=attachment_id, articles=article
)
else:
self.attachment = get_object_or_404(
models.Attachment.objects.active(),
id=attachment_id,
articles=article,
)
revision_id = kwargs.get("revision_id", None)
if revision_id:
self.revision = get_object_or_404(
models.AttachmentRevision,
id=revision_id,
attachment__articles=article,
)
else:
self.revision = self.attachment.current_revision
return super().dispatch(request, article, *args, **kwargs)
def get(self, request, *args, **kwargs):
if self.revision:
if settings.USE_LOCAL_PATH:
try:
return send_file(
request,
self.revision.file.path,
self.revision.created,
self.attachment.original_filename,
)
except OSError:
pass
else:
return HttpResponseRedirect(self.revision.file.url)
raise Http404
class AttachmentChangeRevisionView(ArticleMixin, View):
form_class = forms.AttachmentForm
template_name = "wiki/plugins/attachments/replace.html"
@method_decorator(get_article(can_write=True, not_locked=True))
def dispatch(
self, request, article, attachment_id, revision_id, *args, **kwargs
):
if article.can_moderate(request.user):
self.attachment = get_object_or_404(
models.Attachment, id=attachment_id, articles=article
)
else:
self.attachment = get_object_or_404(
models.Attachment.objects.active(),
id=attachment_id,
articles=article,
)
self.revision = get_object_or_404(
models.AttachmentRevision,
id=revision_id,
attachment__articles=article,
)
return super().dispatch(request, article, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.attachment.current_revision = self.revision
self.attachment.save()
self.article.clear_cache()
messages.success(
self.request,
_("Current revision changed for %s.")
% self.attachment.original_filename,
)
return redirect(
"wiki:attachments_index",
path=self.urlpath.path,
article_id=self.article.id,
)
def get_context_data(self, **kwargs):
kwargs["selected_tab"] = "attachments"
if "form" not in kwargs:
kwargs["form"] = self.get_form()
return ArticleMixin.get_context_data(self, **kwargs)
class AttachmentAddView(ArticleMixin, View):
@method_decorator(get_article(can_write=True, not_locked=True))
def dispatch(self, request, article, attachment_id, *args, **kwargs):
self.attachment = get_object_or_404(
models.Attachment.objects.active().can_write(request.user),
id=attachment_id,
)
return super().dispatch(request, article, *args, **kwargs)
def post(self, request, *args, **kwargs):
if not self.attachment.articles.filter(id=self.article.id):
self.attachment.articles.add(self.article)
self.attachment.save()
self.article.clear_cache()
messages.success(
self.request,
_('Added a reference to "%(att)s" from "%(art)s".')
% {
"att": self.attachment.original_filename,
"art": self.article.current_revision.title,
},
)
else:
messages.error(
self.request,
_('"%(att)s" is already referenced.')
% {"att": self.attachment.original_filename},
)
return redirect(
"wiki:attachments_index",
path=self.urlpath.path,
article_id=self.article.id,
)
class AttachmentDeleteView(ArticleMixin, FormView):
form_class = forms.DeleteForm
template_name = "wiki/plugins/attachments/delete.html"
@method_decorator(get_article(can_write=True, not_locked=True))
def dispatch(self, request, article, attachment_id, *args, **kwargs):
self.attachment = get_object_or_404(
models.Attachment, id=attachment_id, articles=article
)
if not self.attachment.can_delete(request.user):
return response_forbidden(
request, article, kwargs.get("urlpath", None)
)
return super().dispatch(request, article, *args, **kwargs)
def form_valid(self, form):
if self.attachment.article == self.article:
revision = models.AttachmentRevision()
revision.attachment = self.attachment
revision.set_from_request(self.request)
revision.deleted = True
revision.file = (
self.attachment.current_revision.file
if self.attachment.current_revision
else None
)
revision.description = (
self.attachment.current_revision.description
if self.attachment.current_revision
else ""
)
revision.save()
self.attachment.current_revision = revision
self.attachment.save()
self.article.clear_cache()
messages.info(
self.request,
_("The file %s was deleted.")
% self.attachment.original_filename,
)
else:
self.attachment.articles.remove(self.article)
messages.info(
self.request,
_("This article is no longer related to the file %s.")
% self.attachment.original_filename,
)
self.article.clear_cache()
return redirect(
"wiki:get", path=self.urlpath.path, article_id=self.article.id
)
def get_context_data(self, **kwargs):
kwargs["attachment"] = self.attachment
kwargs["selected_tab"] = "attachments"
if "form" not in kwargs:
kwargs["form"] = self.get_form()
return super().get_context_data(**kwargs)
class AttachmentSearchView(ArticleMixin, ListView):
template_name = "wiki/plugins/attachments/search.html"
allow_empty = True
context_object_name = "attachments"
paginator_class = WikiPaginator
paginate_by = 10
@method_decorator(get_article(can_write=True))
def dispatch(self, request, article, *args, **kwargs):
return super().dispatch(request, article, *args, **kwargs)
def get_queryset(self):
self.query = self.request.GET.get("query", None)
if not self.query:
qs = models.Attachment.objects.none()
else:
qs = models.Attachment.objects.active().can_read(self.request.user)
qs = qs.filter(
Q(original_filename__contains=self.query)
| Q(current_revision__description__contains=self.query)
| Q(article__current_revision__title__contains=self.query)
)
return qs.order_by("original_filename")
def get_context_data(self, **kwargs):
# Is this a bit of a hack? Use better inheritance?
kwargs_article = ArticleMixin.get_context_data(self, **kwargs)
kwargs_listview = ListView.get_context_data(self, **kwargs)
kwargs["search_form"] = forms.SearchForm(self.request.GET)
kwargs["query"] = self.query
kwargs.update(kwargs_article)
kwargs.update(kwargs_listview)
kwargs["selected_tab"] = "attachments"
return kwargs

View File

@@ -0,0 +1,43 @@
from django.urls import include
from django.urls import re_path
from django.utils.translation import gettext as _
from wiki.core.plugins import registry
from wiki.core.plugins.base import BasePlugin
from wiki.plugins.attachments import models
from wiki.plugins.attachments import settings
from wiki.plugins.attachments import views
from wiki.plugins.attachments.markdown_extensions import AttachmentExtension
from wiki.plugins.notifications.settings import ARTICLE_EDIT
from wiki.plugins.notifications.util import truncate_title
class AttachmentPlugin(BasePlugin):
slug = settings.SLUG
urlpatterns = {
"article": [re_path("", include("wiki.plugins.attachments.urls"))]
}
article_tab = (_("Attachments"), "fa fa-file")
article_view = views.AttachmentView().dispatch
# List of notifications to construct signal handlers for. This
# is handled inside the notifications plugin.
notifications = [
{
"model": models.AttachmentRevision,
"message": lambda obj: (
_("A file was changed: %s")
if not obj.deleted
else _("A file was deleted: %s")
)
% truncate_title(obj.get_filename()),
"key": ARTICLE_EDIT,
"created": True,
"get_article": lambda obj: obj.attachment.article,
}
]
markdown_extensions = [AttachmentExtension()]
registry.register(AttachmentPlugin)

View File

@@ -0,0 +1 @@
default_app_config = "wiki.plugins.editsection.apps.EditSectionConfig"

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class EditSectionConfig(AppConfig):
name = "wiki.plugins.editsection"
verbose_name = _("Wiki edit section")
label = "wiki_editsection"

View File

@@ -0,0 +1,179 @@
import logging
import re
from xml.etree import ElementTree as etree
from django.urls import reverse
from markdown import Extension
from markdown.blockprocessors import HashHeaderProcessor
from markdown.blockprocessors import SetextHeaderProcessor
from markdown.treeprocessors import Treeprocessor
from wiki.core.markdown import add_to_registry
from wiki.plugins.macros.mdx.toc import wiki_slugify
from . import settings
logger = logging.getLogger("MARKDOWN")
class CustomHashHeaderProcessor(HashHeaderProcessor):
"""
Custom HashHeaderProcessor. The only difference to the upstream
processor is that we set the data-block-source attribute on any
inserted header.
"""
def run(self, parent, blocks):
block = blocks.pop(0)
m = self.RE.search(block)
if m:
before = block[: m.start()] # All lines before header
after = block[m.end() :] # All lines after header
if before:
# As the header was not the first line of the block and the
# lines before the header must be parsed first,
# recursively parse this lines as a block.
self.parser.parseBlocks(parent, [before])
# Create header using named groups from RE
h = etree.SubElement(parent, "h%d" % len(m.group("level")))
h.text = m.group("header").strip()
h.attrib["data-block-source"] = m.group().strip()
if after:
# Insert remaining lines as first block for future parsing.
blocks.insert(0, after)
else: # pragma: no cover
# This should never happen, but just in case...
logger.warning("We've got a problem header: %r" % block)
class CustomSetextHeaderProcessor(SetextHeaderProcessor):
"""
Custom SetextHeaderProcessor. The only difference to the upstream
processor is that we set the data-block-source attribute on any
inserted header.
"""
def run(self, parent, blocks):
lines = blocks.pop(0).split("\n")
# Determine level. ``=`` is 1 and ``-`` is 2.
if lines[1].startswith("="):
level = 1
else:
level = 2
h = etree.SubElement(parent, "h%d" % level)
h.text = lines[0].strip()
h.attrib["data-block-source"] = "\r\n".join(lines)
if len(lines) > 2:
# Block contains additional lines. Add to master blocks for later.
blocks.insert(0, "\n".join(lines[2:]))
class EditSectionExtension(Extension):
def __init__(self, *args, **kwargs):
self.config = {
"level": [
settings.MAX_LEVEL,
"Allow to edit sections until this level",
]
}
super().__init__(**kwargs)
def extendMarkdown(self, md):
# replace HashHeader/SetextHeader processors with our custom variants
md.parser.blockprocessors.register(
CustomHashHeaderProcessor(md.parser), "hashheader", 70
)
md.parser.blockprocessors.register(
CustomSetextHeaderProcessor(md.parser), "setextheader", 60
)
# the tree processor adds the actual edit links
add_to_registry(
md.treeprocessors,
"editsection",
EditSectionProcessor(self.config, md),
"_end",
)
class EditSectionProcessor(Treeprocessor):
"""
TreeProcessor adds the edit links for every header which has a data-block-source attribute
"""
def __init__(self, config, md=None):
self.config = config
self.slugs = {} # keep found slugs (to ensure uniqueness)
self.source = None # will be set in run()
self.last_start = 0 # location of last inserted edit link
super().__init__(md)
def ensure_unique_id(self, node):
"""ensures that node has a unique id, preferring an already existing id"""
if "id" in node.attrib:
slug = node.attrib["id"]
else:
content = node.text.strip()
slug = wiki_slugify(content, "-", unicode=True)
candidate = slug
i = 1
while candidate in self.slugs:
candidate = f"{slug}_{i}"
i += 1
self.slugs[candidate] = True
node.attrib["id"] = candidate
return candidate
def add_links(self, node):
headers = []
for child in list(node):
match = self.HEADER_RE.match(child.tag.lower())
if not match:
continue
level = match.group(1)
if "data-block-source" in child.attrib:
source_block = child.attrib["data-block-source"]
del child.attrib["data-block-source"]
# locate in document source
start = self.source.find(source_block, self.last_start)
if start == -1:
# not found in source, ignore
continue
self.last_start = start + 1
# ensure that the node has a unique id
slug = self.ensure_unique_id(child)
# Insert link to allow editing this section
link = etree.SubElement(child, "a")
link.text = settings.LINK_TEXT
link.attrib["class"] = "article-edit-title-link"
# Build the URL
url_kwargs = self.md.article.get_url_kwargs()
url_kwargs["header"] = child.attrib["id"]
link.attrib["href"] = reverse(
"wiki:editsection", kwargs=url_kwargs
)
headers.append(
{
"slug": slug,
"position": start,
"level": level,
"source": source_block,
}
)
return headers
def run(self, root):
self.level = self.config.get("level")[0]
self.article = self.md.article
self.source = self.md.source
self.HEADER_RE = re.compile(
"^h([" + "".join(map(str, range(1, self.level + 1))) + "])"
)
headers = self.add_links(root)
# store found headers at article, for use in edit view
self.article._found_headers = headers
return root

View File

@@ -0,0 +1,14 @@
from django.conf import settings as django_settings
from django.utils.translation import gettext
SLUG = "editsection"
#: Add "[edit]" links to all section headers till this level. By using
#: these links editing only the text from the selected section is possible.
MAX_LEVEL = getattr(django_settings, "WIKI_EDITSECTION_MAX_LEVEL", 3)
#: Text used for the section edit links which will appear next to section
#: headers. These links allow editing only the text of one particular section.
LINK_TEXT = getattr(
django_settings, "WIKI_EDITSECTION_LINK_TEXT", gettext("[edit]")
)

View File

@@ -0,0 +1,135 @@
from django.contrib import messages
from django.shortcuts import get_object_or_404
from django.shortcuts import redirect
from django.utils.decorators import method_decorator
from django.utils.translation import gettext_lazy
from wiki import models
from wiki.core.markdown import article_markdown
from wiki.decorators import get_article
from wiki.views.article import Edit as EditView
ERROR_SECTION_CHANGED = gettext_lazy(
"Unable to find the selected section. The article was modified meanwhile."
)
ERROR_SECTION_UNSAVED = gettext_lazy(
"Your changes must be re-applied in the new section structure of the "
"article."
)
ERROR_ARTICLE_CHANGED = gettext_lazy(
"Unable to find the selected section in the current article. The article "
"was changed in between. Your changed section is still available as the "
"last now inactive revision of this article."
)
ERROR_TRY_AGAIN = gettext_lazy("Please try again.")
class EditSection(EditView):
def locate_section(self, article, content):
"""
locate the section to be edited, returning index of start and end
"""
# render article to get the headers
article_markdown(content, article)
headers = getattr(article, "_found_headers", [])
# find start
start, end = None, None
while len(headers):
header = headers.pop(0)
if header["slug"] == self.header_id:
if content[header["position"] :].startswith(header["source"]):
start = header
break
if start is None:
# start section not found
return None, None
# we have the beginning, now find next section with same or higher level
while len(headers):
header = headers.pop(0)
if header["level"] <= start["level"]:
if content[header["position"] :].startswith(header["source"]):
end = header
break
else:
# there should be a matching header, but we did not find it.
# better be safe.
return None, None
return (
(start["position"], end["position"])
if end
else (start["position"], len(content))
)
def _redirect_to_article(self):
if self.urlpath:
return redirect("wiki:get", path=self.urlpath.path)
return redirect("wiki:get", article_id=self.article.id)
@method_decorator(get_article(can_write=True, not_locked=True))
def dispatch(self, request, article, *args, **kwargs):
self.header_id = kwargs.pop("header", 0)
self.urlpath = kwargs.get("urlpath")
kwargs["path"] = self.urlpath.path
content = article.current_revision.content
if request.method == "GET":
start, end = self.locate_section(article, content)
if start is not None and end is not None:
self.orig_section = content[start:end]
kwargs["content"] = self.orig_section
request.session["editsection_content"] = self.orig_section
else:
messages.error(
request,
f"{ERROR_SECTION_CHANGED} {ERROR_TRY_AGAIN}",
)
return self._redirect_to_article()
else:
kwargs["content"] = request.session.get("editsection_content")
self.orig_section = kwargs.get("content")
return super().dispatch(request, article, *args, **kwargs)
def form_valid(self, form):
super().form_valid(form)
section = self.article.current_revision.content
if not section.endswith("\n"):
section += "\r\n\r\n"
content = get_object_or_404(
models.ArticleRevision,
article=self.article,
id=self.article.current_revision.previous_revision.id,
).content
start, end = self.locate_section(self.article, content)
if start is not None and end is not None:
# compare saved original section with last version, so we
# can detect if someone else changed it in the meantime
if self.orig_section != content[start:end]:
messages.warning(
self.request,
"{} {} {}".format(
ERROR_SECTION_CHANGED,
ERROR_SECTION_UNSAVED,
ERROR_TRY_AGAIN,
),
)
# Include the edited section into the complete previous article
self.article.current_revision.content = (
content[0:start] + section + content[end:]
)
self.article.current_revision.save()
else:
# Back to the version before replacing the article with the section
self.article.current_revision = (
self.article.current_revision.previous_revision
)
self.article.save()
messages.error(
self.request,
f"{ERROR_ARTICLE_CHANGED} {ERROR_TRY_AGAIN}",
)
return self._redirect_to_article()

View File

@@ -0,0 +1,25 @@
from django.urls import re_path as url
from wiki.core.plugins import registry
from wiki.core.plugins.base import BasePlugin
from wiki.plugins.editsection.markdown_extensions import EditSectionExtension
from . import settings
from . import views
class EditSectionPlugin(BasePlugin):
slug = settings.SLUG
urlpatterns = {
"article": [
url(
r"^header/(?P<header>[\S]+)/$",
views.EditSection.as_view(),
name="editsection",
),
]
}
markdown_extensions = [EditSectionExtension()]
registry.register(EditSectionPlugin)

View File

@@ -0,0 +1 @@
default_app_config = "wiki.plugins.globalhistory.apps.GlobalHistoryConfig"

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class GlobalHistoryConfig(AppConfig):
name = "wiki.plugins.globalhistory"
verbose_name = _("Wiki Global History")
label = "wiki_globalhistory"

View File

View File

View File

@@ -0,0 +1 @@
SLUG = "globalhistory"

View File

@@ -0,0 +1,116 @@
{% extends "wiki/base.html" %}
{% load wiki_tags i18n humanize %}
{% block wiki_pagetitle %}{% trans "Global history" %}{% endblock %}
{% block wiki_contents %}
<h1>{% trans "Global history" %}</h1>
{% spaceless %}
<div class="row">
<div class="lead col-xs-8">
{% with paginator.object_list.count as cnt %}
{% blocktrans count cnt=cnt trimmed %}
List of <strong>{{ cnt }} change</strong> in the wiki.
{% plural %}
List of <strong>{{ cnt }} changes</strong> in the wiki.
{% endblocktrans %}
{% endwith %}
</div>
<div class="float-right">
<a class="btn btn-secondary"
{% if only_last == "1" %}
href="{% url 'wiki:globalhistory' only_last=0 %}">
{% trans "Show all revisions of all articles" %}
{% else %}
href="{% url 'wiki:globalhistory' only_last=1 %}">
{% trans "Show last revision of every article" %}
{% endif %}
</a>
</div>
</div>
<div class="row">
{% if revisions %}
<table class="table table-striped table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Revision ID" %}</th>
<th>{% trans "Article" %}</th>
<th>{% trans "Message" %}</th>
<th>{% trans "User" %}</th>
<th>{% trans "Date" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for article_revision in revisions %}
<tr>
<td>
{{ article_revision.revision_number }}
</td>
<td>
<a href="{% url 'wiki:get' article_revision.article.pk %}">
{{ article_revision.article }}
</a>
</td>
<td>
{% if article_revision.user_message %}
{{ article_revision.user_message }}
{% elif article_revision.automatic_log %}
{{ article_revision.automatic_log }}
{% else %}
({% trans "no log message" %})
{% endif %}
{% if article_revision.deleted %}
<span class="badge badge-important">{% trans "deleted" %}</span>
{% endif %}
{% if article_revision.previous_revision.deleted and not article_revision.deleted %}
<span class="badge badge-success">{% trans "restored" %}</span>
{% endif %}
{% if article_revision.locked %}
<span class="badge badge-dark">{% trans "locked" %}</span>
{% endif %}
{% if article_revision.previous_revision.locked and not revision.locked %}
<span class="badge badge-dark">{% trans "unlocked" %}</span>
{% endif %}
</td>
<td>
{% if article_revision.user %}
{{ article_revision.user }}
{% else %}
{% if article_revision.article|can_moderate:user %}
{{ article_revision.ip_address|default:"anonymous (IP not logged)" }}
{% else %}
{% trans "anonymous (IP logged)" %}i
{% endif %}
{% endif %}
</td>
<td>
{{article_revision.modified}}
</td>
<td>
<a href="{% url 'wiki:history' article_revision.article.pk %}" class="btn btn-info btn-xs">{% trans "Go to article history" %}</a>
<a href="{% url 'wiki:get' article_revision.article.pk %}" class="btn btn-primary btn-xs">{% trans "Go to article" %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info">
{% if page %}
{% trans "No more changes to display !" %}
<a href="?page={{page|add:"-1"}}">{% trans "Go back to previous page" %}</a>
{% else %}
{% trans "No changes to display !" %}
{% endif %}
</div>
{% endif %}
{% include "wiki/includes/pagination.html" %}
</div>
{% endspaceless %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More