694 lines
20 KiB
Python
694 lines
20 KiB
Python
# 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.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_str
|
||
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(context['request'], 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
|
||
new_cell.include_pagination = bool(i + 1 == repeat)
|
||
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
|
||
def split(string, separator=' '):
|
||
return (force_str(string) or '').split(separator)
|
||
|
||
|
||
@register.filter
|
||
def strip(string, chars=None):
|
||
if not string:
|
||
return ''
|
||
if chars:
|
||
return force_str(string).strip(force_str(chars))
|
||
else:
|
||
return force_str(string).strip()
|
||
|
||
|
||
@register.filter
|
||
def removeprefix(string, prefix):
|
||
if not string:
|
||
return ''
|
||
value = force_str(string)
|
||
prefix = force_str(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_str(string)
|
||
suffix = force_str(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_str(string).startswith(force_str(substring))
|
||
|
||
|
||
@register.filter
|
||
def endswith(string, substring):
|
||
return string and force_str(string).endswith(force_str(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)))
|
||
|
||
|
||
@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 ''
|