initial
This commit is contained in:
22
src/manage.py
Normal file
22
src/manage.py
Normal 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
1
src/wiki/__about__.py
Normal file
@@ -0,0 +1 @@
|
||||
__version__ = "0.12.1"
|
||||
17
src/wiki/__init__.py
Normal file
17
src/wiki/__init__.py
Normal 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
107
src/wiki/admin.py
Normal 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
30
src/wiki/apps.py
Normal 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
128
src/wiki/checks.py
Normal 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
|
||||
0
src/wiki/conf/__init__.py
Normal file
0
src/wiki/conf/__init__.py
Normal file
315
src/wiki/conf/settings.py
Normal file
315
src/wiki/conf/settings.py
Normal 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)
|
||||
0
src/wiki/core/__init__.py
Normal file
0
src/wiki/core/__init__.py
Normal file
11
src/wiki/core/diff.py
Normal file
11
src/wiki/core/diff.py
Normal 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
|
||||
12
src/wiki/core/exceptions.py
Normal file
12
src/wiki/core/exceptions.py
Normal 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
59
src/wiki/core/http.py
Normal 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
|
||||
117
src/wiki/core/markdown/__init__.py
Normal file
117
src/wiki/core/markdown/__init__.py
Normal 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)
|
||||
0
src/wiki/core/markdown/mdx/__init__.py
Normal file
0
src/wiki/core/markdown/mdx/__init__.py
Normal file
159
src/wiki/core/markdown/mdx/codehilite.py
Normal file
159
src/wiki/core/markdown/mdx/codehilite.py
Normal 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("&", "&")
|
||||
text = text.replace("<", "<")
|
||||
text = text.replace(">", ">")
|
||||
text = text.replace(""", '"')
|
||||
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)
|
||||
28
src/wiki/core/markdown/mdx/previewlinks.py
Normal file
28
src/wiki/core/markdown/mdx/previewlinks.py
Normal 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)
|
||||
57
src/wiki/core/markdown/mdx/responsivetable.py
Normal file
57
src/wiki/core/markdown/mdx/responsivetable.py
Normal 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)
|
||||
34
src/wiki/core/paginator.py
Normal file
34
src/wiki/core/paginator.py
Normal 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
|
||||
105
src/wiki/core/permissions.py
Normal file
105
src/wiki/core/permissions.py
Normal 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")
|
||||
0
src/wiki/core/plugins/__init__.py
Normal file
0
src/wiki/core/plugins/__init__.py
Normal file
70
src/wiki/core/plugins/base.py
Normal file
70
src/wiki/core/plugins/base.py
Normal 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
|
||||
5
src/wiki/core/plugins/loader.py
Normal file
5
src/wiki/core/plugins/loader.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.utils.module_loading import autodiscover_modules
|
||||
|
||||
|
||||
def load_wiki_plugins():
|
||||
autodiscover_modules("wiki_plugin")
|
||||
77
src/wiki/core/plugins/registry.py
Normal file
77
src/wiki/core/plugins/registry.py
Normal 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
14
src/wiki/core/utils.py
Normal 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
204
src/wiki/decorators.py
Normal 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
|
||||
19
src/wiki/editors/__init__.py
Normal file
19
src/wiki/editors/__init__.py
Normal 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
26
src/wiki/editors/base.py
Normal 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 = ()
|
||||
59
src/wiki/editors/markitup.py
Normal file
59
src/wiki/editors/markitup.py
Normal 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
650
src/wiki/forms.py
Normal 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,
|
||||
)
|
||||
106
src/wiki/forms_account_handling.py
Normal file
106
src/wiki/forms_account_handling.py
Normal 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()]
|
||||
BIN
src/wiki/locale/ca/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/ca/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/cs/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/cs/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/da/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/da/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/de/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/de/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/el/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/el/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2028
src/wiki/locale/en/LC_MESSAGES/django.po
Normal file
2028
src/wiki/locale/en/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/wiki/locale/es/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/es/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/fi/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/fi/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/fr/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/fr/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/hu/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/hu/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/it/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/it/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/ja/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/ja/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/ko_KR/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/ko_KR/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/nb_NO/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/nb_NO/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/nl/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/nl/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/pl_PL/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/pl_PL/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1
src/wiki/locale/pt
Normal file
1
src/wiki/locale/pt
Normal file
@@ -0,0 +1 @@
|
||||
pt_PT
|
||||
BIN
src/wiki/locale/pt_BR/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/pt_BR/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/pt_PT/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/pt_PT/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/ro/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/ro/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/ru/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/ru/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/sk/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/sk/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/sv/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/sv/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/tr_TR/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/tr_TR/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
BIN
src/wiki/locale/zh_CN/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/zh_CN/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1
src/wiki/locale/zh_Hans
Normal file
1
src/wiki/locale/zh_Hans
Normal file
@@ -0,0 +1 @@
|
||||
zh_CN
|
||||
BIN
src/wiki/locale/zh_TW/LC_MESSAGES/django.mo
Normal file
BIN
src/wiki/locale/zh_TW/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
184
src/wiki/managers.py
Normal file
184
src/wiki/managers.py
Normal 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)
|
||||
455
src/wiki/migrations/0001_initial.py
Normal file
455
src/wiki/migrations/0001_initial.py
Normal 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,
|
||||
),
|
||||
]
|
||||
27
src/wiki/migrations/0002_urlpath_moved_to.py
Normal file
27
src/wiki/migrations/0002_urlpath_moved_to.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
42
src/wiki/migrations/0003_mptt_upgrade.py
Normal file
42
src/wiki/migrations/0003_mptt_upgrade.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
src/wiki/migrations/__init__.py
Normal file
0
src/wiki/migrations/__init__.py
Normal file
49
src/wiki/models/__init__.py
Normal file
49
src/wiki/models/__init__.py
Normal 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
510
src/wiki/models/article.py
Normal 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)
|
||||
343
src/wiki/models/pluginbase.py
Normal file
343
src/wiki/models/pluginbase.py
Normal 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
464
src/wiki/models/urlpath.py
Normal 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)
|
||||
0
src/wiki/plugins/__init__.py
Normal file
0
src/wiki/plugins/__init__.py
Normal file
1
src/wiki/plugins/attachments/__init__.py
Normal file
1
src/wiki/plugins/attachments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = "wiki.plugins.attachments.apps.AttachmentsConfig"
|
||||
16
src/wiki/plugins/attachments/admin.py
Normal file
16
src/wiki/plugins/attachments/admin.py
Normal 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)
|
||||
8
src/wiki/plugins/attachments/apps.py
Normal file
8
src/wiki/plugins/attachments/apps.py
Normal 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"
|
||||
172
src/wiki/plugins/attachments/forms.py
Normal file
172
src/wiki/plugins/attachments/forms.py
Normal 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"}),
|
||||
)
|
||||
98
src/wiki/plugins/attachments/markdown_extensions.py
Normal file
98
src/wiki/plugins/attachments/markdown_extensions.py
Normal 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
|
||||
132
src/wiki/plugins/attachments/migrations/0001_initial.py
Normal file
132
src/wiki/plugins/attachments/migrations/0001_initial.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
0
src/wiki/plugins/attachments/migrations/__init__.py
Normal file
0
src/wiki/plugins/attachments/migrations/__init__.py
Normal file
250
src/wiki/plugins/attachments/models.py
Normal file
250
src/wiki/plugins/attachments/models.py
Normal 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)
|
||||
83
src/wiki/plugins/attachments/settings.py
Normal file
83
src/wiki/plugins/attachments/settings.py
Normal 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."
|
||||
)
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
46
src/wiki/plugins/attachments/urls.py
Normal file
46
src/wiki/plugins/attachments/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
458
src/wiki/plugins/attachments/views.py
Normal file
458
src/wiki/plugins/attachments/views.py
Normal 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
|
||||
43
src/wiki/plugins/attachments/wiki_plugin.py
Normal file
43
src/wiki/plugins/attachments/wiki_plugin.py
Normal 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)
|
||||
1
src/wiki/plugins/editsection/__init__.py
Normal file
1
src/wiki/plugins/editsection/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = "wiki.plugins.editsection.apps.EditSectionConfig"
|
||||
8
src/wiki/plugins/editsection/apps.py
Normal file
8
src/wiki/plugins/editsection/apps.py
Normal 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"
|
||||
179
src/wiki/plugins/editsection/markdown_extensions.py
Normal file
179
src/wiki/plugins/editsection/markdown_extensions.py
Normal 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
|
||||
14
src/wiki/plugins/editsection/settings.py
Normal file
14
src/wiki/plugins/editsection/settings.py
Normal 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]")
|
||||
)
|
||||
135
src/wiki/plugins/editsection/views.py
Normal file
135
src/wiki/plugins/editsection/views.py
Normal 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()
|
||||
25
src/wiki/plugins/editsection/wiki_plugin.py
Normal file
25
src/wiki/plugins/editsection/wiki_plugin.py
Normal 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)
|
||||
1
src/wiki/plugins/globalhistory/__init__.py
Normal file
1
src/wiki/plugins/globalhistory/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = "wiki.plugins.globalhistory.apps.GlobalHistoryConfig"
|
||||
8
src/wiki/plugins/globalhistory/apps.py
Normal file
8
src/wiki/plugins/globalhistory/apps.py
Normal 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"
|
||||
0
src/wiki/plugins/globalhistory/forms.py
Normal file
0
src/wiki/plugins/globalhistory/forms.py
Normal file
0
src/wiki/plugins/globalhistory/models.py
Normal file
0
src/wiki/plugins/globalhistory/models.py
Normal file
1
src/wiki/plugins/globalhistory/settings.py
Normal file
1
src/wiki/plugins/globalhistory/settings.py
Normal file
@@ -0,0 +1 @@
|
||||
SLUG = "globalhistory"
|
||||
@@ -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
Reference in New Issue
Block a user