From df0efdfa70bef1c45249b4ac5f20f70c9601976b Mon Sep 17 00:00:00 2001 From: Erik Stein Date: Mon, 31 Oct 2016 13:30:17 +0100 Subject: [PATCH] Added version from HFI. --- __init__.py | 9 ++ dateformat.py | 224 +++++++++++++++++++++++++++++++ fields.py | 79 +++++++++++ forms.py | 51 +++++++ markdown_utils.py | 1 + templatetags/__init__.py | 0 templatetags/daterange.py | 67 +++++++++ templatetags/markup_tags.py | 33 +++++ templatetags/text_tags.py | 26 ++++ templatetags/translation_tags.py | 24 ++++ text.py | 49 +++++++ timezone.py | 15 +++ translation.py | 195 +++++++++++++++++++++++++++ 13 files changed, 773 insertions(+) create mode 100644 __init__.py create mode 100644 dateformat.py create mode 100644 fields.py create mode 100644 forms.py create mode 100644 markdown_utils.py create mode 100644 templatetags/__init__.py create mode 100644 templatetags/daterange.py create mode 100644 templatetags/markup_tags.py create mode 100644 templatetags/text_tags.py create mode 100644 templatetags/translation_tags.py create mode 100644 text.py create mode 100644 timezone.py create mode 100644 translation.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5d0a3e7 --- /dev/null +++ b/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2015 + +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.") + diff --git a/dateformat.py b/dateformat.py new file mode 100644 index 0000000..695f604 --- /dev/null +++ b/dateformat.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2016 +""" +Extends django.utils.dateformat +""" + + +import datetime +import re +from django.conf import settings +from django.utils.dateformat import DateFormat, re_escaped +from django.utils.formats import get_format +from django.utils.encoding import force_text +from django.utils.translation import get_language, ugettext_lazy as _ + + +# Adding "q" +re_formatchars = re.compile(r'(?>> import datetime + >>> format_date_range(datetime.date(2009,1,15), datetime.date(2009,1,20)) + '15. - 20.01.2009.' + >>> format_date_range(datetime.date(2009,1,15), datetime.date(2009,2,20)) + '15.01. - 20.02.2009.' + >>> format_date_range(datetime.date(2009,1,15), datetime.date(2010,2,20)) + '15.01.2009. - 20.02.2010.' + >>> format_date_range(datetime.date(2009,1,15), datetime.date(2010,1,20)) + '15.01.2009. - 20.01.2010.' + """ + if not (from_date or to_date): + return "" + + variant = _normalize_variant(variant) + + # Only deal with dates, ignoring time + def datetime_to_date(dt): + try: + return dt.date() + except AttributeError: + return dt + from_date = datetime_to_date(from_date) + to_date = datetime_to_date(to_date) + + from_format = to_format = get_format(variant + 'DATE_FORMAT') + + if from_date == to_date or not to_date: + return date_format(from_date, get_format(from_format)) + else: + if (from_date.year == to_date.year): + from_format = get_format(variant + 'DAYMONTH_FORMAT') or 'd/m/' + if (from_date.month == to_date.month): + from_format = get_format(variant + 'DAYONLY_FORMAT') or 'd' + + f = t = "" + if from_date: + f = date_format(from_date, get_format(from_format)) + if to_date: + t = date_format(to_date, get_format(to_format)) + + separator = get_format('DATE_RANGE_SEPARATOR') or " - " + return separator.join((f, t)) + + +def format_time_range(from_time, to_time, variant='short'): + """ + Knows how to deal with left out from_time/to_time values. + """ + if not (from_time or to_time): + return "" + + variant = _normalize_variant(variant) + + from_format = to_format = "q" # get_format(variant + 'TIME_FORMAT') + + if from_time == to_time or not to_time: + return time_format(from_time, get_format(from_format)) + else: + f = t = "" + if from_time: + f = time_format(from_time, get_format(from_format)) + if to_time: + t = time_format(to_time, get_format(to_format)) + + separator = get_format('DATE_RANGE_SEPARATOR') or "–" + return separator.join((f, t)) + + +def format_timespan_range(timespan_object, force_wholeday=False, variant='short'): + """ + For Timespan-objects, i.e. object with start_date, end_date, start_time and end_time properties. + + Multiday or force_wholeday: + "10.07.2016-11.07.2016" + + Single days: + "10.07.2016 11 Uhr" + "10.07.2016 11-14 Uhr" + + >>> import datetime + >>> sd, ed = datetime.date(2009,1,15), datetime.date(2009,1,20) + >>> st, et = datetime.date(2009,1,15), datetime.date(2009,1,20) + >>> class TestObject(object): + >>> start_date = None + >>> end_date = None + >>> start_time = None + >>> end_time = None + >>> obj = TestObject() + >>> obj.start_date = obj.end_date = sd + >>> format_timespan_range(obj) + '15.01.–20.01.2009' + + """ + variant = _normalize_variant(variant) + + rv = format_date_range(timespan_object.start_date, timespan_object.end_date, variant) + + if (timespan_object.is_multiday() or + not timespan_object.start_time or + force_wholeday): + # Don't show timespan + return rv + else: + rv = _("%(daterange)s %(timespan)s Uhr") % { + 'daterange': rv, + 'timespan': format_time_range(timespan_object.start_time, timespan_object.end_time, variant) + } + return rv + + +def format_partial_date(year=None, month=None, day=None, variant='short'): + """ + >>> format_partial_date(2008) + 2008 + >>> format_partial_date(2008, 3) + 2008 + >>> format_partial_date(2008) + 2008 + >>> format_partial_date(2008) + 2008 + >>> format_partial_date(2008) + 2008 + >>> format_partial_date(2008) + 2008 + """ + if year and month and day: + format_name = 'DATE_FORMAT' + elif year and month: + format_name = 'YEARMONTH_FORMAT' + elif month and day: + format_name = 'DAYMONTH_FORMAT' + elif year: + format_name = 'YEAR_FORMAT' + elif month: + format_name = 'MONTH_FORMAT' + elif day: + format_name = 'DAYONLY_FORMAT' + + name = _normalize_variant(variant) + format_name + # TODO Django bug or what? Sometimes get_language returns None, therefore force a language here + partial_date_format = get_format(name, lang=get_language() or settings.LANGUAGE_CODE) + return date_format(datetime.date(year or 2000, month or 1, day or 1), partial_date_format) + + +# TODO Add format_partial_date_range function + + +def _test(): + import doctest + doctest.testmod() + + +if __name__ == "__main__": + _test() diff --git a/fields.py b/fields.py new file mode 100644 index 0000000..a1a8939 --- /dev/null +++ b/fields.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2008-2015 + +import re +from django.db.models import fields +from django.template.defaultfilters import slugify +from django.utils.translation import ugettext_lazy as _ + +from . import SLUG_HELP + + +DEFAULT_SLUG = _("item") + + +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(slug_value) + index = 0 + if not queryset: + queryset = instance.__class__._default_manager.get_queryset() + + def get_similar_slugs(slug): + 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: + 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 + + +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 super(AutoSlugField, self).pre_save(model_instance, add) diff --git a/forms.py b/forms.py new file mode 100644 index 0000000..9fb51c2 --- /dev/null +++ b/forms.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2016 + +from django import forms +from django.contrib import admin +from django.utils.translation import ugettext_lazy as _ + + +# from http://stackoverflow.com/questions/877723/inline-form-validation-in-django#877920 + +class MandatoryInlineFormSet(forms.models.BaseInlineFormSet): + """ + Make sure at least one inline form is valid. + """ + mandatory_error_message = _("Bitte mindestens %(min_num)s %(name)s angeben.") + + def is_valid(self): + return super(MandatoryInlineFormSet, self).is_valid() and \ + not any([bool(e) for e in self.errors]) + + def clean(self): + # get forms that actually have valid data + count = 0 + for form in self.forms: + try: + if form.cleaned_data and not form.cleaned_data.get('DELETE', False): + count += 1 + except AttributeError: + # annoyingly, if a subform is invalid Django explicity raises + # an AttributeError for cleaned_data + pass + if count < self.min_num: + if self.min_num > 1: + name = self.model._meta.verbose_name_plural + else: + name = self.model._meta.verbose_name + raise forms.ValidationError( + self.mandatory_error_message % { + 'min_num': self.min_num, + 'name': name, + } + ) + + +class MandatoryTabularInline(admin.TabularInline): + formset = MandatoryInlineFormSet + + +class MandatoryStackedInline(admin.StackedInline): + formset = MandatoryInlineFormSet diff --git a/markdown_utils.py b/markdown_utils.py new file mode 100644 index 0000000..06e1f72 --- /dev/null +++ b/markdown_utils.py @@ -0,0 +1 @@ +from markup.utils import * diff --git a/templatetags/__init__.py b/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/templatetags/daterange.py b/templatetags/daterange.py new file mode 100644 index 0000000..55df54f --- /dev/null +++ b/templatetags/daterange.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2016 +# improved from https://djangosnippets.org/snippets/1405/ + + +from django import template +from ..dateformat import date_format, get_format + + +""" +# TODO Describe custom formats +""" + + +register = template.Library() + + +@register.simple_tag +def format_date_range(from_date, to_date, variant='short'): + """ + >>> import datetime + >>> format_date_range(datetime.date(2009,1,15), datetime.date(2009,1,20)) + '15. - 20.01.2009.' + >>> format_date_range(datetime.date(2009,1,15), datetime.date(2009,2,20)) + '15.01. - 20.02.2009.' + >>> format_date_range(datetime.date(2009,1,15), datetime.date(2010,2,20)) + '15.01.2009. - 20.02.2010.' + >>> format_date_range(datetime.date(2009,1,15), datetime.date(2010,1,20)) + '15.01.2009. - 20.01.2010.' + + Use in django templates: + + {% load date_range %} + {% format_date_range exhibition.start_on exhibition.end_on %} + """ + if variant.lower() not in ('short', 'long', ''): + variant = 'short' + if variant.endswith("_"): + variant = variant + "_" + + from_format = to_format = get_format(variant.upper() + 'DATE_FORMAT') + + if from_date == to_date: + return date_format(to_date, get_format(to_format)) + + if (from_date.year == to_date.year): + from_format = get_format(variant.upper() + 'DAYMONTH_FORMAT') or 'd/m/' + if (from_date.month == to_date.month): + from_format = get_format(variant.upper() + 'DAYONLY_FORMAT') or 'd' + separator = get_format('DATE_RANGE_SEPARATOR') or "–" + # import ipdb; ipdb.set_trace() + + print from_format, to_format + + f = date_format(from_date, get_format(from_format)) + t = date_format(to_date, get_format(to_format)) + + return variant.upper() + " " + separator.join((f, t)) + + +def _test(): + import doctest + doctest.testmod() + +if __name__ == "__main__": + _test() \ No newline at end of file diff --git a/templatetags/markup_tags.py b/templatetags/markup_tags.py new file mode 100644 index 0000000..b1f55fa --- /dev/null +++ b/templatetags/markup_tags.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2015 + +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.html import conditional_escape +from django.utils.safestring import mark_safe +from .. import markdown_utils + + +register = template.Library() + + +@register.filter(needs_autoescape=True) +@stringfilter +def inline_markdown(text, autoescape=None, **kwargs): + """ Doesn't wrap the markup in a HTML paragraph. """ + if autoescape: + esc = conditional_escape + else: + esc = lambda x: x + return mark_safe(markdown_utils.inline_markdown_processor.convert(esc(text), **kwargs)) + + +@register.filter(needs_autoescape=True) +@stringfilter +def markdown(text, autoescape=None, **kwargs): + if autoescape: + esc = conditional_escape + else: + esc = lambda x: x + return mark_safe(markdown_utils.markdown_processor.convert(esc(text), **kwargs)) diff --git a/templatetags/text_tags.py b/templatetags/text_tags.py new file mode 100644 index 0000000..bb3e5fd --- /dev/null +++ b/templatetags/text_tags.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2015 + +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.html import conditional_escape +from django.utils.safestring import mark_safe + + +register = template.Library() + + +@register.filter() +def conditional_punctuation(value, punctuation=",", space=" "): + """ + Appends punctuation if the (stripped) value is not empty + and the value does not already end in a punctuation mark (.,:;!?). + """ + value = value.strip() + if value: + if value[-1] not in ".,:;!?": + value += conditional_escape(punctuation) + value += conditional_escape(space) # Append previously stripped space + return value +conditional_punctuation.is_safe = True diff --git a/templatetags/translation_tags.py b/templatetags/translation_tags.py new file mode 100644 index 0000000..7afc930 --- /dev/null +++ b/templatetags/translation_tags.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2014-2015 + +from django import template +from django.db import models + +from ..translation import get_translation, get_translated_field + + +register = template.Library() + + +@register.filter +def translation(obj): + return get_translation(obj) + + +@register.filter +def translate(obj, field_name): + return get_translated_field(obj, field_name) + +# Alias +translated_field = translate diff --git a/text.py b/text.py new file mode 100644 index 0000000..dca8b40 --- /dev/null +++ b/text.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2015 + +from django.utils.text import slugify +from django.utils import six +from django.utils.encoding import force_text +from django.utils.functional import allow_lazy +from django.utils.safestring import SafeText + +# import unicodedata +import translitcodec +import codecs + + +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) + + +def slugify_long(value): + value = force_text(value) + return slugify(downgrade(value)) +slugify_long = allow_lazy(slugify_long, six.text_type, SafeText) + + +def slugify_german(value): + """ + Transliterates Umlaute before calling django's slugify function. + """ + umlaute = { + 'Ä': 'Ae', + 'Ö': 'Oe', + 'Ü': 'Ue', + 'ä': 'ae', + 'ö': 'oe', + 'ü': 'ue', + 'ß': 'ss', + } + + value = force_text(value) + umap = {ord(key): unicode(val) for key, val in umlaute.items()} + return slugify(value.translate(umap)) +slugify_german = allow_lazy(slugify_german, six.text_type, SafeText) + diff --git a/timezone.py b/timezone.py new file mode 100644 index 0000000..ce87a0c --- /dev/null +++ b/timezone.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2015 + +from django.utils import timezone + + +def smart_default_tz(datetime_value): + """ + Returns the give datetime with the default timezone applied. + """ + if timezone.is_naive(datetime_value): + datetime_value = timezone.make_aware(datetime_value, timezone=timezone.get_default_timezone()) + return timezone.localtime(datetime_value, timezone.get_default_timezone()) + diff --git a/translation.py b/translation.py new file mode 100644 index 0000000..1b648eb --- /dev/null +++ b/translation.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +# Erik Stein , 2015 + +import os +from contextlib import contextmanager +from django import http +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist +from django.core.urlresolvers import translate_url +from django.template.loader import select_template +from django.utils import translation +from django.views.generic import TemplateView +from django.utils.translation import check_for_language, LANGUAGE_SESSION_KEY +from django.utils.http import is_safe_url +from django.http import HttpResponseRedirect +from django.views.i18n import LANGUAGE_QUERY_PARAMETER + + +def lang_suffix(language_code=None): + """ + Returns the suffix appropriate for adding to field names for selecting + the current language. + """ + if not language_code: + language_code = translation.get_language() + if not language_code: + language_code = settings.LANGUAGE_CODE + language_code = language_code[:2] or 'de' # FIXME Fall back to default language + return "_%s" % language_code + + +class DirectTemplateView(TemplateView): + extra_context = None + + def get_context_data(self, **kwargs): + context = super(DirectTemplateView, self).get_context_data(**kwargs) + if self.extra_context is not None: + for key, value in self.extra_context.items(): + if callable(value): + context[key] = value() + else: + context[key] = value + return context + + +class I18nDirectTemplateView(DirectTemplateView): + def get_template_names(self): + t_name, t_ext = os.path.splitext(self.template_name) + lang = translation.get_language() + template_name = select_template(( + "%s.%s%s" % (t_name, lang, t_ext), + self.template_name + )).name + return [template_name] + + +def i18n_direct_to_template(request, *args, **kwargs): + return I18nDirectTemplateView(*args, **kwargs).as_view() + + +def get_translation(obj, relation_name='translations', language_code=None): + language_code = language_code or translation.get_language()[:2] + try: + return getattr(obj, relation_name).get(language=language_code) + except ObjectDoesNotExist: + try: + return getattr(obj, relation_name).get(language=(language_code == 'en' and 'de' or 'en')) + except ObjectDoesNotExist: + return None + + +# class FieldTranslationMixin(object): +# """ +# If the model has a field `attr` or `attr_`, return it's +# value, else raise ValueError. +# """ + +# def __getattr__(self, attr): +# if attr in self.__dict__: +# return self.__dict__[attr] +# for field in self._meta.multilingual: +# code = None +# match = re.match(r'^%s_(?P[a-z_]{2,5})$' % field, str(attr)) +# if match: +# code = match.groups('code') +# code = code[:2] # let's limit it to two letter +# elif attr in self._meta.multilingual: +# code = self._language +# field = attr +# if code is not None: +# try: +# return self._meta.translation.objects.select_related().get(model=self, language__code=code).__dict__[field] +# except ObjectDoesNotExist: +# if MULTILINGUAL_FALL_BACK_TO_DEFAULT and MULTILINGUAL_DEFAULT and code != MULTILINGUAL_DEFAULT: +# try: +# return self._meta.translation.objects.select_related().get(model=self, language__code=MULTILINGUAL_DEFAULT).__dict__[field] +# except ObjectDoesNotExist: +# pass +# if MULTILINGUAL_FAIL_SILENTLY: +# return None +# raise ValueError, "'%s' has no translation in '%s'"%(self, code) +# raise AttributeError, "'%s' object has no attribute '%s'"%(self.__class__.__name__, str(attr)) + + +def get_translated_field(obj, field_name, language_code=None): + """ + Tries to get the model attribute corresponding to the current + selected language by appending "_" to the attribute + name and returning the value. + + On AttributeError try to return the other language or the attribute + without the language suffix. + + If the attribute is empty or null, try to return the value of + the other language's attribute. + + If there is an attribute with the name without any language code + extension, return the value of this. + + Best return value: + field_name + lang_suffix for current language + + If empty or field does not exist: + if default language and field_name + field_name + else + field_name + lang_suffix other language + """ + # TODO Implement multiple languages + language_code = (language_code or + translation.get_language() or + settings.LANGUAGE_CODE)[:2] + is_default_language = bool(language_code == settings.LANGUAGE_CODE[:2]) + if language_code == 'de': + other_language_code = 'en' + else: + other_language_code = 'de' + + def has_db_field(field_name): + try: + # Only try to access database fields to avoid recursion + obj._meta.get_field(field_name) + return True + except FieldDoesNotExist: + return False + + translated_field_name = '%s_%s' % (field_name, language_code) + other_translated_field_name = '%s_%s' % (field_name, other_language_code) + rv = "" + if hasattr(obj, translated_field_name): + rv = getattr(obj, translated_field_name) + if not rv: + if is_default_language and has_db_field(field_name): + rv = getattr(obj, field_name) + elif hasattr(obj, other_translated_field_name): + rv = getattr(obj, other_translated_field_name) + if not rv and has_db_field(field_name): + rv = getattr(obj, field_name) + # FIXME Raise error if neither field exists + return rv + + +@contextmanager +def active_language(lang='de'): + translation.activate(lang) + yield + translation.deactivate() + + +def set_language(request): + """ + Modified copy from django.views.i18n + """ + next = request.POST.get('next', request.GET.get('next')) + if not is_safe_url(url=next, host=request.get_host()): + next = request.META.get('HTTP_REFERER') + if not is_safe_url(url=next, host=request.get_host()): + next = '/' + response = http.HttpResponseRedirect(next) + if request.method == 'GET': + lang_code = request.GET.get(LANGUAGE_QUERY_PARAMETER, None) + if lang_code and check_for_language(lang_code): + next_trans = translate_url(next, lang_code) + if next_trans != next: + response = http.HttpResponseRedirect(next_trans) + + if hasattr(request, 'session'): + request.session[LANGUAGE_SESSION_KEY] = lang_code + else: + response.set_cookie(settings.LANGUAGE_COOKIE_NAME, lang_code, + max_age=settings.LANGUAGE_COOKIE_AGE, + path=settings.LANGUAGE_COOKIE_PATH, + domain=settings.LANGUAGE_COOKIE_DOMAIN) + return response