combo/combo/public/templatetags/combo.py

728 lines
21 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# combo - content management system
# Copyright (C) 2014 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import copy
import datetime
import json
import math
from decimal import Decimal
from decimal import DivisionByZero as DecimalDivisionByZero
from decimal import InvalidOperation as DecimalInvalidOperation
import django
from django import template
from django.core import signing
from django.core.exceptions import PermissionDenied
from django.core.serializers.json import DjangoJSONEncoder
from django.template import VariableDoesNotExist
try:
from django.template.base import TokenType
TOKEN_BLOCK = TokenType.BLOCK
TOKEN_VAR = TokenType.VAR
TOKEN_COMMENT = TokenType.COMMENT
except ImportError:
from django.template.base import TOKEN_BLOCK, TOKEN_VAR, TOKEN_COMMENT
from django.template import Template, TemplateSyntaxError, defaultfilters
from django.template.defaultfilters import stringfilter
from django.utils import dateparse
from django.utils.encoding import force_text
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import is_naive, make_aware
from combo.apps.dashboard.models import DashboardCell, Tile
from combo.data.models import Page, Placeholder
from combo.public.menu import get_menu_context
from combo.utils import NothingInCacheException, flatten_context
from combo.utils.date import make_date, make_datetime
register = template.Library()
def skeleton_text(context, placeholder_name, content=''):
return '{%% block placeholder-%s %%}{%% block %s %%}%s{%% endblock %%}{%% endblock %%}' % (
placeholder_name,
placeholder_name,
content,
)
@register.inclusion_tag('combo/placeholder.html', takes_context=True)
def placeholder(context, placeholder_name, **options):
placeholder = Placeholder(key=placeholder_name, cell=context.get('cell'), **options)
if 'page' in context:
context['placeholder_options'] = context['page'].placeholder_options.get(placeholder_name) or {}
# make sure render_skeleton is available in context
context['render_skeleton'] = context.get('render_skeleton')
if context.get('placeholder_search_mode'):
if placeholder.name:
# only include placeholders with a name
context['placeholders'].append(placeholder)
if not context['traverse_cells']:
return context
context['render'] = True
context['placeholder'] = placeholder
if not placeholder.render:
context['render'] = False
return context
page_cells = []
if 'page_cells' in context:
# page cells are not precomputed when rendering a single cell in an
# ajax call
page_cells = context.get('page_cells')
elif 'page' in context and hasattr(context['page'], 'prefetched_cells'):
# sometimes cells are prefetched; use them
page_cells = context['page'].prefetched_cells
elif not context.get('render_skeleton'):
page_cells = context['page'].get_cells() if 'page' in context else []
if 'page' in context:
# store cells for later use
context['page'].prefetched_cells = page_cells
context['cells'] = [
x
for x in page_cells
if x.placeholder == placeholder_name
and (
context.get('render_skeleton')
or x.is_relevant(context)
and x.is_visible(user=context['request'].user, check_validity_info=False)
)
]
if context.get('render_skeleton'):
context['skeleton'] = skeleton_text(context, placeholder_name)
else:
context['skeleton'] = ''
if not context.get('placeholder_search_mode'):
if len(context['cells']) and placeholder.outer_tag:
context['outer_tag'] = placeholder.outer_tag
for cell in [x for x in context['cells'] if hasattr(x, 'get_repeat_template')]:
repeat_template = cell.get_repeat_template(context)
if not repeat_template:
continue
try:
repeat = int(Template(repeat_template).render(context))
except (ValueError, TemplateSyntaxError, VariableDoesNotExist):
continue
cell_idx = context['cells'].index(cell)
context['cells'].remove(cell)
if repeat == 0:
continue
repeated_cells = []
for i in range(repeat):
new_cell = copy.copy(cell)
new_cell.repeat_index = i
repeated_cells.append(new_cell)
context['cells'][cell_idx:cell_idx] = repeated_cells
return context
@register.simple_tag(takes_context=True)
def render_cell(context, cell):
if context.get('render_skeleton') and cell.is_user_dependant(context):
context = flatten_context(context)
return template.loader.get_template('combo/deferred-cell.html').render(context)
in_dashboard = False
if DashboardCell.is_enabled():
# check if cell is actually a dashboard tile
try:
tile = Tile.get_by_cell(cell)
except Tile.DoesNotExist:
pass
else:
if context['request'].user != tile.user:
raise PermissionDenied()
in_dashboard = True
context = flatten_context(context)
context['in_dashboard'] = in_dashboard
if 'placeholder' in context and context['placeholder'].force_synchronous:
context['synchronous'] = True
try:
return cell.render(context)
except NothingInCacheException:
return template.loader.get_template('combo/deferred-cell.html').render(context)
except:
if context.get('placeholder_search_mode'):
return ''
raise
@register.tag
def skeleton_extra_placeholder(parser, token):
try:
dummy, placeholder_name = token.split_contents()
except ValueError:
raise template.TemplateSyntaxError("%r tag requires exactly one argument" % token.contents.split()[0])
tokens_copy = parser.tokens[:]
if django.VERSION < (3,):
tokens_copy.reverse()
text = []
while True:
token = tokens_copy.pop()
if token.contents == 'end_skeleton_extra_placeholder':
break
if token.token_type == TOKEN_VAR:
text.append('{{ ')
elif token.token_type == TOKEN_BLOCK:
text.append('{% ')
elif token.token_type == TOKEN_COMMENT:
text.append('{# ')
text.append(token.contents)
if token.token_type == TOKEN_VAR:
text.append(' }}')
elif token.token_type == TOKEN_BLOCK:
text.append(' %}')
elif token.token_type == TOKEN_COMMENT:
text.append(' #}')
nodelist = parser.parse(('end_skeleton_extra_placeholder',))
parser.delete_first_token()
return ExtraPlaceholderNode(nodelist, placeholder_name, ''.join(text))
class ExtraPlaceholderNode(template.Node):
def __init__(self, nodelist, placeholder_name, content):
self.nodelist = nodelist
self.placeholder_name = placeholder_name
self.content = content
def render(self, context):
if not context.get('render_skeleton'):
return self.nodelist.render(context)
return skeleton_text(context, self.placeholder_name, content=self.content)
@register.inclusion_tag('combo/menu.html', takes_context=True)
def show_menu(context, level=0, current_page=None, depth=1, ignore_visibility=True, reduce_depth=False):
if reduce_depth:
depth -= 1
new_context = {
'page': context['page'],
'render_skeleton': context.get('render_skeleton'),
'request': context['request'],
}
return get_menu_context(
new_context, level=level, current_page=current_page, depth=depth, ignore_visibility=ignore_visibility
)
@register.simple_tag(takes_context=True)
def page_absolute_url(context, page):
return context['request'].build_absolute_uri(page.get_online_url())
@register.filter(name='strptime')
@stringfilter
def strptime(date_string, date_format):
try:
return datetime.datetime.strptime(date_string, date_format)
except ValueError:
return None
@register.filter
def parse_date(date_string):
try:
return make_date(date_string)
except ValueError:
pass
# fallback to Django function
try:
return dateparse.parse_date(date_string)
except (ValueError, TypeError):
return None
@register.filter(expects_localtime=True, is_safe=False)
def date(value, arg=None):
if arg is None:
return parse_date(value) or ''
if not isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
value = parse_datetime(value) or parse_date(value)
return defaultfilters.date(value, arg=arg)
@register.filter
def parse_datetime(datetime_string):
try:
return make_datetime(datetime_string)
except ValueError:
pass
# fallback to Django function
try:
return dateparse.parse_datetime(datetime_string)
except (ValueError, TypeError):
return None
@register.filter(name='datetime', expects_localtime=True, is_safe=False)
def datetime_(value, arg=None):
if arg is None:
return parse_datetime(value) or ''
if not isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
value = parse_datetime(value)
return defaultfilters.date(value, arg=arg)
@register.filter
def parse_time(time_string):
# if input is a datetime, extract its time
try:
dt = parse_datetime(time_string)
if dt:
return dt.time()
except (ValueError, TypeError):
pass
# fallback to Django function
try:
return dateparse.parse_time(time_string)
except (ValueError, TypeError):
return None
@register.filter(expects_localtime=True, is_safe=False)
def time(value, arg=None):
if arg is None:
parsed = parse_time(value)
return parsed if parsed is not None else '' # because bool(midnight) == False
if not isinstance(value, (datetime.datetime, datetime.date, datetime.time)):
value = parse_time(value)
return defaultfilters.date(value, arg=arg)
@register.filter
def shown_because_admin(cell, request):
if not (request.user and request.user.is_superuser):
return False
if cell.public:
return False
cell_groups = cell.groups.all()
if not cell_groups:
return False
return not (set(cell_groups).intersection(request.user.groups.all()))
@register.filter(name='has_role')
def has_role(user, groupname):
if not user or getattr(user, 'is_anonymous', True):
return False
if not hasattr(user, 'groups'):
return False
return user.groups.filter(name=groupname).exists()
@register.filter(name='get')
def get(obj, key):
try:
return obj.get(key)
except AttributeError:
try:
return obj[key]
except (IndexError, KeyError, TypeError):
return None
@register.filter
def split(string, separator=' '):
return (force_text(string) or '').split(separator)
@register.filter
def strip(string, chars=None):
if not string:
return ''
if chars:
return force_text(string).strip(force_text(chars))
else:
return force_text(string).strip()
@register.filter
def removeprefix(string, prefix):
if not string:
return ''
value = force_text(string)
prefix = force_text(prefix)
if prefix and value.startswith(prefix):
return value[len(prefix) :]
return value
@register.filter
def removesuffix(string, suffix):
if not string:
return ''
value = force_text(string)
suffix = force_text(suffix)
if suffix and value.endswith(suffix):
return value[: -len(suffix)]
return value
@register.filter(name='get_group')
def get_group(group_list, group_name):
ret = []
for group in group_list:
if getattr(group, 'grouper', Ellipsis) == group_name:
# Django >= 1.11, namedtuple
ret.extend(group.list)
elif not hasattr(group, 'grouper') and group['grouper'] == group_name:
ret.extend(group['list'])
return ret
@register.filter(name='is_empty_placeholder')
def is_empty_placeholder(page, placeholder_name):
return len([x for x in page.get_cells() if x.placeholder == placeholder_name]) == 0
@register.filter(name='as_json')
def as_json(obj):
return json.dumps(obj)
@register.filter
def signed(obj):
return signing.dumps(obj)
@register.filter
def name_id(user):
if user and user.is_authenticated:
user_name_id = user.get_name_id()
if user_name_id:
return user_name_id
# it is important to raise this so get_templated_url is aborted and no call
# is tried with a missing user argument.
raise VariableDoesNotExist('name_id')
@register.simple_tag
def get_page(page_slug):
return Page.objects.get(slug=page_slug)
@register.filter
def startswith(string, substring):
return string and force_text(string).startswith(force_text(substring))
@register.filter
def endswith(string, substring):
return string and force_text(string).endswith(force_text(substring))
def parse_float(value):
if isinstance(value, str):
# replace , by . for French users comfort
value = value.replace(',', '.')
try:
return float(value)
except (ValueError, TypeError):
return ''
def get_as_datetime(s):
result = parse_datetime(s)
if not result:
result = parse_date(s)
if result:
result = datetime.datetime(year=result.year, month=result.month, day=result.day)
return result
@register.filter(expects_localtime=True, is_safe=False)
def add_days(value, arg):
value = parse_date(value) # consider only date, not hours
if not value:
return ''
arg = parse_float(arg)
if not arg:
return value
result = value + datetime.timedelta(days=float(arg))
return result
@register.filter(expects_localtime=True, is_safe=False)
def add_hours(value, arg):
value = parse_datetime(value)
if not value:
return ''
arg = parse_float(arg)
if not arg:
return value
return value + datetime.timedelta(hours=float(arg))
@register.filter(expects_localtime=True, is_safe=False)
def age_in_days(value, today=None):
value = parse_date(value)
if not value:
return ''
if today is not None:
today = parse_date(today)
if not today:
return ''
else:
today = datetime.date.today()
return (today - value).days
@register.filter(expects_localtime=True, is_safe=False)
def age_in_hours(value, now=None):
# consider value and now as datetimes (and not dates)
value = parse_datetime(value)
if not value:
return ''
if now is not None:
now = parse_datetime(now)
if not now:
return ''
else:
now = datetime.datetime.now()
return int((now - value).total_seconds() / 3600)
def age_in_years_and_months(born, today=None):
'''Compute age since today as the number of years and months elapsed'''
born = make_date(born)
if not born:
return ''
if today is not None:
today = make_date(today)
if not today:
return ''
else:
today = datetime.date.today()
before = (today.month, today.day) < (born.month, born.day)
years = today.year - born.year
months = today.month - born.month
if before:
years -= 1
months += 12
if today.day < born.day:
months -= 1
return years, months
@register.filter(expects_localtime=True, is_safe=False)
def age_in_years(value, today=None):
try:
return age_in_years_and_months(value, today)[0]
except ValueError:
return ''
@register.filter(expects_localtime=True, is_safe=False)
def age_in_months(value, today=None):
try:
years, months = age_in_years_and_months(value, today)
except ValueError:
return ''
return years * 12 + months
@register.filter(expects_localtime=True)
def datetime_in_past(value):
value = parse_datetime(value)
if not value:
return False
if is_naive(value):
value = make_aware(value)
date_now = make_aware(datetime.datetime.now())
return value <= date_now
@register.filter(expects_localtime=True)
def adjust_to_week_monday(value):
value = parse_date(value)
if not value:
return ''
return value - datetime.timedelta(days=value.weekday())
@register.filter(expects_localtime=True)
def iterate_days_until(value, until):
value = parse_date(value)
until = parse_date(until)
if not (value and until):
return
while value < until:
yield value
value = value + datetime.timedelta(days=1)
yield value
def parse_decimal(value, default=Decimal(0)):
if isinstance(value, str):
# replace , by . for French users comfort
value = value.replace(',', '.')
try:
return Decimal(value).quantize(Decimal('1.0000')).normalize()
except (ArithmeticError, TypeError):
return default
@register.filter(is_safe=False)
def decimal(value, arg=None):
if not isinstance(value, Decimal):
value = parse_decimal(value)
if arg is None:
return value
return defaultfilters.floatformat(value, arg=arg)
@register.filter
def add(term1, term2):
'''replace the "add" native django filter'''
if term1 is None:
term1 = ''
if term2 is None:
term2 = ''
term1_decimal = parse_decimal(term1, default=None)
term2_decimal = parse_decimal(term2, default=None)
if term1_decimal is not None and term2_decimal is not None:
return term1_decimal + term2_decimal
if term1 == '' and term2_decimal is not None:
return term2_decimal
if term2 == '' and term1_decimal is not None:
return term1_decimal
return defaultfilters.add(term1, term2)
@register.filter
def subtract(term1, term2):
return parse_decimal(term1) - parse_decimal(term2)
@register.filter
def multiply(term1, term2):
return parse_decimal(term1) * parse_decimal(term2)
@register.filter
def divide(term1, term2):
try:
return parse_decimal(term1) / parse_decimal(term2)
except DecimalInvalidOperation:
return ''
except DecimalDivisionByZero:
return ''
@register.filter
def ceil(value):
'''the smallest integer value greater than or equal to value'''
return decimal(math.ceil(parse_decimal(value)))
@register.filter
def floor(value):
return decimal(math.floor(parse_decimal(value)))
@register.filter(name='abs')
def abs_(value):
return decimal(abs(parse_decimal(value)))
_json_script_escapes = {
ord('>'): '\\u003E',
ord('<'): '\\u003C',
ord('&'): '\\u0026',
}
@register.filter(is_safe=True)
def json_script(value, element_id):
"""
Escape all the HTML/XML special characters with their unicode escapes, so
value is safe to be output anywhere except for inside a tag attribute. Wrap
the escaped JSON in a script tag.
--
This is a backport from Django 2.1; it should be removed once Combo bumps
its minimal dependency to 2.1.
"""
json_str = json.dumps(value, cls=DjangoJSONEncoder).translate(_json_script_escapes)
return format_html('<script id="{}" type="application/json">{}</script>', element_id, mark_safe(json_str))
@register.filter(is_safe=False)
def phonenumber_fr(value, separator=' '):
DROMS = ('262', '508', '590', '594', '596')
if not value or not isinstance(value, str):
return value
number = value.strip()
if not number:
return value
if number[0] == '+':
international = '+'
number = '00' + number[1:]
else:
international = '00' + separator
number = ''.join(c for c in number if c in '0123456789')
def in_pairs(num):
return separator.join(num[i * 2 : i * 2 + 2] for i in range(len(num) // 2))
# local number
if len(number) == 10 and number[0] == '0' and number[1] in '123456789':
return in_pairs(number)
# international
if len(number) == 14 and number[0:5] == '00330':
# +/00 33 (0)x xx xx xx xx : remove (0)
number = number[0:4] + number[5:]
if len(number) == 13 and number[0:4] == '0033':
return international + '33' + separator + number[4] + separator + in_pairs(number[5:])
if len(number) == 11 and number[0:2] == '00' and number[2:5] in DROMS:
return international + number[2:5] + separator + in_pairs(number[5:])
# unknown
return value
@register.filter
def extra_context(cell, request):
ctx = copy.copy(getattr(request, 'extra_context_data', {}) or {})
if hasattr(cell, 'repeat_index'):
ctx['repeat_index'] = cell.repeat_index
return ctx
@register.filter(name='sum')
def sum_(list_):
if isinstance(list_, str):
# do not consider string as iterable, to avoid misusage
return ''
try:
return sum(parse_decimal(term) for term in list_)
except TypeError: # list_ is not iterable
return ''