Browse Source

Initial import from romarchive-web's content app.

master
Erik Stein 7 years ago
commit
2f6b54f01f
  1. 60
      .gitignore
  2. 8
      LICENSE
  3. 4
      MANIFEST.in
  4. 5
      README.md
  5. 46
      content_plugins/__init__.py
  6. 41
      content_plugins/admin.py
  7. 5
      content_plugins/apps.py
  8. 0
      content_plugins/content_plugins.py
  9. 10
      content_plugins/fields.py
  10. 0
      content_plugins/migrations/__init__.py
  11. 101
      content_plugins/mixins.py
  12. 0
      content_plugins/models.py
  13. 266
      content_plugins/plugins.py
  14. 30
      content_plugins/renderer.py
  15. 23
      content_plugins/shortcuts.py
  16. 51
      setup.py

60
.gitignore vendored

@ -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/

8
LICENSE

@ -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.

4
MANIFEST.in

@ -0,0 +1,4 @@
include AUTHORS
include LICENSE
include README.md
recursive-include shared/utils/templates *

5
README.md

@ -0,0 +1,5 @@
# django-content-plugins
Experimental implementation.
Plugins and TemplatePluginRenderer extensions for matthiask's feincms3.

46
content_plugins/__init__.py

@ -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
"""

41
content_plugins/admin.py

@ -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',
)

5
content_plugins/apps.py

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ContentConfig(AppConfig):
name = 'content'

0
content_plugins/content_plugins.py

10
content_plugins/fields.py

@ -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
content_plugins/migrations/__init__.py

101
content_plugins/mixins.py

@ -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
content_plugins/models.py

266
content_plugins/plugins.py

@ -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)

30
content_plugins/renderer.py

@ -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)

23
content_plugins/shortcuts.py

@ -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

51
setup.py

@ -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…
Cancel
Save