Compare commits
103 Commits
backports/
...
master
39 changed files with 2072 additions and 276 deletions
@ -0,0 +1,2 @@
|
||||
Erik Stein <erik@classlibrary.net>, 2008- |
||||
Jan Gerber <j@thing.net> |
@ -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. |
@ -1,3 +1,12 @@
|
||||
# django-shared-utils |
||||
|
||||
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. |
||||
|
@ -1,14 +1,12 @@
|
||||
# -*- coding: utf-8 -*- |
||||
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 django.utils.translation import ugettext_lazy as _ |
||||
|
||||
SLUG_HELP = _("Kurzfassung des Namens für die Adresszeile im Browser. Vorzugsweise englisch, keine Umlaute, nur Bindestrich als Sonderzeichen.") |
||||
from ._version import __version__ |
||||
except ImportError: |
||||
pass |
||||
__version__ = '0.0.0+see-git-tag' |
||||
|
||||
|
||||
VERSION = __version__.split('+') |
||||
VERSION = tuple(list(map(int, VERSION[0].split('.'))) + VERSION[1:]) |
||||
|
@ -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} |
@ -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(...) |
@ -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) |
@ -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.") |
@ -1,83 +1,59 @@
|
||||
# -*- coding: utf-8 -*- |
||||
from __future__ import unicode_literals |
||||
# Erik Stein <code@classlibrary.net>, 2008-2015 |
||||
|
||||
import re |
||||
from django.db.models import fields |
||||
from django.utils import six |
||||
from django.utils.translation import ugettext_lazy as _ |
||||
if six.PY3: |
||||
from functools import reduce |
||||
|
||||
from .text import slugify_long as slugify |
||||
from . import SLUG_HELP |
||||
from .text import slugify |
||||
|
||||
|
||||
DEFAULT_SLUG = _("item") |
||||
|
||||
|
||||
def unique_slug(instance, slug_field, slug_value, max_length=50, queryset=None): |
||||
def uniquify_field_value(instance, field_name, value, max_length=None, 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(slug_value) |
||||
index = 0 |
||||
if not queryset: |
||||
queryset = instance.__class__._default_manager.get_queryset() |
||||
queryset = instance._meta.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_slugs(slug): |
||||
def get_similar_values(value): |
||||
return queryset.exclude(pk=instance.pk) \ |
||||
.filter(**{"%s__istartswith" % slug_field: slug}).values_list(slug_field, flat=True) |
||||
|
||||
similar_slugs = get_similar_slugs(slug) |
||||
while slug in similar_slugs or len(slug) > max_length: |
||||
.filter(**{"%s__istartswith" % field_name: value}).values_list(field_name, flat=True) |
||||
|
||||
# Find already existing counter |
||||
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 |
||||
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 |
||||
|
||||
return value |
||||
|
||||
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) |
||||
|
@ -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,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' |
@ -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 |
@ -1,64 +1,116 @@
|
||||
# -*- coding: utf-8 -*- |
||||
from __future__ import unicode_literals |
||||
# Erik Stein <code@classlibrary.net>, 2017 |
||||
|
||||
|
||||
from django.conf import settings |
||||
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 _ |
||||
from django.utils.html import strip_tags |
||||
from django.utils.text import normalize_newlines, Truncator |
||||
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 ..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 PageTitlesMixin(models.Model): |
||||
|
||||
class PageTitlesBehaviour: |
||||
""" |
||||
A model mixin containg title and slug field for models serving as website |
||||
pages with an URL. |
||||
Implements fallback behaviour. |
||||
""" |
||||
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): |
||||
return self.short_title |
||||
return strip_tags(slimdown(self.get_short_name())) |
||||
|
||||
def get_title(self): |
||||
return firstof( |
||||
self.title, |
||||
self.short_title |
||||
) |
||||
return self.get_first_title_line() or \ |
||||
self.name |
||||
|
||||
def get_window_title(self): |
||||
return firstof( |
||||
self.window_title, |
||||
self.short_title, |
||||
self.get_first_title_line(), |
||||
) |
||||
def get_short_name(self): |
||||
return getattr(self, 'short_name', '') or \ |
||||
Truncator(self.name).words(5, truncate="...") |
||||
|
||||
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): |
||||
""" |
||||
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): |
||||
search_fields = ['short_title', 'title', 'window_title'] |
||||
list_display = ['short_title', 'slug'] |
||||
class PageTitlesAdminMixin: |
||||
list_display = ['name', 'slug'] |
||||
search_fields = ['name', 'short_name', 'long_title', 'window_title'] |
||||
if USE_TRANSLATABLE_FIELDS: |
||||
search_fields = i18n_fields_list(search_fields) |
||||
prepopulated_fields = { |
||||
'slug': ('short_title',), |
||||
'slug': [SLUG_POPULATE_FROM] |
||||
} |
||||
|
@ -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() |
@ -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 |
@ -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() |
@ -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 %} |
@ -1,9 +1,5 @@
|
||||
{% load i18n %} |
||||
{# 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 "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 %} |
||||
{% 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 %} |
@ -1,24 +1,32 @@
|
||||
# -*- coding: utf-8 -*- |
||||
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 |
||||
def ipdb_inspect(value): |
||||
ipdb.set_trace() |
||||
return value |
||||
|
||||
|
||||
@register.simple_tag |
||||
def ipdb_set_breakpoint(): |
||||
ipdb.set_trace() |
||||
|
||||
except: |
||||
except ImportError: |
||||
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,)) |
||||
|
||||
|
||||
|
@ -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) |
@ -1,53 +1,117 @@
|
||||
# -*- 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 bs4 import BeautifulStoneSoup |
||||
import translitcodec # provides 'translit/long', used by codecs.encode() |
||||
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 |
||||
def downgrade(value): |
||||
""" |
||||
Downgrade unicode to ascii, transliterating accented characters. |
||||
""" |
||||
value = force_text(value) |
||||
return codecs.encode(value, 'translit/long') |
||||
downgrade = allow_lazy(downgrade, six.text_type, SafeText) |
||||
value = force_str(value or "") |
||||
return codecs.encode(value, 'transliterate') |
||||
|
||||
|
||||
@keep_lazy_text |
||||
def slugify_long(value): |
||||
return slugify(downgrade(value)) |
||||
slugify_long = allow_lazy(slugify_long, six.text_type, SafeText) |
||||
return django_slugify(downgrade(value)) |
||||
|
||||
|
||||
# Spreading umlauts is included in the translit/long codec. |
||||
slugify_german = slugify_long |
||||
|
||||
|
||||
def html_entities_to_unicode(html): |
||||
text = smart_text(BeautifulStoneSoup(html, convertEntities=BeautifulStoneSoup.ALL_ENTITIES)) |
||||
return text |
||||
html_entities_to_unicode = allow_lazy(html_entities_to_unicode, six.text_type, SafeText) |
||||
@keep_lazy_text |
||||
def downgrading_slugify(value): |
||||
# Slugfiy only allowing hyphens, numbers and ASCII characters |
||||
# 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 |
||||
|
||||
# Translators: This string is used as a separator between list elements |
||||
DEFAULT_SEPARATOR = ugettext_lazy(", ") |
||||
|
||||
@keep_lazy_text |
||||
def get_text_joined(list_, separator=DEFAULT_SEPARATOR, last_word=ugettext_lazy(' and ')): |
||||
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 ") |
||||
|
||||
|
||||
@keep_lazy_text |
||||
def text_joined(list_, separator=DEFAULT_SEPARATOR, last_word=LAST_WORD_SEPARATOR): |
||||
list_ = list(list_) |
||||
if len(list_) == 0: |
||||
return '' |
||||
if len(list_) == 1: |
||||
return force_text(list_[0]) |
||||
return force_str(list_[0]) |
||||
return '%s%s%s' % ( |
||||
separator.join(force_text(i) for i in list_[:-1]), |
||||
force_text(last_word), force_text(list_[-1])) |
||||
separator.join(force_str(i) for i in 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 |
||||
|
@ -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,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 |
||||
|
@ -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…
Reference in new issue