Compare commits

..

103 Commits

Author SHA1 Message Date
Erik Stein 9e5cd51b82 ChangeForeignKeyAction. 3 years ago
Erik Stein bddfa3ab8a Typo. 3 years ago
Erik Stein e8cb80321b Improved workflow model. 3 years ago
Erik Stein 372fbc9747 Added HTML to text utilities. 3 years ago
Erik Stein d0b85f0dc8 Improved publication workflow. 3 years ago
Erik Stein 2b75538284 Workflow: compare by full datetime 3 years ago
Erik Stein 6ec37a960c Typo. 3 years ago
Erik Stein 4edab13eeb dispatch_slug_path: Use first kwargs as url_path. 3 years ago
Erik Stein c8b5c62a43 Abstract workflow models. 3 years ago
Erik Stein ae4be0e58c Page titles refactoring. 3 years ago
Erik Stein 6a121cd82b Cleanup, removing Python 2 compat. 3 years ago
Erik Stein c501deab1b Some slug handling cleanup. 3 years ago
Erik Stein 3d3ac8596a Removed Python 2 compatibility. 3 years ago
Erik Stein 5746a65ca0 Note. 4 years ago
Erik Stein 530b9d1704 Avoid using Django's six module. 4 years ago
Erik Stein 88059b53a1 Added missing releaese to CHANGES. 4 years ago
Erik Stein 6a5eda5f92 Use "transliterate" alias for "translit/long" codec (Python 3.9 codecs compatibility) 4 years ago
Erik Stein 844b1806b3 strip_links template tag. 5 years ago
Erik Stein 94d66d0c50 Add markdown link syntax to slimdown. 5 years ago
Erik Stein aa2f38ce7c Release 0.2.29; date utils. 6 years ago
Erik Stein e6a3c81bb9 Fixed last of month calculation. 6 years ago
Erik Stein faf203b935 Deprecate get_short_title, use get_short_name. 6 years ago
Erik Stein c3c8384142 Release 0.2.26. 6 years ago
Erik Stein 7a0d18b51c Updated template class ussage in I18nDirectTemplateView. 6 years ago
Erik Stein 8aaee34fab Removed (non-working) i18n_direct_to_template view function. 6 years ago
Erik Stein 8da81f111a Release 0.2.15. 6 years ago
Erik Stein 247852a74b reverse function import path. 6 years ago
Erik Stein b04e11225e GET language switcher code generalized. 6 years ago
Erik Stein 739a306cac SlugField: Leave empty slug if blank=True. 6 years ago
Erik Stein 9bdcbcb430 Don't force None to "none" string. 6 years ago
Erik Stein 4ebc530dc6 Release. 6 years ago
Erik Stein 4ee6c1763c CHANGES. 6 years ago
Erik Stein 3c6cd10f63 lang_suffix: 'field_name' paramter instead of 'fieldname'. 6 years ago
Erik Stein 41cd99b0b0 PageTitlesFunctionMixin: __str__ without HTML. 6 years ago
Erik Stein fdde8c4f66 CHANGES. 6 years ago
Erik Stein e5bdef43a2 Class based daterange views. 6 years ago
Erik Stein 722e330518 Mock datetime for preview. 6 years ago
Erik Stein 228ad3f6aa select_template filter. 6 years ago
Erik Stein 7936e37ffd switch_language_url. 6 years ago
Erik Stein e6f40fd8c1 Admin action refinements. 6 years ago
Erik Stein e0700959b1 TargetActionBase. 6 years ago
Erik Stein 00e56dd096 AdminActionBase. 6 years ago
Erik Stein 9470676370 dispatch_slug_path. 6 years ago
Erik Stein b2058d1b04 Changes. 6 years ago
Erik Stein 0d8bd465b0 Load correct translations tags in language switcher fragment. 6 years ago
Erik Stein 6586083f78 Debugging utils. 6 years ago
Erik Stein d735affcd2 Daytime utils. 6 years ago
Erik Stein ef82878325 - Empty string handling for slimdown. 6 years ago
Erik Stein fa7c2b124e PageTitlesMixin: Slimdown name for get_short_title. 6 years ago
Erik Stein 3d648d143b CHANGES 7 years ago
Erik Stein 4426f8a0a6 Improved get_runtime_display. 7 years ago
Erik Stein bf02394c78 Fix. 7 years ago
Erik Stein 4360ad113a USE_TRANSLATABLE_FIELDS setting. 7 years ago
Erik Stein 20459d42f9 view_helpers template tags. 7 years ago
Erik Stein 415c462356 CHANGES 7 years ago
Erik Stein c0347aff22 Separated fields from methods in RuntimeMixin. 7 years ago
Erik Stein e4398b1b8f _text_list.html without unnecessary whitespace. 7 years ago
Erik Stein a18cb17f04 lang parameter for time_format, date_format. 7 years ago
Erik Stein db7d1ce01d PageTitlesMixin: Renamed `short_title`-field to `name` 7 years ago
Erik Stein c778a6c4d9 SlugTreeMixin: Check `parent` and `has_url` fields, too. 7 years ago
Erik Stein 5d5c08df9c Cleanup. 7 years ago
Erik Stein 9dbb8410c1 Option to allow empty runtimes (without even a start date). 7 years ago
Erik Stein 0c50efff57 Improved USE_TRANSLATABLE_FIELDS. 7 years ago
Erik Stein 45c51f9dd5 Additional text template tags. 7 years ago
Erik Stein 6f065a3245 CHANGES. 7 years ago
Erik Stein 1633c6cfc7 PageTitlesFunctionMixin. 7 years ago
j 10c8411e71 fix conditional ipdb import 7 years ago
j 40df76f089 use English as default language 7 years ago
Erik Stein ac5032f1c2 get_admin_url in debug_utils 7 years ago
Erik Stein d5f30521b8 Reverted unique=True enforcement. 7 years ago
Erik Stein a7896a9490 Make sure the slug field is never empty. 7 years ago
Erik Stein bedecf213f Enforce unique=True if unique_slug=True. 7 years ago
Erik Stein 1dfe913923 AutoSlugFied: Allow function for populate_from. 7 years ago
Erik Stein 72bf950ed6 Missing requirement. 7 years ago
Erik Stein 8c41c9ed5f Prepared release 0.2.5. 7 years ago
Erik Stein 62f4d0107c Improved slug functions, mixins; etc. 7 years ago
Erik Stein fba2f0a075 Version handling. 7 years ago
Erik Stein 630bc40396 Added AUTHORS. 7 years ago
Erik Stein 3346a8e15b Added RuntimeMixin. 7 years ago
Erik Stein 6912d6eeab Added PageTitlesFunctionMixin. 7 years ago
Erik Stein 6b128086d7 Improved get_version. 7 years ago
Erik Stein 6e2f80b49a AlphabeticalPaginationMixin fix. 7 years ago
j aee11eb907 include git commit count in version and update in setup.py 7 years ago
Erik Stein 0d9442d1d1 BeautifulSoup API. 7 years ago
Erik Stein 2aa0251ab6 AlphabeticalPaginationMixin. 7 years ago
Erik Stein 554306503d Date formats. 7 years ago
Erik Stein 7184337a01 Allow arbitrary length language codes, but strip part after "-". 7 years ago
Erik Stein 0566b3e1b4 Replaced old functions with recent version from shared utils. 7 years ago
Erik Stein cfbd5f1b76 no_links parameter for text_list template snippet. 7 years ago
Erik Stein e657cf4a5d More fixes. 7 years ago
Erik Stein 5135c488e4 htmlentities_to_unicode Python 2 compatibility. 7 years ago
Erik Stein e9b4acd83a Fix. 7 years ago
Erik Stein ae0c68f620 List to html functions. 7 years ago
Erik Stein 9e9a66b6ee Typo. 8 years ago
Erik Stein a36a5d517e Django 2.0 fixes. 8 years ago
j 165453f070 fix slimdown bold 8 years ago
Erik Stein 6e9e2d06d8 Fixed parentheses in format_date_range. 8 years ago
Erik Stein e60a42c0e5 get_language_order, improved lang_suffix 8 years ago
Erik Stein 5cc4db3c8b Don't allow lazy for slugify-functions 8 years ago
Erik Stein 9896f0e288 PageTitlesMixin with translated fields (optional); 8 years ago
Erik Stein 1ac6791a73 Added slimdown functionality. 8 years ago
Erik Stein 889f1b6dca Fix unique_slug: Force queryset to be iterable. 8 years ago
Erik Stein c94b328dd7 Fixed html_entities_to_unicode (works only in Python 3). 8 years ago
  1. 1
      .gitignore
  2. 2
      AUTHORS
  3. 102
      CHANGES
  4. 9
      README.md
  5. 33
      setup.py
  6. 14
      shared/utils/__init__.py
  7. 172
      shared/utils/admin_actions.py
  8. 10
      shared/utils/conf.py
  9. 20
      shared/utils/dateformat.py
  10. 12
      shared/utils/daytime.py
  11. 16
      shared/utils/debugging.py
  12. 111
      shared/utils/fields.py
  13. 2
      shared/utils/forms.py
  14. 9
      shared/utils/functional.py
  15. 0
      shared/utils/locale/de/__init__.py
  16. 32
      shared/utils/locale/de/formats.py
  17. 0
      shared/utils/locale/en/__init__.py
  18. 36
      shared/utils/locale/en/formats.py
  19. 4
      shared/utils/management/commands/fix_proxymodel_permissions.py
  20. 122
      shared/utils/models/events.py
  21. 130
      shared/utils/models/pages.py
  22. 173
      shared/utils/models/slugs.py
  23. 173
      shared/utils/models/workflow.py
  24. 25
      shared/utils/preview.py
  25. 30
      shared/utils/templates/admin/action_forms/admin_action_base.html
  26. 2
      shared/utils/templates/utils/_language_switcher_get.html
  27. 8
      shared/utils/templates/utils/_text_list.html
  28. 11
      shared/utils/templatetags/daterange.py
  29. 20
      shared/utils/templatetags/debug_utils.py
  30. 93
      shared/utils/templatetags/text_tags.py
  31. 28
      shared/utils/templatetags/translation_tags.py
  32. 29
      shared/utils/templatetags/view_helpers.py
  33. 116
      shared/utils/text.py
  34. 14
      shared/utils/timezone.py
  35. 130
      shared/utils/translation.py
  36. 33
      shared/utils/url_helpers.py
  37. 0
      shared/utils/views/__init__.py
  38. 48
      shared/utils/views/alphabetical_pagination.py
  39. 553
      shared/utils/views/daterange.py

1
.gitignore vendored

@ -58,3 +58,4 @@ docs/_build/
# PyBuilder # PyBuilder
target/ target/
_version.py

2
AUTHORS

@ -0,0 +1,2 @@
Erik Stein <erik@classlibrary.net>, 2008-
Jan Gerber <j@thing.net>

102
CHANGES

@ -0,0 +1,102 @@
0.2.31 2020-12-10
- Use "transliterate" alias for "translit/long" codec (Python 3.9 codecs compatibility)
0.2.30 2020-03-27
- Added strip_links template filter.
0.2.29 2019-09-05
- is_past and is_future template filters.
- LONG_DATE_FORMAT.
0.2.28 2019-07-19
- Fixed last of month calculation.
0.2.27 2019-06-19
- Deprecated get_short_title, use get_short_name.
0.2.26 2019-04-23
- Updated template class ussage in I18nDirectTemplateView.
- Removed (non-working) i18n_direct_to_template view function.
0.2.25 2019-04-17
- GET language switcher code generalized.
- Import reverse from django.urls.
0.2.24 2019-03-18
- SlugField: Leave empty slug if blank=True.
- SlugField: Don't force None to "none" string.
0.2.23 2019-03-18
- lang_suffix: 'field_name' paramter instead of 'fieldname'.
0.2.22 2019-02-20
- PageTitlesFunctionMixin: __str__ without HTML.
0.2.21 2019-02-19
- Mock datetime for preview.
- Class based daterange views.
0.2.20 2019-01-15
- select_template filter.
0.2.19 2019-01-31
- Added switch_language_url template tag.
0.2.18 2019-01-28
- Added dispatch_slug_path.
- Added AdminActionBase, TargetActionBase.
0.2.17 2018-12-17
- PageTitlesMixin: Slimdown name for get_short_title.
- Daytime utils.
- Debugging utils.
- Load correct translations tags in language switcher fragment.
0.2.16 2018-11-21
- Improved get_runtime_display.
0.2.15 2018-11-21
- Respect USE_TRANSLATABLE_FIELDS setting.
- view_helpers template tags.
0.2.14 2018-11-15
- lang parameter for time_format, date_format
- _text_list.html without unnecessary whitespace
- Separated fields from methods in RuntimeMixin
0.2.13 2018-10-13
- Improved USE_TRANSLATABLE_FIELDS
- Option to allow empty runtimes (without even a start date)
- SlugTreeMixin: Check `has_url` and `parent` fields, too
- PageTitlesMixin: Renamed `short_title`-field to `name`
0.2.12 2018-09-28
- Additional text template tags.
0.2.11 2018-09-27
- RichTextBase has no longer StyleMixin as default.
0.2.10 2018-09-27
- Fix in PageTitlesFunctionMixin
0.2.8 2018-09-21
- get_admin_url in debug_utils
0.2.7 2018-09-04
- Make sure the slug field is never empty.
- Allow function in AutoSlugfield populate_from.
0.2.6
- Added missing requirement for django-dirtyfields
0.2.5
- Improved slug functions, slug fields (DowngradingSlugField).
- Move location of SLUG_HELP, DEFAULT_SLUG and AutoSlugField, now in shared.utils.models.slugs.
- Added SlugTreeMixin and helper signal functions.
- __str__ function for PageTitlesMixin
0.2.4
- Added RuntimeMixin.
0.2.3
- Added PageTitlesFunctionMixin.

9
README.md

@ -1,3 +1,12 @@
# django-shared-utils # django-shared-utils
Mix of Python and Django utility functions, classed etc. Mix of Python and Django utility functions, classed etc.
To enable the needed custom date formats add
FORMAT_MODULE_PATH = [
'shared.utils.locale',
]
to your settings file.

33
setup.py

@ -1,14 +1,39 @@
#!/usr/bin/env python #!/usr/bin/env python
from io import open from io import open
from setuptools import find_packages, setup
import os import os
from setuptools import setup, find_packages import re
import subprocess
"""
Use `git tag -a -m "Release 1.0.0" 1.0.0` to tag a release;
`python setup.py --version` to update the _version.py file.
"""
def get_version(prefix): def get_version(prefix):
import re if os.path.exists('.git'):
with open(os.path.join(prefix, '__init__.py')) as fd: parts = subprocess.check_output(['git', 'describe', '--tags']).decode().strip().split('-')
if len(parts) == 3:
version = '{}.{}+{}'.format(*parts)
else:
version = parts[0]
version_py = "__version__ = '{}'".format(version)
_version = os.path.join(prefix, '_version.py')
if not os.path.exists(_version) or open(_version).read().strip() != version_py:
with open(_version, 'w') as fd:
fd.write(version_py)
return version
else:
for f in ('_version.py', '__init__.py'):
f = os.path.join(prefix, f)
if os.path.exists(f):
with open(f) as fd:
metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fd.read())) metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fd.read()))
if 'version' in metadata:
break
return metadata['version'] return metadata['version']
@ -38,6 +63,8 @@ setup(
'python-dateutil', 'python-dateutil',
'beautifulsoup4', 'beautifulsoup4',
'translitcodec', 'translitcodec',
'django-dirtyfields',
'six',
], ],
classifiers=[ classifiers=[
# 'Development Status :: 4 - Beta', # 'Development Status :: 4 - Beta',

14
shared/utils/__init__.py

@ -1,14 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2007-2016
__version__ = '0.2.1'
VERSION = tuple(int(d) for d in __version__.split('.'))
try: try:
from django.utils.translation import ugettext_lazy as _ from ._version import __version__
SLUG_HELP = _("Kurzfassung des Namens für die Adresszeile im Browser. Vorzugsweise englisch, keine Umlaute, nur Bindestrich als Sonderzeichen.")
except ImportError: except ImportError:
pass __version__ = '0.0.0+see-git-tag'
VERSION = __version__.split('+')
VERSION = tuple(list(map(int, VERSION[0].split('.'))) + VERSION[1:])

172
shared/utils/admin_actions.py

@ -0,0 +1,172 @@
from django import forms
from django.contrib import admin
from django.contrib.admin.widgets import ForeignKeyRawIdWidget
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import ngettext, gettext_lazy as _
class AdminActionBase:
action_name = None
options_template_name = 'admin/action_forms/admin_action_base.html'
title = None
queryset_action_label = None
action_button_label = None
def __init__(self, action_name=None):
if action_name:
self.action_name = action_name
def apply(self, queryset, form):
raise NotImplementedError
def get_message(self, count):
raise NotImplementedError
def get_failure_message(self, count, failure_count):
raise NotImplementedError
class BaseForm(forms.Form):
_selected_action = forms.CharField(widget=forms.MultipleHiddenInput)
def get_form_class(self, modeladmin, request, queryset):
"""
Example:
class CustomForm(BaseForm)
chosen_target = forms.ModelChoiceField(
label=_("Choose target itembundle"),
queryset=ItemBundle.objects.exclude(pk__in=queryset),
widget=ForeignKeyRawIdWidget(modeladmin.model._meta.get_field('parent').rel, modeladmin.admin_site),
empty_label=_("Root level"), required=False)
return CustomForm
"""
raise NotImplementedError
def __call__(self, modeladmin, request, queryset):
form_class = self.get_form_class(modeladmin, request, queryset)
form = None
if 'apply' in request.POST:
form = form_class(request.POST)
if form.is_valid():
queryset_count = queryset.count()
count = self.apply(queryset, form)
failure_count = queryset_count - count
if failure_count > 0:
message = self.get_failure_message(form, count, failure_count)
else:
message = self.get_message(form, count)
modeladmin.message_user(request, message)
return HttpResponseRedirect(request.get_full_path())
if 'cancel' in request.POST:
return HttpResponseRedirect(request.get_full_path())
if not form:
form = form_class(initial={
'_selected_action': request.POST.getlist(
admin.ACTION_CHECKBOX_NAME),
})
return render(request, self.options_template_name, context={
'action_name': self.action_name,
'title': self.title,
'queryset_action_label': self.queryset_action_label,
'action_button_label': self.action_button_label,
'queryset': queryset,
'action_form': form,
'opts': modeladmin.model._meta,
})
class TargetActionBase(AdminActionBase):
target_model = None
related_field_name = None
def get_form_class(self, modeladmin, request, queryset):
class ChooseTargetForm(AdminActionBase.BaseForm):
chosen_target = forms.ModelChoiceField(
label=_("Choose {}".format(self.target_model._meta.verbose_name)),
queryset=self.target_model.objects.exclude(pk__in=queryset),
widget=ForeignKeyRawIdWidget(
modeladmin.model._meta.get_field(self.related_field_name).rel,
modeladmin.admin_site
),
)
return ChooseTargetForm
def get_target(self, form):
return form.cleaned_data['chosen_target']
def get_message(self, form, count):
chosen_target = form.cleaned_data['chosen_target']
target_name = chosen_target.name
return ngettext(
'Successfully added %(count)d %(verbose_name)s to %(target)s.',
'Successfully added %(count)d %(verbose_name_plural)s to %(target)s.',
count) % {
'count': count,
'verbose_name': self.target_model._meta.verbose_name,
'verbose_name_plural': self.target_model._meta.verbose_name_plural,
'target': target_name}
def get_failure_message(self, form, count, failure_count):
chosen_target = form.cleaned_data['chosen_target']
target_name = chosen_target.name
return ngettext(
'Adding %(count)d %(verbose_name)s to %(target)s, %(failure_count)s failed or skipped.',
'Adding %(count)d %(verbose_name_plural)s to %(target)s, %(failure_count)s failed or skipped.',
count) % {
'count': count,
'verbose_name': self.target_model._meta.verbose_name,
'verbose_name_plural': self.target_model._meta.verbose_name_plural,
'target': target_name,
'failure_count': failure_count}
class ChangeForeignKeyAction(AdminActionBase):
# Subclass::
# action_name = 'change_fieldname_action'
# field_name_label = _("<field name>")
# title = _("Change <field name>")
# queryset_action_label = _("For the following items the <field name> will be changed:")
# action_button_label = _("Change <field name>")
def apply(self, queryset, form):
raise NotImplementedError("apply must be implemented by the subclass.")
# Subclass::
# new_value = form.cleaned_data['new_value']
# return change_fieldname_state(queryset, new_value)
def get_value_choices_queryset(self, modeladmin, request, queryset):
raise NotImplementedError("get_value_choices_queryset must be implemented by the subclass.")
def get_form_class(self, modeladmin, request, queryset):
class ChooseValueForm(AdminActionBase.BaseForm):
new_value = forms.ModelChoiceField(
label=_("Choose %(field_name)s" % {'field_name': self.field_name_label}),
queryset=self.get_value_choices_queryset(modeladmin, request, queryset),
required=True)
return ChooseValueForm
def get_message(self, form, count):
new_value = form.cleaned_data['new_value']
return ngettext(
'Successfully changed %(field_name)s of %(count)d item to %(new_value)s.',
'Successfully changed %(field_name)s of %(count)d items to %(new_value)s.',
count) % {
'field_name': self.field_name_label,
'count': count,
'new_value': new_value}
def get_failure_message(self, form, count, failure_count):
new_value = form.cleaned_data['new_value']
return ngettext(
'Successfully changed %(field_name)s of %(count)d item to %(new_value)s, %(failure_count)s failed or skipped.',
'Successfully changed %(field_name)s of %(count)d items to %(new_value)s, %(failure_count)s failed or skipped.',
count) % {
'field_name': self.field_name_label,
'count': count,
'new_value': new_value,
'failure_count': failure_count}

10
shared/utils/conf.py

@ -0,0 +1,10 @@
from django.conf import settings
USE_TRANSLATABLE_FIELDS = (
getattr(settings, 'CONTENT_PLUGINS_USE_TRANSLATABLE_FIELDS', False) or
getattr(settings, 'USE_TRANSLATABLE_FIELDS', False)
)
USE_PREVIEW_DATETIME = getattr(settings, 'USE_PREVIEW_DATETIME', False)
# You also have to set PREVIEW_DATETIME = datetime(...)

20
shared/utils/dateformat.py

@ -15,8 +15,8 @@ import re
from django.conf import settings from django.conf import settings
from django.utils.dateformat import DateFormat, re_escaped from django.utils.dateformat import DateFormat, re_escaped
from django.utils.formats import get_format from django.utils.formats import get_format
from django.utils.encoding import force_text from django.utils.encoding import force_str
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
# All get_format call make sure that there is a language code returned # All get_format call make sure that there is a language code returned
# (our get_language at least returns FALLBACK_LANGUAGE_CODE), because self-defined # (our get_language at least returns FALLBACK_LANGUAGE_CODE), because self-defined
@ -45,9 +45,9 @@ class ExtendedFormat(DateFormat):
def format(self, formatstr): def format(self, formatstr):
pieces = [] pieces = []
for i, piece in enumerate(re_formatchars.split(force_text(formatstr))): for i, piece in enumerate(re_formatchars.split(force_str(formatstr))):
if i % 2: if i % 2:
pieces.append(force_text(getattr(self, piece)())) pieces.append(force_str(getattr(self, piece)()))
elif piece: elif piece:
pieces.append(re_escaped.sub(r'\1', piece)) pieces.append(re_escaped.sub(r'\1', piece))
return ''.join(pieces) return ''.join(pieces)
@ -59,15 +59,19 @@ def format(value, format):
return df.format(format) return df.format(format)
def time_format(value, format=None, use_l10n=None): def time_format(value, format=None, use_l10n=None, lang=None):
# Copy of django.utils.dateformat.time_format, using our extended formatter # Copy of django.utils.dateformat.time_format, using our extended formatter
if not lang:
lang = get_language()
tf = ExtendedFormat(value) tf = ExtendedFormat(value)
return tf.format(get_format(format or 'DATE_FORMAT', use_l10n=use_l10n, lang=get_language())) return tf.format(get_format(format or 'DATE_FORMAT', use_l10n=use_l10n, lang=lang))
def date_format(value, format=None, use_l10n=None): def date_format(value, format=None, use_l10n=None, lang=None):
if not lang:
lang = get_language()
df = ExtendedFormat(value) df = ExtendedFormat(value)
return df.format(get_format(format or 'DATE_FORMAT', use_l10n=use_l10n, lang=get_language())) return df.format(get_format(format or 'DATE_FORMAT', use_l10n=use_l10n, lang=lang))
def _normalize_variant(variant): def _normalize_variant(variant):

12
shared/utils/daytime.py

@ -0,0 +1,12 @@
from django.utils import timezone
def midnight(dt=None):
"""
Returns upcoming midnight.
"""
if not dt:
dt = timezone.now()
return dt.replace(hour=0, minute=0, second=0, microsecond=0) + \
timezone.timedelta(days=1)

16
shared/utils/debugging.py

@ -0,0 +1,16 @@
import logging
from django.template import TemplateDoesNotExist
from django.template.loader import select_template
logger = logging.getLogger(__name__)
def log_select_template(template_names):
logger.info("\nPossible template names:")
logger.info("\n".join(template_names))
try:
logger.info("Chosen: %s" % select_template(template_names).template.name)
except TemplateDoesNotExist:
logger.warn(" Could not find a matching template file.")

111
shared/utils/fields.py

@ -1,84 +1,59 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2008-2015
import re import re
from django.db.models import fields
from django.utils import six
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _
if six.PY3:
from functools import reduce
from .text import slugify_long as slugify from .text import slugify
from . import SLUG_HELP
DEFAULT_SLUG = "item" def uniquify_field_value(instance, field_name, value, max_length=None, queryset=None):
def unique_slug(instance, slug_field, slug_value, max_length=50, queryset=None):
""" """
TODO Doesn't work with model inheritance, where the slug field is part of the parent class. Makes a char field value unique by appending an index, taking care of the
field's max length.
FIXME Doesn't work with model inheritance, where the field is part of the parent class.
""" """
if not slug_value:
raise ValueError("Cannot uniquify empty slug")
orig_slug = slug = slugify(force_text(slug_value))
index = 0
if not queryset: if not queryset:
queryset = instance.__class__._default_manager.get_queryset() queryset = instance._meta.default_manager.get_queryset()
def get_similar_slugs(slug): field = instance._meta.get_field(field_name)
if not max_length:
max_length = instance._meta.get_field(field_name).max_length
if not value:
if field.blank:
# Special case: Make sure only one row has ean empty value
if queryset.exclude(pk=instance.pk).filter(**{field_name: ''}).exists():
raise ValueError("Only one blank (root) entry allowed.")
return
else:
raise ValueError("Cannot uniquify empty value")
# TODO Instead get value from instance.field, or use a default value?
def get_similar_values(value):
return queryset.exclude(pk=instance.pk) \ return queryset.exclude(pk=instance.pk) \
.filter(**{"%s__istartswith" % slug_field: slug}).values_list(slug_field, flat=True) .filter(**{"%s__istartswith" % field_name: value}).values_list(field_name, flat=True)
similar_slugs = get_similar_slugs(slug) # Find already existing counter
while slug in similar_slugs or len(slug) > max_length: m = re.match(r'(.+)(-\d+)$', value)
if m:
base_value, counter = m.groups()
index = int(counter.strip("-")) + 1
else:
base_value = value
index = 2 # Begin appending "-2"
similar_values = get_similar_values(value)
while value in similar_values or len(value) > max_length:
value = "%s-%i" % (base_value, index)
if len(value) > max_length:
base_value = base_value[:-(len(value) - max_length)]
value = "%s-%i" % (base_value, index)
similar_values = get_similar_values(base_value)
index += 1 index += 1
slug = "%s-%i" % (orig_slug, index) return value
if len(slug) > max_length:
orig_slug = orig_slug[:-(len(slug) - max_length)]
slug = "%s-%i" % (orig_slug, index)
similar_slugs = get_similar_slugs(orig_slug)
return slug
def unique_slug2(instance, slug_source, slug_field):
slug = slugify(slug_source)
all_slugs = [sl.values()[0] for sl in instance.__class__._default_manager.values(slug_field)]
if slug in all_slugs:
counter_finder = re.compile(r'-\d+$')
counter = 2
slug = "%s-%i" % (slug, counter)
while slug in all_slugs:
slug = re.sub(counter_finder, "-%i" % counter, slug)
counter += 1
return slug
class AutoSlugField(fields.SlugField):
# AutoSlugField based on http://www.djangosnippets.org/snippets/728/
def __init__(self, *args, **kwargs):
kwargs.setdefault('max_length', 50)
kwargs.setdefault('help_text', SLUG_HELP)
if 'populate_from' in kwargs:
self.populate_from = kwargs.pop('populate_from')
self.unique_slug = kwargs.pop('unique_slug', False)
super(AutoSlugField, self).__init__(*args, **kwargs)
def pre_save(self, model_instance, add):
value = getattr(model_instance, self.attname)
if not value:
if hasattr(self, 'populate_from'):
# Follow dotted path (e.g. "occupation.corporation.name")
value = reduce(lambda obj, attr: getattr(obj, attr), self.populate_from.split("."), model_instance)
if callable(value):
value = value()
if not value:
value = DEFAULT_SLUG
if self.unique_slug:
return unique_slug(model_instance, self.name, value, max_length=self.max_length)
else:
return slugify(value)
# TODO Remove alias
def unique_slug(instance, slug_field, slug_value, max_length=50, queryset=None):
slug_value = slugify(slug_value)
return uniquify_field_value(instance, slug_field, slug_value, max_length=50, queryset=None)

2
shared/utils/forms.py

@ -4,7 +4,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
# from http://stackoverflow.com/questions/877723/inline-form-validation-in-django#877920 # from http://stackoverflow.com/questions/877723/inline-form-validation-in-django#877920

9
shared/utils/functional.py

@ -1,8 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2017
def firstof(*args, default=None): def firstof(*args, default=None):
""" """
Returns the first value which is neither empty nor None. Returns the first value which is neither empty nor None.
@ -17,3 +12,7 @@ def firstof(*args, default=None):
if value: if value:
return value return value
return default return default
def join_existing(delimiter, *items):
return delimiter.join([str(i) for i in items if i])

0
shared/utils/locale/de/__init__.py

32
shared/utils/locale/de/formats.py

@ -0,0 +1,32 @@
DATETIME_FORMAT = 'j. F Y H:i'
SHORT_YEAR_FORMAT = 'Y'
SHORT_MONTH_FORMAT = 'b'
SHORT_DAY_FORMAT = 'j.'
SHORT_DAY_MONTH_FORMAT = 'j.n.'
SHORT_YEAR_MONTH_FORMAT = 'n/Y'
SHORT_DATE_FORMAT = 'j.n.Y'
SHORT_TIME_FORMAT = 'q'
LONG_DATE_FORMAT = 'l, j. F Y'
YEAR_FORMAT = 'Y'
MONTH_FORMAT = 'F'
DAY_FORMAT = 'j.'
DAY_MONTH_FORMAT = 'j. F'
# from Django: YEAR_MONTH_FORMAT
SHORT_DAYONLY_FORMAT = SHORT_DAY_FORMAT # FIXME Deprecated
SHORT_DAYMONTH_FORMAT = SHORT_DAY_MONTH_FORMAT # FIXME Deprecated
DAYONLY_FORMAT = DAY_FORMAT # FIXME Deprecated
DAYMONTH_FORMAT = DAY_MONTH_FORMAT # FIXME Deprecated
DATE_RANGE_SEPARATOR = ''
OPENING_HOURS_DATE_FORMAT = 'D j. F'
OPENING_HOURS_TIME_FORMAT = 'q'

0
shared/utils/locale/en/__init__.py

36
shared/utils/locale/en/formats.py

@ -0,0 +1,36 @@
# UK Style Date Format
DATETIME_FORMAT = 'j F Y H:i'
SHORT_YEAR_FORMAT = 'Y'
SHORT_MONTH_FORMAT = 'b'
SHORT_DAY_FORMAT = 'd'
SHORT_DAY_MONTH_FORMAT = 'd/n'
SHORT_YEAR_MONTH_FORMAT = 'n/Y'
SHORT_DATE_FORMAT = 'd/n/Y'
SHORT_TIME_FORMAT = 'q'
LONG_DATE_FORMAT = 'l, j. F Y'
YEAR_FORMAT = 'Y'
MONTH_FORMAT = 'F'
DAY_FORMAT = 'j'
DAY_MONTH_FORMAT = 'j F'
DATE_FORMAT = 'j F Y'
# from Django: YEAR_MONTH_FORMAT
SHORT_DAYONLY_FORMAT = SHORT_DAY_FORMAT # FIXME Deprecated
SHORT_DAYMONTH_FORMAT = SHORT_DAY_MONTH_FORMAT # FIXME Deprecated
DAYONLY_FORMAT = DAY_FORMAT # FIXME Deprecated
DAYMONTH_FORMAT = DAY_MONTH_FORMAT # FIXME Deprecated
DATE_RANGE_SEPARATOR = ''
OPENING_HOURS_DATE_FORMAT = 'D j F'
OPENING_HOURS_TIME_FORMAT = 'q'

4
shared/utils/management/commands/fix_proxymodel_permissions.py

@ -1,11 +1,9 @@
# -*- coding: utf-8 -*-
"""Add permissions for proxy model. """Add permissions for proxy model.
This is needed because of the bug https://code.djangoproject.com/ticket/11154 This is needed because of the bug https://code.djangoproject.com/ticket/11154
in Django (as of 1.6, it's not fixed). in Django (as of 1.6, it's not fixed).
When a permission is created for a proxy model, it actually creates if for it's When a permission is created for a proxy model, it gets actually created for the
base model app_label (eg: for "article" instead of "about", for the About proxy base model app_label (eg: for "article" instead of "about", for the About proxy
model). model).

122
shared/utils/models/events.py

@ -0,0 +1,122 @@
import datetime
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from ..conf import USE_TRANSLATABLE_FIELDS
from ..dateformat import format_partial_date, format_date_range
from ..dates import get_last_of_month
if USE_TRANSLATABLE_FIELDS:
from shared.multilingual.utils.fields import TranslatableCharField
else:
TranslatableCharField = models.CharField
# FIXME Currently Python cannot handle BC dates
# Possible solution: https://github.com/okfn/datautil/blob/master/datautil/date.py
MIN_DATE = datetime.date.min
MAX_DATE = datetime.date.max
class RuntimeBehaviour:
"""
Allows a model to have partially defined from-/to-dates;
at least one year value must be entered.
"""
start_date_field_name = '_from_sort_date'
end_date_field_name = '_until_sort_date'
allow_empty_runtime = False
def clean(self):
if not self.allow_empty_runtime and not (self.from_year_value or self.until_year_value):
raise ValidationError(_('Please enter either a from or an until date year.'))
# Update from/sort date fields
if self.from_year_value:
setattr(self, self.start_date_field_name, datetime.date(
self.from_year_value, self.from_month_value or 1, self.from_day_value or 1))
else:
setattr(self, self.start_date_field_name, MIN_DATE)
if self.until_year_value:
d = datetime.date(self.until_year_value, self.until_month_value or 12, 1)
setattr(self, self.end_date_field_name, get_last_of_month(d))
else:
setattr(self, self.end_date_field_name, MAX_DATE)
def save(self, *args, **kwargs):
self.full_clean()
super(RuntimeBehaviour, self).save(*args, **kwargs)
@property
def from_date(self):
return getattr(self, self.start_date_field_name)
@property
def until_date(self):
return getattr(self, self.end_date_field_name)
# TODO ? Implement @from_date.setter, @until_date.setter
def get_from_display(self):
return self.runtime_text or format_partial_date(
self.from_year_value,
self.from_month_value,
self.from_day_value)
get_from_display.admin_order_field = start_date_field_name
get_from_display.short_description = _("from")
def get_until_display(self):
if self.runtime_text:
return ""
else:
return format_partial_date(
self.until_year_value,
self.until_month_value,
self.until_day_value)
get_until_display.admin_order_field = end_date_field_name
get_until_display.short_description = _("until")
def get_runtime_display(self):
# TODO Improve
if self.runtime_text:
return self.runtime_text
elif self.from_day_value and self.from_month_value:
return format_date_range(self.from_date, self.until_date)
else:
f = self.get_from_display()
# Single point
if self.from_date == self.until_date:
return f
u = self.get_until_display()
if f and u and not f == u:
return "{}{}".format(f, u)
else:
return f or u
class RuntimeMixin(RuntimeBehaviour, models.Model):
from_year_value = models.PositiveIntegerField(_("starting year"), null=True, blank=True)
from_month_value = models.PositiveIntegerField(_("starting month"), null=True, blank=True)
from_day_value = models.PositiveIntegerField(_("starting day"), null=True, blank=True)
until_year_value = models.PositiveIntegerField(_("ending year"), null=True, blank=True)
until_month_value = models.PositiveIntegerField(_("ending month"), null=True, blank=True)
until_day_value = models.PositiveIntegerField(_("ending day"), null=True, blank=True)
_from_sort_date = models.DateField(_("from"), editable=False)
_until_sort_date = models.DateField(_("until"), editable=False)
runtime_text = TranslatableCharField(_("Zeitangabe Textform"),
max_length=200, null=True, blank=True,
help_text=_("Alternativer Text für die Laufzeitangabe"))
start_date_field_name = '_from_sort_date'
end_date_field_name = '_until_sort_date'
allow_empty_runtime = False
class Meta:
abstract = True

130
shared/utils/models/pages.py

@ -1,64 +1,116 @@
# -*- coding: utf-8 -*- from django.conf import settings
from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2017
from django.db import models from django.db import models
from django.utils.encoding import python_2_unicode_compatible from django.utils.html import strip_tags
from django.utils.text import normalize_newlines from django.utils.text import normalize_newlines, Truncator
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ..fields import AutoSlugField from shared.multilingual.utils import i18n_fields_list
from ..functional import firstof from ..functional import firstof
from ..text import slimdown
from .slugs import DowngradingSlugField
from ..conf import USE_TRANSLATABLE_FIELDS
if USE_TRANSLATABLE_FIELDS:
from shared.multilingual.utils.fields import (
TranslatableCharField,
TranslatableTextField
)
# TODO populate_from should use settings.LANGUAGE_CODE
# FIXME Wrong spelling!
SLUG_POPULATE_FROM = getattr(settings, 'SLUG_POPULATE_FROM', 'name_en')
else:
TranslatableCharField = models.CharField
TranslatableTextField = models.TextField
SLUG_POPULATE_FROM = 'name'
# TODO Make slimdown optional through settings
# TODO Leave window_title alone, do not slimdown
# TODO Use translatable fields by default
@python_2_unicode_compatible class PageTitlesBehaviour:
class PageTitlesMixin(models.Model):
""" """
A model mixin containg title and slug field for models serving as website Implements fallback behaviour.
pages with an URL.
""" """
short_title = models.CharField(_("Name"), max_length=50)
slug = AutoSlugField(_("URL-Name"), max_length=200, populate_from='short_title', unique_slug=True)
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)
class Meta:
abstract = True
def __str__(self): def __str__(self):
return self.short_title return strip_tags(slimdown(self.get_short_name()))
def get_title(self): def get_title(self):
return firstof( return self.get_first_title_line() or \
self.title, self.name
self.short_title
)
def get_window_title(self): def get_short_name(self):
return firstof( return getattr(self, 'short_name', '') or \
self.window_title, Truncator(self.name).words(5, truncate="...")
self.short_title,
self.get_first_title_line(),
)
def get_first_title_line(self): def get_first_title_line(self):
""" """
First line of title field. First line of title field or self.name..
""" """
return normalize_newlines(self.get_title()).partition("\n")[0] return normalize_newlines(getattr(self, 'long_title', '') or '').partition("\n")[0]
def get_subtitle_lines(self): def get_subtitle_lines(self):
""" """
All but first line of the long title field. All but first line of the long title field.
""" """
return normalize_newlines(self.title).partition("\n")[2] return normalize_newlines(getattr(self, 'long_title', '') or '').partition("\n")[2]
def get_window_title(self):
return firstof(
getattr(self, 'window_title', None),
strip_tags(slimdown(self.get_short_name())),
strip_tags(slimdown(self.get_first_title_line())),
)
PageTitlesFunctionMixin = PageTitlesBehaviour
class PageTitlesMixin(PageTitlesBehaviour, models.Model):
"""
A model mixin containg title and slug fields for models serving
as web pages with an URL.
name: Main naming field, all other fields besides `slug`
are optional.
short_name: For menus etc.
long_title: Long title, composed of title line (first line) and
subtitle (all other lines)
window_title: Browser window title, also for search engine's results
slug: URL name
"""
name = TranslatableCharField(_("Name"),
max_length=250)
sort_name = TranslatableCharField(_("Short Name"),
max_length=250, null=True, blank=True,
help_text=_("Optional, used for sorting."))
short_name = TranslatableCharField(_("Short Name"),
max_length=25, null=True, blank=True,
help_text=_("Optional, used for menus etc."))
long_title = TranslatableTextField(_("title/subtitle"),
null=True, blank=True, max_length=500,
help_text=_("Optional, long title for page content region. FIrst line is the title, other lines are the subtitle. Simplified Markdown."))
window_title = TranslatableCharField(_("window title"),
null=True, blank=True, max_length=300)
slug = DowngradingSlugField(_("URL-Name"), max_length=200,
populate_from=SLUG_POPULATE_FROM, unique_slug=True, blank=True)
class Meta:
abstract = True
ordering = ['sort_name', 'name']
class PageTitleAdminMixin(object): class PageTitlesAdminMixin:
search_fields = ['short_title', 'title', 'window_title'] list_display = ['name', 'slug']
list_display = ['short_title', 'slug'] search_fields = ['name', 'short_name', 'long_title', 'window_title']
if USE_TRANSLATABLE_FIELDS:
search_fields = i18n_fields_list(search_fields)
prepopulated_fields = { prepopulated_fields = {
'slug': ('short_title',), 'slug': [SLUG_POPULATE_FROM]
} }

173
shared/utils/models/slugs.py

@ -0,0 +1,173 @@
from functools import reduce
from django.conf import settings
from django.core import validators
from django.db import models
from django.db.models import fields as django_fields
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from dirtyfields import DirtyFieldsMixin
from ..text import slugify, downgrading_slugify
# TODO: Use english
SLUG_HELP = _("Kurzfassung des Namens für die Adresszeile im Browser. Vorzugsweise englisch, keine Umlaute, nur Bindestrich als Sonderzeichen.")
slug_re = validators._lazy_re_compile(r'^[-a-z0-9]+\Z')
validate_downgraded_slug = validators.RegexValidator(
slug_re,
_("Enter a valid 'slug' consisting of lower-case letters, numbers or hyphens."),
'invalid'
)
class AutoSlugField(django_fields.SlugField):
"""
SlugField which optionally populates the value and/or makes sure that
the value is unique. By default as stricter slugify function is used.
populate_from: Field name
unique_slug: Boolean, automatically make the field value unique
"""
def __init__(self, *args, **kwargs):
kwargs.setdefault('max_length', 50)
kwargs.setdefault('help_text', SLUG_HELP)
if 'populate_from' in kwargs:
self.populate_from = kwargs.pop('populate_from')
self.unique_slug = kwargs.pop('unique_slug', False)
# FIXME Enforce unique=True
# if self.unique_slug:
# kwargs['unique'] = True
# TODO Refactor: We need a sibling FormField which also does our pre_save work and then validates correctly (called from Model.clean_fields)
super(AutoSlugField, self).__init__(*args, **kwargs)
def slugify(self, value):
return slugify(value)
def pre_save(self, model_instance, add):
value = getattr(model_instance, self.attname)
if not value:
if hasattr(self, 'populate_from'):
if callable(self.populate_from):
value = self.populate_from(model_instance, self)
else:
# Follow dotted path (e.g. "occupation.corporation.name")
value = reduce(lambda obj, attr: getattr(obj, attr),
self.populate_from.split("."), model_instance)
if callable(value):
value = value()
value = self.slugify(value)
if not value and not self.blank:
value = model_instance._meta.model_name
if self.unique_slug:
# TODO Move import to top of file once AutoSlugField is removed from shared.utils.fields and we no longer have a circular import
from ..fields import uniquify_field_value
return uniquify_field_value(
model_instance, self.name, value, max_length=self.max_length)
else:
return value
class DowngradingSlugField(AutoSlugField):
"""
SlugField which allows only lowercase ASCII characters and the dash,
automatically downgrading/replacing the entered content.
"""
default_validators = [validate_downgraded_slug]
def __init__(self, *args, **kwargs):
kwargs['allow_unicode'] = False
super(DowngradingSlugField, self).__init__(*args, **kwargs)
def slugify(self, value):
return downgrading_slugify(value)
def to_python(self, value):
# Downgrade immediately so that validators work
value = super().to_python(value)
return self.slugify(value)
def formfield(self, **kwargs):
# Remove the slug validator from the form field so that we can modify
# the field value in the model
field = super().formfield(**kwargs)
if field.default_validators:
try:
field.validators.remove(field.default_validators[0])
except ValueError:
pass
return field
class SlugTreeMixin(DirtyFieldsMixin, models.Model):
"""
Expects a `slug` and a `parent` field.
`has_url`: The node itself has an URL. It is possible that a node does
node have an URL, but its children have.
Maintains the `slug_path` field of the node and its children, watching if
either the `slug`, `parent` or `has_url` fields have changed.
"""
slug_path = models.CharField(_("URL path"),
unique=True, max_length=2000, editable=False)
# TODO Add validator to slug_path?
# validators=[
# RegexValidator(
# regex=r"^/(|.+/)$",
# message=_("Path must start and end with a slash (/)."),
# )
# ],
# TODO Make slug_path manually overridable (see feincms3 -> use_static_path)
has_url = models.BooleanField(_("has webaddress"), default=True)
FIELDS_TO_CHECK = ['slug', 'parent', 'has_url']
class Meta:
abstract = True
def _get_slug_path(self):
if self.pk:
ancestors = self.get_ancestors(include_self=False).filter(has_url=True).values_list('slug', flat=True)
parts = list(ancestors)
else:
parts = []
if self.slug:
parts += [self.slug]
return "/".join(parts)
def _rebuild_descendants_slug_path(self):
for p in self.get_descendants():
p.slug_path = p._get_slug_path()
p.save()
@receiver(pre_save)
def slug_tree_mixin_pre_save(sender, instance, **kwargs):
if isinstance(instance, SlugTreeMixin):
# FIXME: find a way to not always call this
instance.slug = instance._meta.get_field('slug').pre_save(instance, False)
instance.slug_path = instance._get_slug_path()
@receiver(post_save)
def slug_tree_mixin_post_save(sender, instance, **kwargs):
if isinstance(instance, SlugTreeMixin):
if kwargs.get('created'):
# Always get a new database instance before saving again
# or MPTTModel.save() will interpret the newly .save as
# a forbidden tree move action
# FIXME Not clear if this is a proper solution -> get rid of the slug_path stuff altogether
instance_copy = type(instance).objects.get(pk=instance.pk)
instance_copy.slug_path = instance_copy._get_slug_path()
if 'slug_path' in instance_copy.get_dirty_fields().keys():
instance_copy.save()
elif instance.get_dirty_fields().keys() & {'slug_path'}:
instance._rebuild_descendants_slug_path()

173
shared/utils/models/workflow.py

@ -0,0 +1,173 @@
from django.conf import settings
from django.contrib import admin
from django.db import models
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
#
# Workflow Helpers
"""
States:
- inactive (either active=False or publication date not reached)
- public (active or archived)
- active (public, not archived)
- archived (public, not active)
"""
class WorkflowQuerySet(models.QuerySet):
@classmethod
def published_filter(cls):
return {
"is_published": True,
"publication_datetime__lte": timezone.now(),
}
@classmethod
def active_filter(cls):
return {
"is_published": True,
"publication_datetime__lte": timezone.now(),
"archiving_datetime__gt": timezone.now(),
}
@classmethod
def archived_filter(cls):
return {
"is_published": True,
"publication_datetime__lte": timezone.now(),
"archiving_datetime__lte": timezone.now(),
}
@classmethod
def future_filter(cls):
return {
"is_published": True,
"publication_datetime__gt": timezone.now(),
"archiving_datetime__gt": timezone.now(),
}
def unpublished(self):
return self.exclude(**self.published_filter())
def published(self):
# Active or archived
return self.filter(**self.published_filter())
public = published
def active(self):
return self.filter(**self.active_filter())
def archived(self):
return self.filter(**self.archived_filter())
def future(self):
return self.filter(**self.future_filter())
class ManyToManyWorkflowQuerySet(WorkflowQuerySet):
def _build_related_filters(self, filter_template):
"""
Transforms all filter rules to match any related models which
itself is workflow managed.
"""
filter = {}
for field in self.model._meta.get_fields():
if field.many_to_one and isinstance(field.related_model._meta.default_manager, WorkflowManager):
field.name
for path, value in filter_template.items():
filter[f'{field.name}__{path}'] = value
return filter
def published(self):
# Active or archived
return self.exclude(**self._build_related_filters(self.published_filter()))
public = published
def active(self):
return self.filter(**self._build_related_filters(self.active_filter()))
def archived(self):
return self.filter(**self._build_related_filters(self.archived_filter()))
def future(self):
return self.filter(**self._build_related_filters(self.future_filter()))
class WorkflowManager(models.Manager.from_queryset(WorkflowQuerySet)):
pass
class ManyToManyWorkflowManager(models.Manager.from_queryset(ManyToManyWorkflowQuerySet)):
pass
class WorkflowMixin(models.Model):
creation_datetime = models.DateTimeField(auto_now_add=True)
modification_datetime = models.DateTimeField(auto_now=True)
creation_user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
editable=False,
on_delete=models.SET_NULL, related_name='+')
modification_user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True,
editable=False,
on_delete=models.SET_NULL, related_name='+')
is_published = models.BooleanField(_("Published"), default=False)
publication_datetime = models.DateTimeField(_("Publication Date"), default=timezone.now)
archiving_datetime = models.DateTimeField(_("Archiving Date"), default=timezone.datetime.max)
objects = WorkflowManager()
class Meta:
abstract = True
ordering = ['-publication_datetime'] # Most recent first
@property
def workflow_status(self):
now = timezone.now()
if not self.is_published or self.publication_datetime > now:
return 'unpublished'
elif self.publication_datetime <= now and self.archiving_datetime > now:
return 'active'
else:
return 'archived'
def display_is_published(obj):
return obj.is_published and obj.publication_datetime <= timezone.now()
display_is_published.boolean = True
display_is_published.short_description = _("Aktiv")
class PublicationStateListFilter(admin.SimpleListFilter):
title = _("Published")
# Parameter for the filter that will be used in the URL query.
parameter_name = 'workflow_state'
filter_dict = {
'unpublished': 'unpublished',
'public': 'public',
'active': 'active',
'archived': 'archived',
}
def lookups(self, request, model_admin):
return (
('unpublished', _("Unpublished")),
('public', _("Published (Active or Archived)")),
('active', _("Active (published, not archived)")),
('archived', _("Archived")),
)
def queryset(self, request, queryset):
if self.value():
filter_method = self.filter_dict[self.value()]
return getattr(queryset, filter_method)()
else:
return queryset

25
shared/utils/preview.py

@ -0,0 +1,25 @@
from django.conf import settings
from django.utils import timezone
from .conf import USE_PREVIEW_DATETIME
from .timezone import smart_default_tz
class datetime(timezone.datetime):
@classmethod
def now(klass):
if USE_PREVIEW_DATETIME:
if settings.DEBUG_PREVIEW_DATETIME:
now = timezone.datetime(*settings.DEBUG_PREVIEW_DATETIME)
else:
# TODO Get preview datetime from request user
now = timezone.now()
if settings.USE_TZ:
now = smart_default_tz(now)
else:
now = timezone.now()
return now
@classmethod
def today(klass):
return klass.now().date()

30
shared/utils/templates/admin/action_forms/admin_action_base.html

@ -0,0 +1,30 @@
{% extends "admin/change_form.html" %}{# Needed for admin javascripts #}
{% load i18n static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div id="content-main">
<form action="" method="post">{% csrf_token %}
<div>
{{ action_form.as_p }}
{% for obj in queryset %}
<input type="hidden" name="_selected_action" value="{{ obj.id }}">
{% endfor %}
<p>{{ queryset_action_label }}</p>
<ul>
{{ queryset|unordered_list }}
</ul>
<input type="hidden" name="action" value="{{ action_name }}">
<input type="submit" name="apply" value="{{ action_button_label }}">
<input class="button cancel-link" type="submit" name="cancel" value="{% trans "Cancel" %}">
{# <a href="#" class="button cancel-link">{% trans "No, take me back" %}</a> #}
</div>
</form>
</div>
{% endblock %}

2
shared/utils/templates/utils/_language_switcher_get.html

@ -1,4 +1,4 @@
{% load i18n translation_tags %} {% load i18n %}
{% get_language_info_list for LANGUAGES as languages %} {% get_language_info_list for LANGUAGES as languages %}

8
shared/utils/templates/utils/_text_list.html

@ -1,9 +1,5 @@
{% load i18n %} {% load i18n %}
{# List of items, where items have a (potentially empty) get_absolute_url and a name attribute #} {# List of items, where items have a (potentially empty) get_absolute_url and a name attribute #}
{# Usage: {% include "utils/_text_list.html" with items=page.get_authors no_links=True %} #}
{% for item in items %} {% for item in items %}{% if forloop.last and forloop.counter > 1 %} {% trans "and" %} {% endif %}{% with item.get_absolute_url as url %}{% if url and not no_links %}<a href="{{ url }}">{% endif %}{{ item.name }}{% if url and not no_links %}</a>{% endif %}{% if not forloop.last %}{% if forloop.revcounter0 > 1 %}, {% endif %}{% endif %}{% endwith %}{% endfor %}
{% if forloop.last and forloop.counter > 1 %}{% trans "und" %}{% endif %}
{% with item.get_absolute_url as url %}
{% if url %}<a href="{{ url }}">{% endif %}{{ item.name }}{% if url %}</a>{% endif %}{% if not forloop.last %}{% if forloop.revcounter0 > 1 %},{% endif %}{% endif %}
{% endwith %}
{% endfor %}

11
shared/utils/templatetags/daterange.py

@ -2,6 +2,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2016-2017 # Erik Stein <code@classlibrary.net>, 2016-2017
import datetime
from django import template from django import template
from django.conf import settings from django.conf import settings
@ -55,3 +56,13 @@ def format_year_range(start_date, end_date, variant=DEFAULT_VARIANT):
@register.simple_tag @register.simple_tag
def format_partial_date(year=None, month=None, day=None, variant=DEFAULT_VARIANT): def format_partial_date(year=None, month=None, day=None, variant=DEFAULT_VARIANT):
return dateformat.format_partial_date(year, month, day, variant=DEFAULT_VARIANT) return dateformat.format_partial_date(year, month, day, variant=DEFAULT_VARIANT)
@register.filter
def is_past(_date):
return bool(_date < datetime.date.today())
@register.filter
def is_future(_date):
return bool(_date >= datetime.date.today())

20
shared/utils/templatetags/debug_utils.py

@ -1,24 +1,32 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2009-2017
try:
import ipdb
from django import template from django import template
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
register = template.Library() register = template.Library()
try:
import ipdb
@register.filter @register.filter
def ipdb_inspect(value): def ipdb_inspect(value):
ipdb.set_trace() ipdb.set_trace()
return value return value
@register.simple_tag @register.simple_tag
def ipdb_set_breakpoint(): def ipdb_set_breakpoint():
ipdb.set_trace() ipdb.set_trace()
except: except ImportError:
pass pass
# TODO Move get_admin_url to different template tag library
@register.filter
def get_admin_url(obj):
content_type = ContentType.objects.get_for_model(obj.__class__)
return reverse("admin:%s_%s_change" % (content_type.app_label, content_type.model), args=(obj.id,))

93
shared/utils/templatetags/text_tags.py

@ -1,12 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2015
import re import re
import string
from django import template from django import template
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
from django.utils.encoding import force_text from django.utils.encoding import force_str
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -22,7 +22,7 @@ def conditional_punctuation(value, punctuation=",", space=" "):
Appends punctuation if the (stripped) value is not empty Appends punctuation if the (stripped) value is not empty
and the value does not already end in a punctuation mark (.,:;!?). and the value does not already end in a punctuation mark (.,:;!?).
""" """
value = force_text(value or "").strip() value = force_str(value or "").strip()
if value: if value:
if value[-1] not in ".,:;!?": if value[-1] not in ".,:;!?":
value += conditional_escape(punctuation) value += conditional_escape(punctuation)
@ -48,3 +48,90 @@ def nbsp(text, autoescape=True):
@stringfilter @stringfilter
def html_entities_to_unicode(text): def html_entities_to_unicode(text):
return mark_safe(text_utils.html_entities_to_unicode(text)) return mark_safe(text_utils.html_entities_to_unicode(text))
@register.filter(needs_autoescape=False)
def slimdown(text):
return mark_safe(text_utils.slimdown(text))
@register.filter(needs_autoescape=False)
def strip_links(text):
return mark_safe(text_utils.strip_links(text))
@register.filter(is_safe=True)
@stringfilter
def html_lines_to_list(value):
"""
Replaces all <br> tags with ", "
"""
rv = []
lines = value.split("<br>")
for i in range(0, len(lines)):
line = lines[i].strip()
rv.append(line)
if i < len(lines) - 1:
if line[-1] not in ";:,.-–—":
rv.append(", ")
else:
rv.append(" ")
return "".join(rv)
return ", ".join([l.strip() for l in value.split("<br>")])
def remove_punctuation(s):
# http://stackoverflow.com/questions/265960/best-way-to-strip-punctuation-from-a-string-in-python
# return s.translate(s.maketrans("",""), string.punctuation)
regex = re.compile('[%s]' % re.escape(string.punctuation))
return regex.sub('', s)
def splitn(s, n):
"""split string s into chunks no more than n characters long"""
parts = re.split("(.{%d,%d})" % (n, n), s)
map(parts.remove, [""] * parts.count(""))
return parts
def clean_value(value):
# Convert all whitespace including non-breaking space to a single space
if value:
return re.sub(u"([\s\u00A0]+)", u" ", force_str(value.strip()))
else:
return value
@register.filter()
@stringfilter
def append(value, text):
"""
Appends text if value is not None.
"""
if value is not None:
value = str(value).strip()
if value:
return "{}{}".format(value, text)
return ""
@register.filter()
@stringfilter
def prepend(value, text):
"""
Prepends text if value is not None.
"""
if value is not None:
value = str(value).strip()
if value:
return "{}{}".format(text, value)
return ""
@register.filter
@stringfilter
def first_line(s):
"""
Strips whitespace, then returns the first line of text.
"""
return s.strip().splitlines(False)[0]

28
shared/utils/templatetags/translation_tags.py

@ -1,8 +1,9 @@
# -*- coding: utf-8 -*- from urllib.parse import urlsplit, urlunsplit
from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2014-2015
from django import template from django import template
from django.urls.exceptions import NoReverseMatch
from django.urls import reverse
from django.utils.translation import override
from ..translation import get_translation, get_translated_field from ..translation import get_translation, get_translated_field
@ -10,6 +11,25 @@ from ..translation import get_translation, get_translated_field
register = template.Library() register = template.Library()
@register.simple_tag(takes_context=True)
def switch_language_url(context, lang_code: str):
request = context['request']
match = request.resolver_match
parsed_url = urlsplit(request.get_full_path())
to_be_reversed = "%s:%s" % (match.namespace, match.url_name) \
if match.namespace else match.url_name
with override(lang_code):
try:
url = reverse(to_be_reversed, args=match.args, kwargs=match.kwargs)
except NoReverseMatch:
pass
else:
url = urlunsplit((parsed_url.scheme, parsed_url.netloc,
url, parsed_url.query, parsed_url.fragment))
return url
@register.filter @register.filter
def translation(obj): def translation(obj):
return get_translation(obj) return get_translation(obj)
@ -27,7 +47,7 @@ translated_field = translate
Unfinished: Unfinished:
from django import template from django import template
from django.core.urlresolvers import reverse from django.urls import reverse
from django.core.urlresolvers import resolve from django.core.urlresolvers import resolve
from django.urls.base import translate_url from django.urls.base import translate_url

29
shared/utils/templatetags/view_helpers.py

@ -0,0 +1,29 @@
from urllib.parse import urlencode
from django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def url_replace(context, **kwargs):
query = context['request'].GET.dict()
query.update(kwargs)
return urlencode(query)
@register.filter
def paginator_context(page_range, current):
before = [p for p in page_range if p < current]
after = [p for p in page_range if p > current]
if len(before) > 3:
before = before[:2] + [''] + before[-1:]
if len(after) > 3:
after = after[:1] + [''] + after[-2:]
return before + [current] + after
@register.filter
def select_template(template_list):
return template.loader.select_template(template_list)

116
shared/utils/text.py

@ -1,17 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2015-2017
from django.utils import six
from django.utils.encoding import force_text, smart_text
from django.utils.functional import allow_lazy, keep_lazy_text
from django.utils.safestring import SafeText
from django.utils.text import slugify
from django.utils.translation import ugettext as _, ugettext_lazy
from HTMLParser import HTMLParser
import translitcodec # provides 'translit/long', used by codecs.encode()
import codecs import codecs
import html
import re
import translitcodec # provides 'translit/long', used by codecs.encode() # noqa
from django.conf import settings
from django.utils.encoding import force_str
from django.utils.functional import keep_lazy_text
from django.utils.html import mark_safe, strip_tags
from django.utils.text import slugify as django_slugify, normalize_newlines
from django.utils.translation import gettext_lazy
@keep_lazy_text @keep_lazy_text
@ -19,35 +16,102 @@ def downgrade(value):
""" """
Downgrade unicode to ascii, transliterating accented characters. Downgrade unicode to ascii, transliterating accented characters.
""" """
value = force_text(value) value = force_str(value or "")
return codecs.encode(value, 'translit/long') return codecs.encode(value, 'transliterate')
@keep_lazy_text @keep_lazy_text
def slugify_long(value): def slugify_long(value):
return slugify(downgrade(value)) return django_slugify(downgrade(value))
# Spreading umlauts is included in the translit/long codec. # Spreading umlauts is included in the translit/long codec.
slugify_german = slugify_long slugify_german = slugify_long
def html_entities_to_unicode(html): @keep_lazy_text
parser = HTMLParser() def downgrading_slugify(value):
return parser.unescape(html) # Slugfiy only allowing hyphens, numbers and ASCII characters
html_entities_to_unicode = allow_lazy(html_entities_to_unicode, six.text_type, SafeText) # FIXME django_slugify might return an empty string; take care that we always return something
return re.sub("[ _]+", "-", django_slugify(downgrade(value)))
SLUGIFY_FUNCTION = getattr(settings, 'SLUGIFY_FUNCTION', downgrading_slugify)
slugify = SLUGIFY_FUNCTION
@keep_lazy_text
def html_entities_to_unicode(html_str):
return html.unescape(html_str)
# Translators: Separator between list elements
DEFAULT_SEPARATOR = gettext_lazy(", ")
# Translators: Last separator of list elements
LAST_WORD_SEPARATOR = gettext_lazy(" and ")
# Translators: This string is used as a separator between list elements
DEFAULT_SEPARATOR = ugettext_lazy(", ")
@keep_lazy_text @keep_lazy_text
def get_text_joined(list_, separator=DEFAULT_SEPARATOR, last_word=ugettext_lazy(' and ')): def text_joined(list_, separator=DEFAULT_SEPARATOR, last_word=LAST_WORD_SEPARATOR):
list_ = list(list_) list_ = list(list_)
if len(list_) == 0: if len(list_) == 0:
return '' return ''
if len(list_) == 1: if len(list_) == 1:
return force_text(list_[0]) return force_str(list_[0])
return '%s%s%s' % ( return '%s%s%s' % (
separator.join(force_text(i) for i in list_[:-1]), separator.join(force_str(i) for i in list_[:-1]),
force_text(last_word), force_text(list_[-1])) force_str(last_word), force_str(list_[-1]))
# TODO Don't match escaped stars (like \*)
b_pattern = re.compile(r"(\*\*)(.*?)\1")
i_pattern = re.compile(r"(\*)(.*?)\1")
u_pattern = re.compile(r"(__)(.*?)\1")
link_pattern = re.compile(r"\[([^\[]+)\]\(([^\)]+)\)")
@keep_lazy_text
def slimdown(text):
"""
Converts simplified markdown (`**`, `*`, `__`) to <b>, <i> und <u> tags.
"""
if text:
text, n = re.subn(b_pattern, "<b>\\2</b>", text)
text, n = re.subn(i_pattern, "<i>\\2</i>", text)
text, n = re.subn(u_pattern, "<u>\\2</u>", text)
text, n = re.subn(link_pattern, "<a href=\'\\2\'>\\1</a>", text)
return mark_safe(text)
else:
return ""
@keep_lazy_text
def strip_links(text):
return re.sub(r'<a[^>]+>', '', text, flags=re.DOTALL).replace('</a>', '')
COLLAPSE_WHITESPACE_RE = re.compile(r"\s+")
@keep_lazy_text
def collapse_whitespace(text):
return COLLAPSE_WHITESPACE_RE.sub(" ", text).strip()
@keep_lazy_text
def html_to_text(text):
print(text)
rv = collapse_whitespace(strip_tags(html_entities_to_unicode(str(text))))
print(rv)
return rv
try:
from html_sanitizer.django import get_sanitizer
def sanitized_html(html, config_name='default'):
return get_sanitizer(config_name).sanitize(html)
except ImportError:
pass

14
shared/utils/timezone.py

@ -1,6 +1,3 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2015
""" """
Django and Timezones Django and Timezones
@ -17,6 +14,8 @@ A sample Django application illustrating some time zone traps for the unwary.
""" """
import datetime
from django.conf import settings
from django.utils import timezone from django.utils import timezone
@ -28,3 +27,12 @@ def smart_default_tz(datetime_value):
datetime_value = timezone.make_aware(datetime_value, timezone=timezone.get_default_timezone()) datetime_value = timezone.make_aware(datetime_value, timezone=timezone.get_default_timezone())
return timezone.localtime(datetime_value, timezone.get_default_timezone()) return timezone.localtime(datetime_value, timezone.get_default_timezone())
def timezone_today():
"""
Return the current date in the current time zone.
"""
if settings.USE_TZ:
return timezone.localdate()
else:
return datetime.date.today()

130
shared/utils/translation.py

@ -1,21 +1,14 @@
# -*- coding: utf-8 -*- from copy import copy
from __future__ import unicode_literals
# Erik Stein <code@classlibrary.net>, 2015
import os import os
from collections import OrderedDict
from contextlib import contextmanager from contextlib import contextmanager
from django import http
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.core.urlresolvers import translate_url
from django.http import HttpResponseRedirect
from django.template.loader import select_template from django.template.loader import select_template
from django.utils import translation from django.utils import translation
from django.utils.http import is_safe_url
from django.utils.six.moves.urllib.parse import urlsplit, urlunsplit
from django.utils.translation import check_for_language, LANGUAGE_SESSION_KEY
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.views.i18n import LANGUAGE_QUERY_PARAMETER from django.views.i18n import set_language
FALLBACK_LANGUAGE_CODE = getattr(settings, 'FALLBACK_LANGUAGE_CODE', 'en') FALLBACK_LANGUAGE_CODE = getattr(settings, 'FALLBACK_LANGUAGE_CODE', 'en')
@ -34,16 +27,28 @@ def _normalize_language_code(language_code):
def get_language(language_code=None): def get_language(language_code=None):
return _normalize_language_code(language_code)[:2] return _normalize_language_code(language_code).split("-")[0]
def get_language_order(languages=None):
"""
Returns a copy of settings.LANGUAGES with the active language at the first position.
"""
languages = languages or list(OrderedDict(settings.LANGUAGES).keys())
languages.insert(0, languages.pop(languages.index(get_language())))
return languages
def lang_suffix(language_code=None): # TODO Deprecated 'fieldname' parameter, use 'field_name'
def lang_suffix(language_code=None, field_name=None, fieldname=None):
""" """
Returns the suffix appropriate for adding to field names for selecting Returns the suffix appropriate for adding to field names for selecting
the current language. the current language.
If fieldname is given, returns the suffixed fieldname.
""" """
language_code = _normalize_language_code(language_code)[:2] language_code = _normalize_language_code(language_code or get_language()).split("-")[0]
return "_{}".format(language_code) return "{}_{}".format(field_name or fieldname or "", language_code)
class DirectTemplateView(TemplateView): class DirectTemplateView(TemplateView):
@ -64,19 +69,15 @@ class I18nDirectTemplateView(DirectTemplateView):
def get_template_names(self): def get_template_names(self):
t_name, t_ext = os.path.splitext(self.template_name) t_name, t_ext = os.path.splitext(self.template_name)
lang = translation.get_language() lang = translation.get_language()
template_name = select_template(( template = select_template([
"%s.%s%s" % (t_name, lang, t_ext), "%s.%s%s" % (t_name, lang, t_ext),
self.template_name self.template_name
)).name ])
return [template_name] return [template.template.name]
def i18n_direct_to_template(request, *args, **kwargs):
return I18nDirectTemplateView(*args, **kwargs).as_view()
def get_translation(obj, relation_name='translations', language_code=None): def get_translation(obj, relation_name='translations', language_code=None):
language_code = _normalize_language_code(language_code)[:2] language_code = _normalize_language_code(language_code).split("-")[0]
try: try:
return getattr(obj, relation_name).get(language=language_code) return getattr(obj, relation_name).get(language=language_code)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@ -87,39 +88,6 @@ def get_translation(obj, relation_name='translations', language_code=None):
return None return None
# class FieldTranslationMixin(object):
# """
# If the model has a field `attr` or `attr_<language_code>`, return it's
# value, else raise ValueError.
# """
# def __getattr__(self, attr):
# if attr in self.__dict__:
# return self.__dict__[attr]
# for field in self._meta.multilingual:
# code = None
# match = re.match(r'^%s_(?P<code>[a-z_]{2,5})$' % field, str(attr))
# if match:
# code = match.groups('code')
# code = code[:2] # let's limit it to two letter
# elif attr in self._meta.multilingual:
# code = self._language
# field = attr
# if code is not None:
# try:
# return self._meta.translation.objects.select_related().get(model=self, language__code=code).__dict__[field]
# except ObjectDoesNotExist:
# if MULTILINGUAL_FALL_BACK_TO_DEFAULT and MULTILINGUAL_DEFAULT and code != MULTILINGUAL_DEFAULT:
# try:
# return self._meta.translation.objects.select_related().get(model=self, language__code=MULTILINGUAL_DEFAULT).__dict__[field]
# except ObjectDoesNotExist:
# pass
# if MULTILINGUAL_FAIL_SILENTLY:
# return None
# raise ValueError, "'%s' has no translation in '%s'"%(self, code)
# raise AttributeError, "'%s' object has no attribute '%s'"%(self.__class__.__name__, str(attr))
def get_translated_field(obj, field_name, language_code=None): def get_translated_field(obj, field_name, language_code=None):
""" """
Tries to get the model attribute corresponding to the current Tries to get the model attribute corresponding to the current
@ -145,8 +113,8 @@ def get_translated_field(obj, field_name, language_code=None):
field_name + lang_suffix other language field_name + lang_suffix other language
""" """
# TODO Implement multiple languages # TODO Implement multiple languages
language_code = _normalize_language_code(language_code)[:2] language_code = _normalize_language_code(language_code).split("-")[0]
is_default_language = bool(language_code == settings.LANGUAGE_CODE[:2]) is_default_language = bool(language_code == settings.LANGUAGE_CODE.split("-")[0])
if language_code == 'de': if language_code == 'de':
other_language_code = 'en' other_language_code = 'en'
else: else:
@ -186,26 +154,28 @@ def active_language(lang='de'):
def set_language_get(request): def set_language_get(request):
""" """
set_language per GET request, set_language per GET request,
modified copy from django.views.i18n (django 1.9.x)
""" """
next = request.POST.get('next', request.GET.get('next')) request = copy(request)
if not is_safe_url(url=next, host=request.get_host()): request.POST = request.GET
next = request.META.get('HTTP_REFERER') request.method = 'POST'
if not is_safe_url(url=next, host=request.get_host()): return set_language(request)
next = '/'
response = http.HttpResponseRedirect(next)
if request.method == 'GET': class I18nUrlMixin(object):
lang_code = request.GET.get(LANGUAGE_QUERY_PARAMETER, None) """
if lang_code and check_for_language(lang_code): View Mixin.
next_trans = translate_url(next, lang_code) Makes the url pattern name available in the template context.
if next_trans != next:
response = http.HttpResponseRedirect(next_trans) Usage:
class ViewClass(I18nUrlMixin, TemplateView):
if hasattr(request, 'session'): ...
request.session[LANGUAGE_SESSION_KEY] = lang_code
else: url(r'<your_pattern>', ViewClass.as_view(view_name='my-wonderful-view', name='my-wonderful-view'),
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, lang_code, """
max_age=settings.LANGUAGE_COOKIE_AGE, view_name = None
path=settings.LANGUAGE_COOKIE_PATH,
domain=settings.LANGUAGE_COOKIE_DOMAIN) def get_context_data(self, **kwargs):
return response if 'view_name' not in kwargs and self.view_name:
kwargs['view_name'] = self.view_name
context = super().get_context_data(**kwargs)
return context

33
shared/utils/url_helpers.py

@ -0,0 +1,33 @@
from django.http import Http404
def dispatch_slug_path(*views):
"""
Dispatch full path slug in iterating through a set of views.
Http404 exceptions raised by a view lead to trying the next view
in the list.
This allows to plug different slug systems to the same root URL.
Usages::
# in urls.py
path('<slug:slug_path>/', dispatch_slug_path(
views.CategoryDetailView.as_view(),
views.ArticleDetailView.as_view())),
)
"""
def wrapper(request, **kwargs):
view_args = []
view_kwargs = {'url_path': kwargs[list(kwargs.keys())[0]]}
not_found_exception = Http404
for view in views:
try:
return view(request, *view_args, **view_kwargs)
except Http404 as e:
not_found_exception = e # assign to use it outside of except block
continue
raise not_found_exception
return wrapper

0
shared/utils/views/__init__.py

48
shared/utils/views/alphabetical_pagination.py

@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db.models import F, Func, Value
class AlphabeticalPaginationMixin(object):
alphabetical_pagination_field = 'name'
def get_alphabetical_pagination_field(self):
return self.alphabetical_pagination_field
def get_selected_letter(self):
return self.request.GET.get('letter', 'a')
def get_base_queryset(self):
"""
Queryset before applying pagination filters.
"""
qs = super(AlphabeticalPaginationMixin, self).get_queryset().exclude(
**{self.get_alphabetical_pagination_field(): ''}
)
return qs
def get_queryset(self):
qs = self.get_base_queryset()
# FIXME Select Umlauts (using downgrade and also downgrade sort_name field?)
# FIXME Select on TRIM/LEFT as in get_letter_choices
filter = {
"{}__istartswith".format(self.get_alphabetical_pagination_field()):
self.get_selected_letter()}
return qs.filter(**filter).order_by(self.alphabetical_pagination_field)
def get_letter_choices(self):
return self.get_base_queryset().annotate(name_lower=Func(
Func(
Func(
F(self.get_alphabetical_pagination_field()), function='LOWER'),
function='TRIM'),
Value("1"), function='LEFT')).order_by(
'name_lower').distinct('name_lower').values_list('name_lower', flat=True)
def get_context_data(self, **kwargs):
context = super(AlphabeticalPaginationMixin, self).get_context_data(**kwargs)
context['selected_letter'] = self.get_selected_letter()
context['alphabet'] = self.get_letter_choices()
return context

553
shared/utils/views/daterange.py

@ -0,0 +1,553 @@
## UNFINISHED WORK IN PROGRESS
"""
Generic date range views.
Django's generic date views only deal with a single date per
model. The date range views replicate the API but deal with
a start and an end date.
"""
import datetime
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from django.utils import timezone
from django.utils.encoding import force_str, force_str
from django.utils.translation import ugettext as _
from django.views.generic.base import View
from django.views.generic.detail import (
BaseDetailView, SingleObjectTemplateResponseMixin,
)
from django.views.generic.list import (
MultipleObjectMixin, MultipleObjectTemplateResponseMixin,
)
from django.views.generic.dates import (
YearMixin, MonthMixin, DayMixin, WeekMixin,
DateMixin,
)
from .. import conf, preview
if conf.USE_PREVIEW_DATETIME:
effective_datetime = preview.datetime
else:
effective_datetime = timezone.datetime
class DateRangeMixin(DateMixin):
"""
Mixin class for views manipulating date-based data.
"""
date_field = None
end_date_field = None
allow_future = False
def get_end_date_field(self):
"""
Get the name of the end date field to be used to filter by.
"""
if self.end_date_field is None:
raise ImproperlyConfigured("%s.end_date_field is required." % self.__class__.__name__)
return self.end_date_field
# Note: the following three methods only work in subclasses that also
# inherit SingleObjectMixin or MultipleObjectMixin.
def _make_date_lookup_arg(self, value):
"""
Convert a date into a datetime when the date field is a DateTimeField.
When time zone support is enabled, `date` is assumed to be in the
current time zone, so that displayed items are consistent with the URL.
"""
if self.uses_datetime_field:
value = datetime.datetime.combine(value, datetime.time.min)
if settings.USE_TZ:
value = timezone.make_aware(value, timezone.get_current_timezone())
return value
def _make_single_date_lookup(self, date):
"""
Get the lookup kwargs for filtering on a single date.
If the date field is a DateTimeField, we can't just filter on
date_field=date because that doesn't take the time into account.
"""
date_field = self.get_date_field()
if self.uses_datetime_field:
since = self._make_date_lookup_arg(date)
until = self._make_date_lookup_arg(date + datetime.timedelta(days=1))
return {
'%s__gte' % date_field: since,
'%s__lt' % date_field: until,
}
else:
# Skip self._make_date_lookup_arg, it's a no-op in this branch.
return {date_field: date}
class BaseDateListView(MultipleObjectMixin, DateMixin, View):
"""
Abstract base class for date-based views displaying a list of objects.
"""
allow_empty = False
date_list_period = 'year'
def get(self, request, *args, **kwargs):
self.date_list, self.object_list, extra_context = self.get_dated_items()
context = self.get_context_data(object_list=self.object_list,
date_list=self.date_list)
context.update(extra_context)
return self.render_to_response(context)
def get_dated_items(self):
"""
Obtain the list of dates and items.
"""
raise NotImplementedError('A DateView must provide an implementation of get_dated_items()')
def get_ordering(self):
"""
Returns the field or fields to use for ordering the queryset; uses the
date field by default.
"""
return self.get_date_field() if self.ordering is None else self.ordering
def get_dated_queryset(self, **lookup):
"""
Get a queryset properly filtered according to `allow_future` and any
extra lookup kwargs.
"""
qs = self.get_queryset().filter(**lookup)
date_field = self.get_date_field()
allow_future = self.get_allow_future()
allow_empty = self.get_allow_empty()
paginate_by = self.get_paginate_by(qs)
if not allow_future:
now = effective_datetime.now() if self.uses_datetime_field else effective_datetime.today()
qs = qs.filter(**{'%s__lte' % date_field: now})
if not allow_empty:
# When pagination is enabled, it's better to do a cheap query
# than to load the unpaginated queryset in memory.
is_empty = len(qs) == 0 if paginate_by is None else not qs.exists()
if is_empty:
raise Http404(_("No %(verbose_name_plural)s available") % {
'verbose_name_plural': force_str(qs.model._meta.verbose_name_plural)
})
return qs
def get_date_list_period(self):
"""
Get the aggregation period for the list of dates: 'year', 'month', or 'day'.
"""
return self.date_list_period
def get_date_list(self, queryset, date_type=None, ordering='ASC'):
"""
Get a date list by calling `queryset.dates/datetimes()`, checking
along the way for empty lists that aren't allowed.
"""
date_field = self.get_date_field()
allow_empty = self.get_allow_empty()
if date_type is None:
date_type = self.get_date_list_period()
if self.uses_datetime_field:
date_list = queryset.datetimes(date_field, date_type, ordering)
else:
date_list = queryset.dates(date_field, date_type, ordering)
if date_list is not None and not date_list and not allow_empty:
name = force_str(queryset.model._meta.verbose_name_plural)
raise Http404(_("No %(verbose_name_plural)s available") %
{'verbose_name_plural': name})
return date_list
class BaseArchiveIndexView(BaseDateListView):
"""
Base class for archives of date-based items.
Requires a response mixin.
"""
context_object_name = 'latest'
def get_dated_items(self):
"""
Return (date_list, items, extra_context) for this request.
"""
qs = self.get_dated_queryset()
date_list = self.get_date_list(qs, ordering='DESC')
if not date_list:
qs = qs.none()
return (date_list, qs, {})
class ArchiveIndexView(MultipleObjectTemplateResponseMixin, BaseArchiveIndexView):
"""
Top-level archive of date-based items.
"""
template_name_suffix = '_archive'
class BaseYearArchiveView(YearMixin, BaseDateListView):
"""
List of objects published in a given year.
"""
date_list_period = 'month'
make_object_list = False
def get_dated_items(self):
"""
Return (date_list, items, extra_context) for this request.
"""
year = self.get_year()
date_field = self.get_date_field()
date = _date_from_string(year, self.get_year_format())
since = self._make_date_lookup_arg(date)
until = self._make_date_lookup_arg(self._get_next_year(date))
lookup_kwargs = {
'%s__gte' % date_field: since,
'%s__lt' % date_field: until,
}
qs = self.get_dated_queryset(**lookup_kwargs)
date_list = self.get_date_list(qs)
if not self.get_make_object_list():
# We need this to be a queryset since parent classes introspect it
# to find information about the model.
qs = qs.none()
return (date_list, qs, {
'year': date,
'next_year': self.get_next_year(date),
'previous_year': self.get_previous_year(date),
})
def get_make_object_list(self):
"""
Return `True` if this view should contain the full list of objects in
the given year.
"""
return self.make_object_list
class YearArchiveView(MultipleObjectTemplateResponseMixin, BaseYearArchiveView):
"""
List of objects published in a given year.
"""
template_name_suffix = '_archive_year'
class BaseMonthArchiveView(YearMixin, MonthMixin, BaseDateListView):
"""
List of objects published in a given month.
"""
date_list_period = 'day'
def get_dated_items(self):
"""
Return (date_list, items, extra_context) for this request.
"""
year = self.get_year()
month = self.get_month()
date_field = self.get_date_field()
date = _date_from_string(year, self.get_year_format(),
month, self.get_month_format())
since = self._make_date_lookup_arg(date)
until = self._make_date_lookup_arg(self._get_next_month(date))
lookup_kwargs = {
'%s__gte' % date_field: since,
'%s__lt' % date_field: until,
}
qs = self.get_dated_queryset(**lookup_kwargs)
date_list = self.get_date_list(qs)
return (date_list, qs, {
'month': date,
'next_month': self.get_next_month(date),
'previous_month': self.get_previous_month(date),
})
class MonthArchiveView(MultipleObjectTemplateResponseMixin, BaseMonthArchiveView):
"""
List of objects published in a given month.
"""
template_name_suffix = '_archive_month'
class BaseWeekArchiveView(YearMixin, WeekMixin, BaseDateListView):
"""
List of objects published in a given week.
"""
def get_dated_items(self):
"""
Return (date_list, items, extra_context) for this request.
"""
year = self.get_year()
week = self.get_week()
date_field = self.get_date_field()
week_format = self.get_week_format()
week_start = {
'%W': '1',
'%U': '0',
}[week_format]
date = _date_from_string(year, self.get_year_format(),
week_start, '%w',
week, week_format)
since = self._make_date_lookup_arg(date)
until = self._make_date_lookup_arg(self._get_next_week(date))
lookup_kwargs = {
'%s__gte' % date_field: since,
'%s__lt' % date_field: until,
}
qs = self.get_dated_queryset(**lookup_kwargs)
return (None, qs, {
'week': date,
'next_week': self.get_next_week(date),
'previous_week': self.get_previous_week(date),
})
class WeekArchiveView(MultipleObjectTemplateResponseMixin, BaseWeekArchiveView):
"""
List of objects published in a given week.
"""
template_name_suffix = '_archive_week'
class BaseDayArchiveView(YearMixin, MonthMixin, DayMixin, BaseDateListView):
"""
List of objects published on a given day.
"""
def get_dated_items(self):
"""
Return (date_list, items, extra_context) for this request.
"""
year = self.get_year()
month = self.get_month()
day = self.get_day()
date = _date_from_string(year, self.get_year_format(),
month, self.get_month_format(),
day, self.get_day_format())
return self._get_dated_items(date)
def _get_dated_items(self, date):
"""
Do the actual heavy lifting of getting the dated items; this accepts a
date object so that TodayArchiveView can be trivial.
"""
lookup_kwargs = self._make_single_date_lookup(date)
qs = self.get_dated_queryset(**lookup_kwargs)
return (None, qs, {
'day': date,
'previous_day': self.get_previous_day(date),
'next_day': self.get_next_day(date),
'previous_month': self.get_previous_month(date),
'next_month': self.get_next_month(date)
})
class DayArchiveView(MultipleObjectTemplateResponseMixin, BaseDayArchiveView):
"""
List of objects published on a given day.
"""
template_name_suffix = "_archive_day"
class BaseTodayArchiveView(BaseDayArchiveView):
"""
List of objects published today.
"""
def get_dated_items(self):
"""
Return (date_list, items, extra_context) for this request.
"""
return self._get_dated_items(datetime.date.today())
class TodayArchiveView(MultipleObjectTemplateResponseMixin, BaseTodayArchiveView):
"""
List of objects published today.
"""
template_name_suffix = "_archive_day"
class BaseDateDetailView(YearMixin, MonthMixin, DayMixin, DateMixin, BaseDetailView):
"""
Detail view of a single object on a single date; this differs from the
standard DetailView by accepting a year/month/day in the URL.
"""
def get_object(self, queryset=None):
"""
Get the object this request displays.
"""
year = self.get_year()
month = self.get_month()
day = self.get_day()
date = _date_from_string(year, self.get_year_format(),
month, self.get_month_format(),
day, self.get_day_format())
# Use a custom queryset if provided
qs = self.get_queryset() if queryset is None else queryset
if not self.get_allow_future() and date > datetime.date.today():
raise Http404(_(
"Future %(verbose_name_plural)s not available because "
"%(class_name)s.allow_future is False."
) % {
'verbose_name_plural': qs.model._meta.verbose_name_plural,
'class_name': self.__class__.__name__,
})
# Filter down a queryset from self.queryset using the date from the
# URL. This'll get passed as the queryset to DetailView.get_object,
# which'll handle the 404
lookup_kwargs = self._make_single_date_lookup(date)
qs = qs.filter(**lookup_kwargs)
return super(BaseDetailView, self).get_object(queryset=qs)
class DateDetailView(SingleObjectTemplateResponseMixin, BaseDateDetailView):
"""
Detail view of a single object on a single date; this differs from the
standard DetailView by accepting a year/month/day in the URL.
"""
template_name_suffix = '_detail'
def _date_from_string(year, year_format, month='', month_format='', day='', day_format='', delim='__'):
"""
Helper: get a datetime.date object given a format string and a year,
month, and day (only year is mandatory). Raise a 404 for an invalid date.
"""
format = delim.join((year_format, month_format, day_format))
datestr = delim.join((year, month, day))
try:
return datetime.datetime.strptime(force_str(datestr), format).date()
except ValueError:
raise Http404(_("Invalid date string '%(datestr)s' given format '%(format)s'") % {
'datestr': datestr,
'format': format,
})
def _get_next_prev(generic_view, date, is_previous, period):
"""
Helper: Get the next or the previous valid date. The idea is to allow
links on month/day views to never be 404s by never providing a date
that'll be invalid for the given view.
This is a bit complicated since it handles different intervals of time,
hence the coupling to generic_view.
However in essence the logic comes down to:
* If allow_empty and allow_future are both true, this is easy: just
return the naive result (just the next/previous day/week/month,
regardless of object existence.)
* If allow_empty is true, allow_future is false, and the naive result
isn't in the future, then return it; otherwise return None.
* If allow_empty is false and allow_future is true, return the next
date *that contains a valid object*, even if it's in the future. If
there are no next objects, return None.
* If allow_empty is false and allow_future is false, return the next
date that contains a valid object. If that date is in the future, or
if there are no next objects, return None.
"""
date_field = generic_view.get_date_field()
allow_empty = generic_view.get_allow_empty()
allow_future = generic_view.get_allow_future()
get_current = getattr(generic_view, '_get_current_%s' % period)
get_next = getattr(generic_view, '_get_next_%s' % period)
# Bounds of the current interval
start, end = get_current(date), get_next(date)
# If allow_empty is True, the naive result will be valid
if allow_empty:
if is_previous:
result = get_current(start - datetime.timedelta(days=1))
else:
result = end
if allow_future or result <= effective_datetime.today():
return result
else:
return None
# Otherwise, we'll need to go to the database to look for an object
# whose date_field is at least (greater than/less than) the given
# naive result
else:
# Construct a lookup and an ordering depending on whether we're doing
# a previous date or a next date lookup.
if is_previous:
lookup = {'%s__lt' % date_field: generic_view._make_date_lookup_arg(start)}
ordering = '-%s' % date_field
else:
lookup = {'%s__gte' % date_field: generic_view._make_date_lookup_arg(end)}
ordering = date_field
# Filter out objects in the future if appropriate.
if not allow_future:
# Fortunately, to match the implementation of allow_future,
# we need __lte, which doesn't conflict with __lt above.
if generic_view.uses_datetime_field:
now = effective_datetime.now()
else:
now = effective_datetime.today()
lookup['%s__lte' % date_field] = now
qs = generic_view.get_queryset().filter(**lookup).order_by(ordering)
# Snag the first object from the queryset; if it doesn't exist that
# means there's no next/previous link available.
try:
result = getattr(qs[0], date_field)
except IndexError:
return None
# Convert datetimes to dates in the current time zone.
if generic_view.uses_datetime_field:
if settings.USE_TZ:
result = effective_datetime.localtime(result)
result = result.date()
# Return the first day of the period.
return get_current(result)
Loading…
Cancel
Save