commit
2f6b54f01f
16 changed files with 650 additions and 0 deletions
@ -0,0 +1,60 @@
|
||||
# ---> Python |
||||
# Byte-compiled / optimized / DLL files |
||||
__pycache__/ |
||||
*.py[cod] |
||||
*$py.class |
||||
|
||||
# C extensions |
||||
*.so |
||||
|
||||
# Distribution / packaging |
||||
.Python |
||||
env/ |
||||
build/ |
||||
develop-eggs/ |
||||
dist/ |
||||
downloads/ |
||||
eggs/ |
||||
.eggs/ |
||||
lib/ |
||||
lib64/ |
||||
parts/ |
||||
sdist/ |
||||
var/ |
||||
*.egg-info/ |
||||
.installed.cfg |
||||
*.egg |
||||
|
||||
# PyInstaller |
||||
# Usually these files are written by a python script from a template |
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it. |
||||
*.manifest |
||||
*.spec |
||||
|
||||
# Installer logs |
||||
pip-log.txt |
||||
pip-delete-this-directory.txt |
||||
|
||||
# Unit test / coverage reports |
||||
htmlcov/ |
||||
.tox/ |
||||
.coverage |
||||
.coverage.* |
||||
.cache |
||||
nosetests.xml |
||||
coverage.xml |
||||
*,cover |
||||
|
||||
# Translations |
||||
*.mo |
||||
*.pot |
||||
|
||||
# Django stuff: |
||||
*.log |
||||
|
||||
# Sphinx documentation |
||||
docs/_build/ |
||||
|
||||
# PyBuilder |
||||
target/ |
||||
|
@ -0,0 +1,8 @@
|
||||
MIT License |
||||
Copyright (c) <year> <copyright holders> |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
@ -0,0 +1,4 @@
|
||||
include AUTHORS |
||||
include LICENSE |
||||
include README.md |
||||
recursive-include shared/utils/templates * |
@ -0,0 +1,5 @@
|
||||
# django-content-plugins |
||||
|
||||
Experimental implementation. |
||||
|
||||
Plugins and TemplatePluginRenderer extensions for matthiask's feincms3. |
@ -0,0 +1,46 @@
|
||||
""" |
||||
Abstract models for common content editor plugins. |
||||
|
||||
|
||||
admin.py |
||||
-------- |
||||
|
||||
ContentInlineBase |
||||
RichTextInlineBase |
||||
|
||||
RichTextarea |
||||
|
||||
|
||||
|
||||
fields.py |
||||
--------- |
||||
|
||||
TranslatableCleansedRichTextField |
||||
|
||||
|
||||
|
||||
plugins.py |
||||
---------- |
||||
|
||||
BasePlugin |
||||
StringRendererPlugin |
||||
RichTextBase |
||||
SectionBase |
||||
ImageBase |
||||
DownloadBase |
||||
FootnoteBase |
||||
|
||||
StyleMixin |
||||
RichTextFootnoteMixin |
||||
|
||||
|
||||
renderer.py |
||||
----------- |
||||
|
||||
PluginRenderer |
||||
|
||||
@register_with_renderer |
||||
|
||||
|
||||
|
||||
""" |
@ -0,0 +1,41 @@
|
||||
# Erik Stein <code@classlibrary.net>, 2017 |
||||
""" |
||||
Abstract base classes and mixins. |
||||
""" |
||||
|
||||
from django import forms |
||||
|
||||
from content_editor.admin import ContentEditorInline |
||||
|
||||
|
||||
class ContentInlineBase(ContentEditorInline): |
||||
""" |
||||
Empty definition for later use. |
||||
""" |
||||
|
||||
|
||||
class RichTextarea(forms.Textarea): |
||||
def __init__(self, attrs=None): |
||||
# Provide class so that the code in plugin_ckeditor.js knows |
||||
# which text areas should be enhanced with a rich text |
||||
# control: |
||||
default_attrs = {'class': 'richtext'} |
||||
if attrs: |
||||
default_attrs.update(attrs) |
||||
super().__init__(default_attrs) |
||||
|
||||
|
||||
class RichTextInlineBase(ContentInlineBase): |
||||
# Subclasses: Add your model, like model = models.RichTextArticlePlugin |
||||
|
||||
formfield_overrides = { |
||||
'richtext_en': {'widget': RichTextarea}, |
||||
} |
||||
|
||||
regions = [] |
||||
|
||||
class Media: |
||||
js = ( |
||||
# '//cdn.ckeditor.com/4.5.6/standard/ckeditor.js', |
||||
'js/plugin_ckeditor.js', |
||||
) |
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig |
||||
|
||||
|
||||
class ContentConfig(AppConfig): |
||||
name = 'content' |
@ -0,0 +1,10 @@
|
||||
# Erik Stein <code@classlibrary.net>, 2017 |
||||
|
||||
from feincms3.cleanse import CleansedRichTextField |
||||
from shared.multilingual.utils.fields import TranslatableFieldMixin |
||||
|
||||
|
||||
class TranslatableCleansedRichTextField(TranslatableFieldMixin, CleansedRichTextField): |
||||
base_class = CleansedRichTextField |
||||
extra_parameter_names = ['config_name', 'extra_plugins', 'external_plugin_resources'] |
||||
# TODO Implement translatable rich text widget |
@ -0,0 +1,101 @@
|
||||
# Erik Stein <code@classlibrary.net>, 2017 |
||||
|
||||
from functools import reduce, partial |
||||
from django.conf import settings |
||||
from django.db import models |
||||
from django.utils.html import strip_tags |
||||
from django.utils.text import normalize_newlines |
||||
from django.utils.translation import ugettext_lazy as _ |
||||
|
||||
from shared.utils.fields import AutoSlugField |
||||
from shared.utils.functional import firstof |
||||
from shared.utils.text import slimdown |
||||
|
||||
|
||||
USE_TRANSLATABLE_FIELDS = getattr(settings, 'CONTENT_USE_TRANSLATABLE_FIELDS', True) |
||||
# TODO Implement translatable AutoSlugField: USE_TRANSLATABLE_SLUG_FIELDS = getattr(settings, 'CONTENT_USE_TRANSLATABLE_SLUG_FIELDS', True) |
||||
|
||||
|
||||
if USE_TRANSLATABLE_FIELDS: |
||||
from shared.multilingual.utils.fields import TranslatableCharField, TranslatableTextField |
||||
|
||||
|
||||
class PageTitlesMixin(models.Model): |
||||
""" |
||||
A model mixin containg title and slug field for models serving as website |
||||
pages with an URL. |
||||
""" |
||||
# FIXME signals are not sent from abstract models, therefore AutoSlugField doesn't work |
||||
if USE_TRANSLATABLE_FIELDS: |
||||
short_title = TranslatableCharField(_("Name"), max_length=50) |
||||
title = TranslatableTextField(_("Titel (Langform)"), null=True, blank=True, max_length=300) |
||||
window_title = TranslatableCharField(_("Fenster-/Suchmaschinentitel"), null=True, blank=True, max_length=300) |
||||
# FIXME populate_from should use settings.LANGUAGE |
||||
slug = AutoSlugField(_("URL-Name"), max_length=200, populate_from='short_title_de', unique_slug=True, blank=True) |
||||
|
||||
else: |
||||
short_title = models.CharField(_("Name"), max_length=50) |
||||
title = models.TextField(_("Titel (Langform)"), null=True, blank=True, max_length=300) |
||||
window_title = models.CharField(_("Fenster-/Suchmaschinentitel"), null=True, blank=True, max_length=300) |
||||
slug = AutoSlugField(_("URL-Name"), max_length=200, populate_from='short_title', unique_slug=True, blank=True) |
||||
|
||||
class Meta: |
||||
abstract = True |
||||
|
||||
def __str__(self): |
||||
return strip_tags(slimdown(self.short_title)) |
||||
|
||||
def get_title(self): |
||||
return slimdown(firstof( |
||||
self.title, |
||||
self.short_title |
||||
)) |
||||
|
||||
def get_window_title(self): |
||||
return strip_tags(slimdown( |
||||
firstof( |
||||
self.window_title, |
||||
self.short_title, |
||||
self.get_first_title_line(), |
||||
) |
||||
)) |
||||
|
||||
def get_first_title_line(self): |
||||
""" |
||||
First line of title field. |
||||
""" |
||||
return slimdown( |
||||
normalize_newlines(self.get_title()).partition("\n")[0] |
||||
) |
||||
|
||||
def get_subtitle_lines(self): |
||||
""" |
||||
All but first line of the long title field. |
||||
""" |
||||
return slimdown( |
||||
normalize_newlines(self.title).partition("\n")[2] |
||||
) |
||||
|
||||
|
||||
# TODO Move to shared.multilingual or shared.utils.translation |
||||
def language_variations_for_field(language_codes, fields): |
||||
# TODO Check if field is translatable |
||||
return ["{}_{}".format(fields, s) for s in language_codes] |
||||
|
||||
|
||||
# TODO Move to shared.multilingual or shared.utils.translation |
||||
def language_variations_for_fields(fields, language_codes=None): |
||||
if not language_codes: |
||||
language_codes = [t[0] for t in settings.LANGUAGES] |
||||
f = partial(language_variations_for_field, language_codes) |
||||
return reduce(lambda x, y: x + y, map(f, fields)) |
||||
|
||||
|
||||
class PageTitleAdminMixin(object): |
||||
search_fields = ['short_title', 'title', 'window_title'] |
||||
if USE_TRANSLATABLE_FIELDS: |
||||
search_fields = language_variations_for_fields(search_fields) |
||||
list_display = ['short_title', 'slug'] |
||||
prepopulated_fields = { |
||||
'slug': ('short_title_en',), |
||||
} |
@ -0,0 +1,266 @@
|
||||
# TODO Always use Django templates for rendering, replace .format() class |
||||
|
||||
import os |
||||
import re |
||||
from django.conf import settings |
||||
from django.db import models |
||||
from django.template import Template |
||||
from django.utils.html import format_html, mark_safe |
||||
from django.utils.translation import ugettext_lazy as _ |
||||
|
||||
from feincms3.cleanse import CleansedRichTextField |
||||
from shared.utils.fields import AutoSlugField |
||||
from shared.multilingual.utils.fields import TranslatableCharField, TranslatableTextField |
||||
|
||||
from .admin import ContentInlineBase, RichTextInlineBase |
||||
from .fields import TranslatableCleansedRichTextField |
||||
|
||||
# FIXME Implement USE_TRANSLATABLE_FIELDS |
||||
from .mixins import USE_TRANSLATABLE_FIELDS |
||||
|
||||
|
||||
""" |
||||
TODO Class hierarchy should be |
||||
BasePlugin |
||||
StringRendererPlugin |
||||
TemplateRendererPlugin |
||||
FilesystemRendererPlugin (is specific for RomArchive) |
||||
""" |
||||
|
||||
|
||||
class BasePlugin(models.Model): |
||||
class Meta: |
||||
abstract = True |
||||
verbose_name = _("Element") |
||||
verbose_name_plural = _("Elements") |
||||
|
||||
def __str__(self): |
||||
return "{} ({})".format(self._meta.verbose_name, self.pk) |
||||
|
||||
@classmethod |
||||
def admin_inline(cls, base_class=ContentInlineBase): |
||||
class Inline(base_class): |
||||
model = cls |
||||
regions = cls.regions |
||||
return Inline |
||||
|
||||
@classmethod |
||||
def register_with_renderer(cls, renderer): |
||||
renderer.register_template_renderer(cls, cls.get_template, cls.get_context_data) |
||||
|
||||
def get_template_names(self): |
||||
return getattr(self, 'template_name', None) |
||||
|
||||
def get_template(self): |
||||
""" |
||||
Might return a single template name, a list of template names |
||||
or an instance with a "render" method (i.e. a Template instance). |
||||
|
||||
Default implementation is to return the result of self.get_template_names(). |
||||
""" |
||||
return self.get_template_names() |
||||
|
||||
def get_context_data(self, request_context, **kwargs): |
||||
context = kwargs.get('context', {}) |
||||
context['parent'] = self.parent |
||||
context['request'] = request_context['request'] |
||||
context['content'] = self |
||||
return context |
||||
|
||||
# For rendering the template's render() method is used |
||||
|
||||
|
||||
class StringRendererPlugin(BasePlugin): |
||||
class Meta: |
||||
abstract = True |
||||
|
||||
@classmethod |
||||
def register_with_renderer(cls, renderer): |
||||
renderer.register_template_renderer(cls, None, cls.render) |
||||
|
||||
def render(self): |
||||
raise NotImplementedError |
||||
|
||||
|
||||
class StyleField(models.CharField): |
||||
# Allow overriding of STYLE_CHOICES constant in subclasses |
||||
|
||||
def contribute_to_class(self, cls, name, **kwargs): |
||||
if hasattr(cls, 'STYLE_CHOICES'): |
||||
self.choices = cls.STYLE_CHOICES |
||||
super().contribute_to_class(cls, name, **kwargs) |
||||
|
||||
|
||||
class StyleMixin(models.Model): |
||||
STYLE_CHOICES = ( |
||||
('default', _("default")), |
||||
) |
||||
style = StyleField(_("style"), max_length=50, null=True, blank=True) |
||||
|
||||
class Meta: |
||||
abstract = True |
||||
|
||||
def get_style_slug(self): |
||||
return getattr(self, 'style', None) or 'default' |
||||
|
||||
|
||||
class FilesystemTemplateRendererPlugin(BasePlugin): |
||||
# TODO Join FilesystemTemplateRendererPlugin code with BaseObjectElement code |
||||
|
||||
template_name = None |
||||
path_prefix = None # Potential values: "richtext", "image" |
||||
|
||||
class Meta: |
||||
abstract = True |
||||
|
||||
def get_path_prefix(self): |
||||
if self.path_prefix: |
||||
return "{}{}".format(self.path_prefix, os.path.sep) |
||||
else: |
||||
return "" |
||||
|
||||
def get_template_names(self): |
||||
# TODO Style related logic should be part of the StyleMixin: maybe get_template_names should call super() |
||||
if hasattr(self, 'style'): |
||||
template_names = [ |
||||
"curatorialcontent/elements/{path_prefix}style/_{style}.html".format( |
||||
path_prefix=self.get_path_prefix(), style=self.get_style_slug()), |
||||
] |
||||
else: |
||||
template_names = [] |
||||
|
||||
return template_names + [ |
||||
"curatorialcontent/elements/{path_prefix}_default.html".format( |
||||
path_prefix=self.get_path_prefix()) |
||||
] + ([self.template_name] if getattr(self, 'template_name', None) else []) |
||||
|
||||
|
||||
class RichTextBase(StyleMixin, FilesystemTemplateRendererPlugin): |
||||
richtext = TranslatableCleansedRichTextField(_("text"), blank=True) |
||||
|
||||
path_prefix = 'richtext' |
||||
|
||||
class Meta: |
||||
abstract = True |
||||
verbose_name = _("text") |
||||
verbose_name_plural = _("texts") |
||||
|
||||
@classmethod |
||||
def admin_inline(cls, base_class=None): |
||||
return super().admin_inline(base_class=RichTextInlineBase) |
||||
|
||||
|
||||
class SectionBase(StyleMixin, BasePlugin): |
||||
subheading = TranslatableCharField(_("subheading"), max_length=500) |
||||
slug = AutoSlugField(_("slug"), max_length=200, blank=True, populate_from='subheading', unique_slug=False) |
||||
|
||||
class Meta: |
||||
abstract = True |
||||
verbose_name = _("Abschnittswechsel") |
||||
verbose_name_plural = _("Abschnittswechsel") |
||||
|
||||
def get_template(self): |
||||
return Template(""" |
||||
</div> |
||||
</section> |
||||
|
||||
<section id="{{ slug }}"> |
||||
<h2>{{ heading }}</h2> |
||||
|
||||
<div class="text"> |
||||
""") |
||||
|
||||
def get_context_data(self, request_context, **kwargs): |
||||
context = super().get_context_data(request_context, **kwargs) |
||||
context['slug'] = self.slug |
||||
context['heading'] = self.heading |
||||
return context |
||||
|
||||
|
||||
# class ImageBase(StyleMixin, BasePlugin): |
||||
# image = models.ForeignKey(Image, on_delete=models.PROTECT) |
||||
# alt_caption = TranslatableCharField(_("caption"), max_length=500, null=True, blank=True, help_text=_("Optional, used instead of the caption of the image object."))# |
||||
|
||||
# class Meta: |
||||
# abstract = True |
||||
# verbose_name = _("image") |
||||
# verbose_name_plural = _("images")# |
||||
|
||||
# def render(self): |
||||
# template = """ |
||||
# <figure class="image"> |
||||
# <img src="{src}"># |
||||
|
||||
# <figcaption> |
||||
# {caption_text} |
||||
# </figcaption> |
||||
# </figure> |
||||
# """ |
||||
# # TOOD Assemble caption from image's captions if empty |
||||
# return mark_safe(template.format( |
||||
# src=self.image.figure_image.url, |
||||
# caption_text=mark_safe(self.alt_caption or "") |
||||
# )) |
||||
|
||||
|
||||
class DownloadBase(StyleMixin, BasePlugin): |
||||
file = models.FileField(upload_to='downloads/%Y/%m/') |
||||
|
||||
class Meta: |
||||
abstract = True |
||||
verbose_name = _("download") |
||||
verbose_name_plural = _("downloads") |
||||
|
||||
def render(self): |
||||
template = """ |
||||
<a href="{url}">{name}</a> |
||||
""" |
||||
return mark_safe(template.format( |
||||
url=self.file.url, |
||||
name=self.file.name, |
||||
)) |
||||
|
||||
|
||||
class FootnoteBase(BasePlugin): |
||||
# TODO Validators: index might only contain alphanumeric characters |
||||
index = models.CharField(_("footnote index"), max_length=10) |
||||
richtext = TranslatableCleansedRichTextField(_("footnote text")) |
||||
|
||||
html_tag = '<li>' |
||||
|
||||
class Meta: |
||||
abstract = True |
||||
verbose_name = _("footnote") |
||||
verbose_name_plural = _("footnote") |
||||
|
||||
def render(self, html_tag=None): |
||||
template = """ |
||||
{opening_tag} |
||||
<div class="text"> |
||||
<a id="fn{number}" class="footnote-index" href="#back{number}">{number}</a> |
||||
{text} |
||||
</div> |
||||
{closing_tag} |
||||
""" |
||||
context = { |
||||
'number': self.index, |
||||
'text': mark_safe(self.richtext or ""), |
||||
'opening_tag': "", |
||||
'closing_tag': "", |
||||
} |
||||
html_tag = html_tag or self.html_tag |
||||
if html_tag: |
||||
context['opening_tag'] = html_tag |
||||
context['closing_tag'] = '{0}/{1}'.format(html_tag[:1], html_tag[1:]) |
||||
return mark_safe(template.format(**context)) |
||||
|
||||
|
||||
class RichTextFootnoteMixin: |
||||
MATCH_FOOTNOTES = re.compile("<sup>(\w+)</sup>") |
||||
|
||||
def render(self): |
||||
# Find all footnotes and convert them into links |
||||
rv = self.MATCH_FOOTNOTES.subn( |
||||
'<sup id=\"back\g<1>\" class="footnote"><a href=\"#fn\g<1>\">\g<1></a></sup>', |
||||
self.richtext)[0] |
||||
return mark_safe(rv) |
@ -0,0 +1,30 @@
|
||||
# Erik Stein <code@classlibrary.net>, 2017 |
||||
|
||||
from django.db.models import Model |
||||
from django.utils.translation import get_language |
||||
|
||||
from feincms3.renderer import Regions, TemplatePluginRenderer |
||||
|
||||
class MultilingualRegions(Regions): |
||||
|
||||
def cache_key(self, region): |
||||
return '%s-%s' % (get_language(), super().cache_key(region)) |
||||
|
||||
|
||||
class PluginRenderer(TemplatePluginRenderer): |
||||
# Used as decorator |
||||
def register(self): |
||||
""" |
||||
Usage: |
||||
@renderer.register() |
||||
class TextPlugin(ModelPlugin): |
||||
pass |
||||
|
||||
""" |
||||
def _renderer_wrapper(plugin_class): |
||||
plugin_class.register_with_renderer(self) |
||||
return plugin_class |
||||
return _renderer_wrapper |
||||
|
||||
def regions(self, item, inherit_from=None, regions=MultilingualRegions): |
||||
return super().regions(item, inherit_from=inherit_from, regions=regions) |
@ -0,0 +1,23 @@
|
||||
from django.contrib.auth.models import AnonymousUser |
||||
from django.http import HttpRequest |
||||
from django.template.loader import render_to_string |
||||
from django.utils.html import strip_tags |
||||
|
||||
from content_editor.contents import contents_for_item |
||||
from shared.utils.text import html_entities_to_unicode |
||||
from shared.utils.translation import get_language_order |
||||
|
||||
|
||||
def render_page_as_text(page, renderer, template, language): |
||||
request = HttpRequest() |
||||
request.user = AnonymousUser() |
||||
contents = contents_for_item(page, renderer.plugins()) |
||||
context = { |
||||
'contents': contents, |
||||
'language': language, |
||||
'page': page, |
||||
'renderer': renderer, |
||||
} |
||||
html = render_to_string(template, context, request=request) |
||||
text = html_entities_to_unicode(strip_tags(html)).strip() |
||||
return text |
@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python |
||||
|
||||
from io import open |
||||
import os |
||||
from setuptools import setup, find_packages |
||||
|
||||
|
||||
def get_version(prefix): |
||||
import re |
||||
with open(os.path.join(prefix, '__init__.py')) as fd: |
||||
metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fd.read())) |
||||
return metadata['version'] |
||||
|
||||
|
||||
def read(filename): |
||||
path = os.path.join(os.path.dirname(__file__), filename) |
||||
with open(path, encoding='utf-8') as handle: |
||||
return handle.read() |
||||
|
||||
|
||||
setup( |
||||
name='django-content-plugins', |
||||
version=get_version('content_plugins'), |
||||
description='', |
||||
long_description=read('README.md'), |
||||
author='Erik Stein', |
||||
author_email='erik@classlibrary.net', |
||||
url='https://projects.c--y.net/erik/django-content-plugins/', |
||||
license='MIT License', |
||||
platforms=['OS Independent'], |
||||
packages=find_packages( |
||||
exclude=['tests', 'testapp'], |
||||
), |
||||
include_package_data=True, |
||||
install_requires=[ |
||||
# 'django<2', commented out to make `pip install -U` easier |
||||
'django-content-editor', |
||||
'feincms3', |
||||
], |
||||
classifiers=[ |
||||
# "Development Status :: 3 - Alpha", |
||||
'Environment :: Web Environment', |
||||
'Framework :: Django', |
||||
'Intended Audience :: Developers', |
||||
'License :: OSI Approved :: BSD License', |
||||
'Operating System :: OS Independent', |
||||
'Programming Language :: Python', |
||||
'Topic :: Utilities', |
||||
], |
||||
zip_safe=False, |
||||
) |
Loading…
Reference in new issue