Commit 4ab97010 by Bridger Maxwell

Merge branch 'master' of git://github.com/benjaoming/django-wiki

parents c053503a b85791d5
......@@ -67,6 +67,14 @@ Please use these function calls rather than writing your own include() call - th
The above line puts the wiki in */* so it's important to put it at the end of your urlconf. You can also put it in */wiki* by putting `'^wiki/'` as the pattern.
### Settings
For now, look in [wiki/conf/settings.py](wiki/conf/settings.py) to see a list of available settings.
### Other tips
1. **Account handling:** There are simple views that handle login, logout and signup. They are on by default. Make sure to set settings.LOGIN_URL to point to your login page as many wiki views may redirect to a login page.
Plugins
------------
......
......@@ -13,11 +13,11 @@ Not implemented - will be ASAP
* Key-value meta data
* Index views for urlpaths
* Searching
* South migrations **Soon**
* South migrations **Done**
* View source for read-only articles + locked status
* Global moderator permission **Almost done** (need to add grant form for users with *grant* permissions)
* Are you sure you wanna leave this page?
* Special view for deleted articles w/ restore button
* Special view for deleted articles w/ restore button **Done**
Ideas
=====
......
......@@ -9,6 +9,12 @@ ADMINS = (
# ('Your Name', 'your_email@example.com'),
)
from django.core.urlresolvers import reverse_lazy
LOGIN_REDIRECT_URL = reverse_lazy('wiki:get', kwargs={'path': ''})
LOGIN_URL = '/_accounts/login/'
LOGOUT_URL = '/_accounts/logout/'
MANAGERS = ADMINS
DATABASES = {
......
......@@ -9,6 +9,8 @@ URL_CASE_SENSITIVE = getattr(django_settings, 'WIKI_URL_CASE_SENSITIVE', False)
APP_LABEL = 'wiki'
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')
# This slug is used in URLPath if an article has been deleted. The children of the
......@@ -16,9 +18,11 @@ EDITOR = getattr(django_settings, 'WIKI_EDITOR', 'wiki.editors.MarkItUp')
# and all their content.
LOST_AND_FOUND_SLUG = getattr(django_settings, 'WIKI_LOST_AND_FOUND_SLUG', 'lost-and-found')
# Do we want to log IPs?
LOG_IPS_ANONYMOUS = getattr(django_settings, 'WIKI_LOG_IPS_ANONYMOUS', True)
LOG_IPS_USERS = getattr(django_settings, 'WIKI_LOG_IPS_USERS', False)
# Sign up, login and logout views should be accessible
ACCOUNT_HANDLING = getattr(django_settings, 'WIKI_ACCOUNT_HANDLING', True)
if ACCOUNT_HANDLING:
......
......@@ -5,6 +5,7 @@ _cache = {}
_settings_forms = []
_markdown_extensions = []
_article_tabs = []
_sidebar = []
def register(PluginClass):
"""
......@@ -25,9 +26,13 @@ def register(PluginClass):
settings_form = getattr(form_module, klassname)
_settings_forms.append(settings_form)
if PluginClass.article_tab:
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', []))
def get_plugins():
......@@ -37,4 +42,9 @@ def get_markdown_extensions():
return _markdown_extensions
def get_article_tabs():
"""Returns plugin classes that should connect to the article tab menu"""
return _article_tabs
def get_sidebar():
"""Returns plugin classes that should connect to the sidebar"""
return _sidebar
# -*- coding: utf-8 -*-
from django.conf import settings as django_settings
from django.core.urlresolvers import reverse
from django.shortcuts import redirect, get_object_or_404
from django.http import HttpResponse, HttpResponseNotFound
......@@ -42,10 +43,7 @@ def get_article(func=None, can_read=True, can_write=False, deleted_contents=Fals
path = kwargs.pop('path', None)
article_id = kwargs.pop('article_id', None)
if can_read:
articles = models.Article.objects.can_read(request.user)
if can_write:
articles = models.Article.objects.can_write(request.user)
articles = models.Article.objects
# TODO: Is this the way to do it?
articles = articles.select_related()
......@@ -71,15 +69,28 @@ def get_article(func=None, can_read=True, can_write=False, deleted_contents=Fals
except models.URLPath.DoesNotExist:
# TODO: Make a nice page
return HttpResponseNotFound("This article was not found, and neither was the parent. This page should look nicer.")
# TODO: If the article is not found but it exists, there is a permission error!
if urlpath.article:
article = get_object_or_404(articles, id=urlpath.article.id)
else:
# Somehow article is gone
# Be robust: Somehow article is gone but urlpath exists... clean up
return_url = reverse('wiki:get', kwargs={'path': urlpath.parent.path})
urlpath.delete()
return redirect(return_url)
if can_read and not article.can_read(request.user):
if request.user.is_anonymous:
return redirect(django_settings.LOGIN_URL)
else:
pass
# TODO: Return a permission denied page
if can_write and not article.can_write(request.user):
if request.user.is_anonymous:
return redirect(django_settings.LOGIN_URL)
else:
pass
# TODO: Return a permission denied page
# If the article has been deleted, show a special page.
if not deleted_contents and article.current_revision and article.current_revision.deleted:
if urlpath:
......
......@@ -35,6 +35,7 @@ class EditForm(forms.Form):
def __init__(self, current_revision, *args, **kwargs):
self.no_clean = kwargs.pop('no_clean', False)
self.preview = kwargs.pop('preview', False)
self.initial_revision = current_revision
self.presumed_revision = None
......@@ -68,6 +69,8 @@ class EditForm(forms.Form):
def clean(self):
cd = self.cleaned_data
if self.no_clean:
return cd
if not str(self.initial_revision.id) == str(self.presumed_revision):
raise forms.ValidationError(_(u'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 cd['title'] == self.initial_revision.title and cd['content'] == self.initial_revision.content:
......
......@@ -44,7 +44,7 @@ class Article(models.Model):
if user == self.owner:
return True
if self.group_read:
if group == self.group:
if self.group and group == self.group:
return True
if self.group and user and user.groups.filter(group=group):
return True
......@@ -58,7 +58,7 @@ class Article(models.Model):
if user == self.owner:
return True
if self.group_write:
if group == self.group:
if self.group and group == self.group:
return True
if self.group and user and user.groups.filter(group=group):
return True
......
......@@ -32,6 +32,8 @@ class ArticlePlugin(models.Model):
deleted = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
def purge(self):
"""Remove related contents completely, ie. media files."""
pass
......@@ -106,7 +108,7 @@ class RevisionPlugin(ArticlePlugin):
super(RevisionPlugin, self).__init__(*args, **kwargs)
if not self.id and not 'article' in kwargs:
raise RevisionPluginCreateError("Keyword argument 'article' expected.")
self.article = kwargs['article']
self.article = kwargs['article']
def get_logmessage(self):
return _(u"A plugin was changed")
......@@ -121,6 +123,7 @@ class RevisionPlugin(ArticlePlugin):
new_revision.save()
self.revision = new_revision
super(RevisionPlugin, self).save(*args, **kwargs)
class Meta:
app_label = settings.APP_LABEL
......
......@@ -24,12 +24,9 @@ class BasePlugin(object):
class PluginSidebarFormMixin(object):
def __init__(self, plugin_instance, *args, **kwargs):
kwargs['prefix'] = plugin_instance.slug
def get_usermessage(self):
pass
class PluginSettingsFormMixin(object):
settings_form_headline = _(u'Notifications')
......
......@@ -2,6 +2,9 @@ from django.conf import settings as django_settings
SLUG = "attachments"
# Allow anonymous users to upload (not nice on an open network)
ANONYMOUS = getattr(django_settings, 'WIKI_ATTACHMENTS_ANONYMOUS', False)
# Maximum file sizes: Please using something like LimitRequestBody on
# your web server.
# http://httpd.apache.org/docs/2.2/mod/core.html#LimitRequestBody
......
......@@ -82,12 +82,16 @@
<div id="collapse_upload" class="accordion-body collapse{% if form.errors %} in{% endif %}">
<div class="accordion-inner">
{% 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 %}
{% wiki_form form %}
<button type="submit" name="save" value="1" class="btn btn-large">
{% trans "Upload file" %}
</button>
</form>
{% endif %}
</div>
</div>
......
# -*- coding: utf-8 -*-
from django.conf import settings as django_settings
from django.contrib import messages
from django.db import transaction
from django.db.models import Q
from django.http import Http404
from django.shortcuts import redirect, get_object_or_404
from django.utils.decorators import method_decorator
from django.utils.translation import ugettext as _
from django.views.generic.edit import FormView
from django.db.models import Q
from wiki.views.mixins import ArticleMixin
from wiki.decorators import get_article
from wiki.plugins.attachments import forms
from wiki.plugins.attachments import models
from django.contrib import messages
from django.views.generic.base import TemplateView, View
from wiki.core.http import send_file
from django.http import Http404
from django.db import transaction
from django.views.generic.edit import FormView
from django.views.generic.list import ListView
from wiki.core.http import send_file
from wiki.decorators import get_article
from wiki.plugins.attachments import models, settings, forms
from wiki.views.mixins import ArticleMixin
class AttachmentView(ArticleMixin, FormView):
form_class = forms.AttachmentForm
......@@ -34,6 +35,9 @@ class AttachmentView(ArticleMixin, FormView):
# WARNING! The below decorator silences other exceptions that may occur!
#@transaction.commit_manually
def form_valid(self, form):
if self.request.user.is_anonymous and not settings.ANONYMOUS:
return redirect(django_settings.LOGIN_URL)
try:
attachment_revision = form.save(commit=False)
attachment = models.Attachment()
......@@ -59,6 +63,7 @@ class AttachmentView(ArticleMixin, FormView):
kwargs['attachments'] = self.attachments
kwargs['search_form'] = forms.SearchForm()
kwargs['selected_tab'] = 'attachments'
kwargs['anonymous_disallowed'] = self.request.user.is_anonymous and not settings.ANONYMOUS
return super(AttachmentView, self).get_context_data(**kwargs)
......
......@@ -26,6 +26,7 @@ class AttachmentPlugin(plugins.BasePlugin):
url('^download/(?P<attachment_id>\d+)/revision/(?P<revision_id>\d+)/$', views.AttachmentDownloadView.as_view(), name='attachments_download'),
url('^change/(?P<attachment_id>\d+)/revision/(?P<revision_id>\d+)/$', views.AttachmentChangeRevisionView.as_view(), name='attachments_revision_change'),
)
article_tab = (_(u'Attachments'), "icon-file")
article_view = views.AttachmentView().dispatch
......
from django import forms
from django.utils.translation import ugettext as _
from wiki.plugins import PluginSidebarFormMixin
from wiki.plugins.images import models
class SidebarForm(forms.ModelForm, PluginSidebarFormMixin):
def __init__(self, *args, **kwargs):
self.article = kwargs.pop('article')
super(SidebarForm, self).__init__(*args, **kwargs)
def get_usermessage(self):
return _(u"New image %s was successfully uploaded. You can use it by selecting it from the list of available images.") % self.instance.get_filename()
class Meta:
model = models.Image
fields = ('image',)
\ No newline at end of file
......@@ -9,13 +9,14 @@ class Image(RevisionPlugin):
image = models.ImageField(upload_to=settings.IMAGE_PATH)
caption = models.CharField(max_length=2056, null=True, blank=True)
def render_caption(self):
"""Returns a rendered version of the caption. Should only use a
subset of the rendering machine."""
pass
def get_filename(self):
if self.image:
return self.image.path.split('/')[-1]
class Meta:
verbose_name = _(u'image')
verbose_name_plural = _(u'images')
def __unicode__(self):
return _(u'Image: %s') % self.get_filename()
\ No newline at end of file
from django.conf import settings as django_settings
# Where to store images
IMAGE_PATH = getattr(django_settings, "WIKI_IMAGE_PATH", 'wiki/images/%aid/')
IMAGE_PATH = getattr(django_settings, 'WIKI_IMAGE_PATH', "wiki/images/%aid/")
# Allow anonymous users to upload (not nice on an open network)
ANONYMOUS = getattr(django_settings, 'WIKI_ATTACHMENTS_ANONYMOUS', False)
SLUG = 'images'
\ No newline at end of file
{% load i18n wiki_tags wiki_images_tags humanize %}
<h4>{% trans "Available images" %}</h4>
<p>{% trans "Click on an image below to insert in your text. The format of the code inserted is:" %}<br /><code>[image:id alignment caption text]</code></p>
<table class="table table-bordered table-striped">
<tr>
<th>{% trans "File" %}</th>
<th>{% trans "Added" %}</th>
</tr>
{% for image in article|images_for_article %}
<tr>
<td>{{ image.get_filename }}</td>
<td>{{ image.created|naturaltime }}</td>
</tr>
{% endfor %}
</table>
<hr />
<h4>{% trans "Add new image" %}</h4>
{% if form.non_field_errors %}
{% if form_error_title %}<h4 class="alert-heading">{{ form_error_title }}</h4>{% endif %}
{% for error_message in form.non_field_errors %}
<div class="alert alert-block alert-error">
{{ error_message }}
</div>
{% endfor %}
{% endif %}
{% for field in form %}
<p id="div_{{ field.auto_id }}" class="fields {% if field.errors %} error{% endif %}">
{% if field.label %}
<!--<label for="{{ field.id_for_label }}" class="{% if field.field.required %}requiredField{% endif %}">
{{ field.label|safe }}
</label>-->
{% endif %}
{{ field }}
{% if field.errors %}
{% for error in field.errors %}
<div id="error_{{ forloop.counter }}_{{ field.auto_id }}" class="help-block"><strong>{{ error }}</strong></div>
{% endfor %}
{% endif %}
</p>
{% if field.help_text %}
<p id="hint_{{ field.auto_id }}" class="help-block">{{ field.help_text|safe }}</p>
{% endif %}
{% endfor %}
<p>
<button type="submit" name="{{ plugin.slug }}_save" value="1" class="btn btn-large">
<span class="icon-upload"></span>
{% trans "Add image" %}
</button>
</p>
from django import template
from wiki.plugins.images import models
register = template.Library()
@register.filter
def images_for_article(article):
return models.Image.objects.filter(revision__article=article)
from wiki.views.mixins import ArticleMixin
from django.views.generic.base import TemplateView
from django.utils.decorators import method_decorator
from wiki.decorators import get_article
class ImageView(ArticleMixin, TemplateView):
@method_decorator(get_article(can_read=True))
def dispatch(self, request, article, *args, **kwargs):
return super(ImageView, self).dispatch(request, article, *args, **kwargs)
\ No newline at end of file
# -*- coding: utf-8 -*-
from django.conf.urls.defaults import patterns, url
from django.utils.translation import ugettext as _
from wiki.core import plugins_registry
from wiki import plugins
from wiki.plugins.images import views, models, settings, forms
from wiki.plugins.notifications import ARTICLE_EDIT
class ImagePlugin(plugins.BasePlugin):
#settings_form = 'wiki.plugins.notifications.forms.SubscriptionForm'
slug = settings.SLUG
urlpatterns = patterns('',
url('^$', views.ImageView.as_view(), name='images_index'),
)
sidebar = {'headline': _('Images'),
'icon_class': 'icon-picture',
'template': 'wiki/plugins/images/sidebar.html',
'form_class': forms.SidebarForm,
'get_form_kwargs': (lambda a: {'instance': models.Image(article=a)})}
# List of notifications to construct signal handlers for. This
# is handled inside the notifications plugin.
notifications = [{'model': models.Image,
'message': lambda obj: _(u"An image was added: %s") % obj.get_filename(),
'key': ARTICLE_EDIT,
'created': True,
'get_article': lambda obj: obj.revision.article}
]
#markdown_extensions = [AttachmentExtension()]
def __init__(self):
#print "I WAS LOADED!"
pass
plugins_registry.register(ImagePlugin)
......@@ -7,11 +7,9 @@
.markItUp {padding: 0; width: auto;}
textarea.markItUp {font-size: 16px; padding: 10px; float: none; display: block; width: 100%; }
textarea.markItUp {font-size: 16px; padding: 10px; float: none; display: block; width: 100%; height: 400px; font-size: 13px; color: #222; }
.markItUpHeader {float: none; display: block; }
.markItUpContainer {margin-right: 40px;;}
.markItUpContainer {margin-right: 20px; width: -20px;}
.markItUp .markItUpButton1 a {
background-image:url(images/h1.png);
......
......@@ -12,8 +12,12 @@
<!-- TODO: Put all this stuff in Less -->
<style>
#id_title {font-size: 20px; height: 25px; padding: 10px; width: 400px;}
#id_summary {width: 95%}
#div_id_title .asteriskField{display:none}
#id_title {font-size: 20px; height: 30px; padding: 6px; width: 98%;}
#id_summary {width: 98%; padding: 6px;}
#article_edit_form label {max-width: 120px;}
#article_edit_form .controls {margin-left: 140px;}
.form-horizontal label { font-size: 18px; font-weight: bold; color: #777;}
#settings_form label {font-size: inherit; font-weight: normal;}
......
......@@ -6,7 +6,7 @@
{% block wiki_contents_tab %}
<form method="POST" class="form-horizontal">
<form method="POST" class="form-horizontal" id="article_edit_form" enctype="multipart/form-data">
{% include "wiki/includes/editor.html" %}
<div class="form-actions">
<button type="submit" name="preview" value="1" class="btn btn-large" onclick="$('#previewModal').modal('show'); this.form.target='previewWindow'; this.form.action='{% url 'wiki:preview' path=urlpath.path article_id=article.id %}'">
......
{% load i18n %}
{% load url from future %}
<em>
{% url 'wiki:signup' as signup_url %}
{% url 'wiki:login' as login_url %}
{% if login_url and signup_url %}
{% blocktrans %}
You need to <a href="{{ login_url }}">log in</a> or <a href="{{ signup_url }}">sign up</a> to use this function.
{% endblocktrans %}
{% else %}
{% trans "You need to log in og sign up to use this function." %}
{% endif %}
</em>
......@@ -2,6 +2,15 @@
{% with selected_tab as selected %}
<li class="pull-right{% if selected == "settings" %} active{% endif %}">
{% if not user.is_anonymous %}
<a href="{% url 'wiki:settings' article_id=article.id path=urlpath.path %}">
<span class="icon-wrench"></span>
{% trans "Settings" %}
</a>
{% endif %}
</li>
{% for plugin in article_tabs %}
<li class="pull-right{% if selected == plugin.slug %} active{% endif %}">
<a href="{% url 'wiki:plugin' slug=plugin.slug article_id=article.id path=urlpath.path %}">
......@@ -11,14 +20,6 @@
</li>
{% endfor %}
<li class="pull-right{% if selected == "settings" %} active{% endif %}">
{% if not user.is_anonymous %}
<a href="{% url 'wiki:settings' article_id=article.id path=urlpath.path %}">
<span class="icon-wrench"></span>
{% trans "Settings" %}
</a>
{% endif %}
</li>
<li class="pull-right{% if selected == "history" %} active{% endif %}">
<a href="{% url 'wiki:history' article_id=article.id path=urlpath.path %}">
<span class="icon-time"></span>
......
{% load wiki_tags %}
{% load wiki_tags i18n %}
{% include "wiki/includes/editormedia.html" %}
<div style="width: 67%; min-width: 600px; float: left;">
{% wiki_form edit_form %}
<script language="javascript">
$(document).ready(function() {
$("#id_revision").val('{{ article.current_revision.id }}');
});
</script>
</div>
<div style="width: 33%; min-width: 300px; float: right;">
<div style="padding-left: 40px;">
{% for plugin in sidebar %}
<div class="accordion" id="accordion_{{ plugin.slug }}">
<div class="accordion-group">
<div class="accordion-heading">
<a class="accordion-toggle" href="#collapse_{{ plugin.slug }}" data-toggle="collapse">
<h2>{{ plugin.sidebar.headline }} <span class="{{ plugin.sidebar.icon_class }}"></span></h2>
</a>
</div>
<div id="collapse_{{ plugin.slug }}" class="accordion-body collapse{% if form_images.errors %} in{% endif %}">
<div class="accordion-inner form-vertical">
{% if plugin.sidebar.template %}
{% with form_images as form and plugin as plugin %}
{% include plugin.sidebar.template %}
{% endwith %}
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<div style="clear: both"></div>
......@@ -2,17 +2,19 @@
"""This is nothing but the usual handling of django user accounts, so
go ahead and replace it or disable it!"""
from django.contrib.auth.models import User
from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth import logout as auth_logout, login as auth_login
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib import messages
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.generic.base import View
from django.views.generic.edit import CreateView, FormView
from wiki.models import URLPath
from django.core.urlresolvers import reverse
class Signup(CreateView):
model = User
......@@ -28,7 +30,6 @@ class Logout(View):
def get(self, request, *args, **kwargs):
auth_logout(request)
messages.info(request, _(u"You are no longer logged in. Bye bye!"))
print redirect("wiki:get", URLPath.root().path)
return redirect("wiki:get", URLPath.root().path)
class Login(FormView):
......@@ -47,5 +48,5 @@ class Login(FormView):
messages.info(self.request, _(u"You are now logged in! Have fun!"))
if self.request.GET.get("next", None):
return redirect(self.request.GET['next'])
return redirect("wiki:get", URLPath.root().path)
return redirect(django_settings.LOGIN_REDIRECT_URL)
......@@ -222,13 +222,53 @@ class Edit(FormView, ArticleMixin):
@method_decorator(get_article(can_write=True))
def dispatch(self, request, article, *args, **kwargs):
self.sidebar_plugins = plugins_registry.get_sidebar()
self.sidebar_forms = {}
for plugin in self.sidebar_plugins:
sidebar_form_class = plugin.sidebar.get('form_class', None)
if sidebar_form_class:
form_kwargs = {}
form_kwargs['prefix'] = plugin.slug
form_kwargs['article'] = article
form_kwargs.update(plugin.sidebar['get_form_kwargs'](article))
plugin.sidebar_form_context = 'form_' + plugin.slug
if request.POST.get(plugin.slug+'_save', '') == '1':
form_kwargs['data'] = request.POST
form_kwargs['files'] = request.FILES
self.sidebar_forms[plugin.sidebar_form_context] = sidebar_form_class(**form_kwargs)
return super(Edit, self).dispatch(request, article, *args, **kwargs)
def get_form(self, form_class):
"""
Returns an instance of the form to be used in this view.
"""
return form_class(self.article.current_revision, **self.get_form_kwargs())
kwargs = self.get_form_kwargs()
if self.request.POST.get('save', '') != '1':
kwargs['no_clean'] = True
return form_class(self.article.current_revision, **kwargs)
def post(self, request, *args, **kwargs):
# Check if any of the plugin form data is supposed to be saved
for plugin in self.sidebar_plugins:
if self.request.POST.get(plugin.slug+'_save', '') == '1':
form = self.sidebar_forms[plugin.sidebar_form_context]
if form.is_valid():
form.save()
message = form.get_usermessage()
if message:
messages.success(request, message)
#if self.urlpath:
# return redirect('wiki:edit', path=self.urlpath.path)
#else:
# return redirect('wiki:edit', article_id=self.article.id)
if self.request.POST.get('save', '') == '1':
return super(Edit, self).post(request, *args, **kwargs)
else:
return super(Edit, self).get(request, *args, **kwargs)
def form_valid(self, form):
revision = models.ArticleRevision()
......@@ -252,6 +292,10 @@ class Edit(FormView, ArticleMixin):
kwargs['edit_form'] = kwargs.pop('form', None)
kwargs['editor'] = editors.editor
kwargs['selected_tab'] = 'edit'
kwargs['sidebar'] = self.sidebar_plugins
kwargs.update(self.sidebar_forms)
return super(Edit, self).get_context_data(**kwargs)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment