Compare commits
7 Commits
master
...
backports/
Author | SHA1 | Date |
---|---|---|
|
6b18b5e48a | 7 years ago |
|
44f6e32ca7 | 7 years ago |
|
c0f78b301e | 7 years ago |
|
a8ca26a682 | 7 years ago |
|
21ccad9186 | 7 years ago |
|
a57dff7f3b | 7 years ago |
|
a0c5e351d9 | 7 years ago |
39 changed files with 270 additions and 2065 deletions
@ -1,2 +0,0 @@ |
|||||||
Erik Stein <erik@classlibrary.net>, 2008- |
|
||||||
Jan Gerber <j@thing.net> |
|
@ -1,102 +0,0 @@ |
|||||||
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. |
|
@ -1,12 +1,14 @@ |
|||||||
# -*- 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: |
|
||||||
from ._version import __version__ |
|
||||||
except ImportError: |
|
||||||
__version__ = '0.0.0+see-git-tag' |
|
||||||
|
|
||||||
|
try: |
||||||
|
from django.utils.translation import ugettext_lazy as _ |
||||||
|
|
||||||
VERSION = __version__.split('+') |
SLUG_HELP = _("Kurzfassung des Namens für die Adresszeile im Browser. Vorzugsweise englisch, keine Umlaute, nur Bindestrich als Sonderzeichen.") |
||||||
VERSION = tuple(list(map(int, VERSION[0].split('.'))) + VERSION[1:]) |
except ImportError: |
||||||
|
pass |
||||||
|
@ -1,172 +0,0 @@ |
|||||||
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} |
|
@ -1,10 +0,0 @@ |
|||||||
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(...) |
|
@ -1,12 +0,0 @@ |
|||||||
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) |
|
@ -1,16 +0,0 @@ |
|||||||
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.") |
|
@ -1,59 +1,84 @@ |
|||||||
# -*- 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 |
from .text import slugify_long as slugify |
||||||
|
from . import SLUG_HELP |
||||||
|
|
||||||
|
|
||||||
def uniquify_field_value(instance, field_name, value, max_length=None, queryset=None): |
DEFAULT_SLUG = "item" |
||||||
""" |
|
||||||
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. |
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. |
||||||
""" |
""" |
||||||
|
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._meta.default_manager.get_queryset() |
queryset = instance.__class__._default_manager.get_queryset() |
||||||
|
|
||||||
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): |
def get_similar_slugs(slug): |
||||||
return queryset.exclude(pk=instance.pk) \ |
return queryset.exclude(pk=instance.pk) \ |
||||||
.filter(**{"%s__istartswith" % field_name: value}).values_list(field_name, flat=True) |
.filter(**{"%s__istartswith" % slug_field: slug}).values_list(slug_field, flat=True) |
||||||
|
|
||||||
# Find already existing counter |
similar_slugs = get_similar_slugs(slug) |
||||||
m = re.match(r'(.+)(-\d+)$', value) |
while slug in similar_slugs or len(slug) > max_length: |
||||||
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 |
||||||
return value |
slug = "%s-%i" % (orig_slug, index) |
||||||
|
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 |
||||||
|
|
||||||
|
|
||||||
# TODO Remove alias |
def unique_slug2(instance, slug_source, slug_field): |
||||||
def unique_slug(instance, slug_field, slug_value, max_length=50, queryset=None): |
slug = slugify(slug_source) |
||||||
slug_value = slugify(slug_value) |
all_slugs = [sl.values()[0] for sl in instance.__class__._default_manager.values(slug_field)] |
||||||
return uniquify_field_value(instance, slug_field, slug_value, max_length=50, queryset=None) |
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) |
||||||
|
|
||||||
|
@ -1,32 +0,0 @@ |
|||||||
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' |
|
@ -1,36 +0,0 @@ |
|||||||
# 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' |
|
@ -1,122 +0,0 @@ |
|||||||
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 |
|
@ -1,116 +1,64 @@ |
|||||||
from django.conf import settings |
# -*- coding: utf-8 -*- |
||||||
from django.db import models |
from __future__ import unicode_literals |
||||||
from django.utils.html import strip_tags |
# Erik Stein <code@classlibrary.net>, 2017 |
||||||
from django.utils.text import normalize_newlines, Truncator |
|
||||||
from django.utils.translation import gettext_lazy as _ |
|
||||||
|
|
||||||
from shared.multilingual.utils import i18n_fields_list |
|
||||||
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' |
|
||||||
|
|
||||||
|
from django.db import models |
||||||
|
from django.utils.encoding import python_2_unicode_compatible |
||||||
|
from django.utils.text import normalize_newlines |
||||||
|
from django.utils.translation import ugettext_lazy as _ |
||||||
|
|
||||||
# TODO Make slimdown optional through settings |
from ..fields import AutoSlugField |
||||||
# TODO Leave window_title alone, do not slimdown |
from ..functional import firstof |
||||||
|
|
||||||
|
|
||||||
class PageTitlesBehaviour: |
# TODO Use translatable fields by default |
||||||
|
@python_2_unicode_compatible |
||||||
|
class PageTitlesMixin(models.Model): |
||||||
""" |
""" |
||||||
Implements fallback behaviour. |
A model mixin containg title and slug field for models serving as website |
||||||
|
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 strip_tags(slimdown(self.get_short_name())) |
return self.short_title |
||||||
|
|
||||||
def get_title(self): |
def get_title(self): |
||||||
return self.get_first_title_line() or \ |
return firstof( |
||||||
self.name |
self.title, |
||||||
|
self.short_title |
||||||
|
) |
||||||
|
|
||||||
def get_short_name(self): |
def get_window_title(self): |
||||||
return getattr(self, 'short_name', '') or \ |
return firstof( |
||||||
Truncator(self.name).words(5, truncate="...") |
self.window_title, |
||||||
|
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 or self.name.. |
First line of title field. |
||||||
""" |
""" |
||||||
return normalize_newlines(getattr(self, 'long_title', '') or '').partition("\n")[0] |
return normalize_newlines(self.get_title()).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(getattr(self, 'long_title', '') or '').partition("\n")[2] |
return normalize_newlines(self.title).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 PageTitlesAdminMixin: |
class PageTitleAdminMixin(object): |
||||||
list_display = ['name', 'slug'] |
search_fields = ['short_title', 'title', 'window_title'] |
||||||
search_fields = ['name', 'short_name', 'long_title', 'window_title'] |
list_display = ['short_title', 'slug'] |
||||||
if USE_TRANSLATABLE_FIELDS: |
|
||||||
search_fields = i18n_fields_list(search_fields) |
|
||||||
prepopulated_fields = { |
prepopulated_fields = { |
||||||
'slug': [SLUG_POPULATE_FROM] |
'slug': ('short_title',), |
||||||
} |
} |
||||||
|
@ -1,173 +0,0 @@ |
|||||||
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() |
|
@ -1,173 +0,0 @@ |
|||||||
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 |
|
@ -1,25 +0,0 @@ |
|||||||
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() |
|
@ -1,30 +0,0 @@ |
|||||||
{% 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 %} |
|
@ -1,5 +1,9 @@ |
|||||||
{% 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 %}{% 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 %} |
{% for item in items %} |
||||||
|
{% 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 %} |
||||||
|
@ -1,29 +0,0 @@ |
|||||||
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) |
|
@ -1,33 +0,0 @@ |
|||||||
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 |
|
@ -1,48 +0,0 @@ |
|||||||
# -*- 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 |
|
||||||
|
|
@ -1,553 +0,0 @@ |
|||||||
|
|
||||||
## 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…
Reference in new issue