authentic/src/authentic2/attribute_kinds.py

404 lines
12 KiB
Python

# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 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 re
import string
import datetime
import hashlib
import os
from itertools import chain
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.urls import reverse
from django.utils import six, formats
from django.utils.translation import ugettext_lazy as _, pgettext_lazy
from django.utils import html
from django.core.files.storage import default_storage
from django.db.models import query
from django.utils.functional import keep_lazy
from gadjo.templatetags.gadjo import xstatic
from rest_framework import serializers
from rest_framework.fields import empty
from .decorators import to_iter
from .plugins import collect_from_plugins
from . import app_settings
from .forms import widgets, fields
@keep_lazy(six.text_type)
def capfirst(value):
return value and value[0].upper() + value[1:]
DEFAULT_TITLE_CHOICES = (
(pgettext_lazy('title', 'Mrs'), pgettext_lazy('title', 'Mrs')),
(pgettext_lazy('title', 'Mr'), pgettext_lazy('title', 'Mr')),
)
class DateWidget(widgets.DateWidget):
help_text = _('Format: yyyy-mm-dd')
class DateField(forms.DateField):
widget = DateWidget
class DateRestField(serializers.DateField):
default_error_messages = {
'blank': _('This field may not be blank.'),
}
def __init__(self, **kwargs):
self.allow_blank = kwargs.pop('allow_blank', False)
self.trim_whitespace = kwargs.pop('trim_whitespace', True)
super(DateRestField, self).__init__(**kwargs)
def run_validation(self, data=empty):
# Test for the empty string here so that it does not get validated,
# and so that subclasses do not need to handle it explicitly
# inside the `to_internal_value()` method.
if data == '' or (self.trim_whitespace and six.text_type(data).strip() == ''):
if not self.allow_blank:
self.fail('blank')
return ''
return super(DateRestField, self).run_validation(data)
class BirthdateWidget(DateWidget):
def __init__(self, *args, **kwargs):
attrs = kwargs.setdefault('attrs', {})
options = kwargs.setdefault('options', {})
options['endDate'] = '-1d'
options['startDate'] = '1900-01-01'
attrs['min'] = '1900-01-01'
attrs['max'] = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
super(BirthdateWidget, self).__init__(*args, **kwargs)
def validate_birthdate(value):
if value and not (datetime.date(1900, 1, 1) <= value < datetime.date.today()):
raise ValidationError(_('birthdate must be in the past and greater or equal than 1900-01-01.'))
class BirthdateField(forms.DateField):
widget = BirthdateWidget
default_validators = [
validate_birthdate,
]
class BirthdateRestField(DateRestField):
default_validators = [
validate_birthdate,
]
class AddressAutocompleteInput(forms.Select):
template_name = 'authentic2/widgets/address_autocomplete.html'
class Media:
js = [
xstatic('jquery.js', 'jquery.min.js'),
settings.SELECT2_JS,
'authentic2/js/address_autocomplete.js',
]
css = {
'screen': [settings.SELECT2_CSS],
}
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.attrs['data-select2-url'] = reverse('a2-api-address-autocomplete')
self.attrs['class'] = 'address-autocomplete'
class AddressAutocompleteField(forms.CharField):
widget = AddressAutocompleteInput
@to_iter
def get_title_choices():
return app_settings.A2_ATTRIBUTE_KIND_TITLE_CHOICES or DEFAULT_TITLE_CHOICES
validate_phone_number = RegexValidator(
r'^\+?\d{,20}$',
message=_('Phone number can start with a + and must contain only digits.'))
class PhoneNumberField(forms.CharField):
widget = widgets.PhoneNumberInput
def __init__(self, *args, **kwargs):
kwargs['max_length'] = 30
kwargs.setdefault('help_text', _('ex.: 0699999999, +33 6 99 99 99 99'))
super(PhoneNumberField, self).__init__(*args, **kwargs)
def clean(self, value):
if value not in self.empty_values:
value = re.sub(r'[-.\s/]', '', value)
validate_phone_number(value)
return value
class PhoneNumberDRFField(serializers.CharField):
default_validators = [validate_phone_number]
validate_fr_postcode = RegexValidator(
r'^\d{5}$',
message=_('The value must be a valid french postcode'))
class FrPostcodeField(forms.CharField):
def __init__(self, *args, **kwargs):
kwargs.setdefault('help_text', _('ex.: 13260'))
super(FrPostcodeField, self).__init__(*args, **kwargs)
def clean(self, value):
value = super(FrPostcodeField, self).clean(value)
if value not in self.empty_values:
value = value.strip()
validate_fr_postcode(value)
return value
def widget_attrs(self, widget):
return {'inputmode': 'numeric'}
class FrPostcodeDRFField(serializers.CharField):
default_validators = [validate_fr_postcode]
class ProfileImageFile(object):
def __init__(self, name):
self.name = name
@property
def url(self):
return default_storage.url(self.name)
def profile_image_serialize(uploadedfile):
if not uploadedfile:
return ''
if hasattr(uploadedfile, 'url'):
return uploadedfile.name
h_computation = hashlib.md5()
for chunk in uploadedfile.chunks():
h_computation.update(chunk)
hexdigest = h_computation.hexdigest()
stored_file = default_storage.save(
os.path.join('profile-image', hexdigest + '.jpeg'),
uploadedfile)
return stored_file
def profile_image_deserialize(name):
if name:
return ProfileImageFile(name)
return None
def profile_image_html_value(attribute, value):
if value:
fragment = u'<a href="%s"><img class="%s" src="%s"/></a>' % (
value.url, attribute.name, value.url)
return html.mark_safe(fragment)
return ''
def profile_attributes_ng_serialize(ctx, value):
if value and getattr(value, 'url', None):
request = ctx.get('request')
if request:
return request.build_absolute_uri(value.url)
else:
return value.url
return None
def date_free_text_search(term):
for date_format in formats.get_format('DATE_INPUT_FORMATS'):
try:
date = datetime.datetime.strptime(term, date_format).date()
break
except (ValueError, TypeError):
pass
else:
return None
return query.Q(attribute_values__content__exact=date.isoformat())
DEFAULT_ALLOW_BLANK = True
DEFAULT_MAX_LENGTH = 256
DEFAULT_ATTRIBUTE_KINDS = [
{
'label': _('string'),
'name': 'string',
'field_class': forms.CharField,
'kwargs': {
'max_length': DEFAULT_MAX_LENGTH,
},
},
{
'label': _('title'),
'name': 'title',
'field_class': forms.ChoiceField,
'kwargs': {
'choices': get_title_choices(),
'widget': forms.RadioSelect,
}
},
{
'label': _('boolean'),
'name': 'boolean',
'field_class': forms.BooleanField,
'serialize': lambda x: str(int(bool(x))),
'deserialize': lambda x: bool(int(x)),
'rest_framework_field_class': serializers.NullBooleanField,
},
{
'label': _('date'),
'name': 'date',
'field_class': DateField,
'serialize': lambda x: x and x.isoformat(),
'deserialize': lambda x: x and datetime.datetime.strptime(x, '%Y-%m-%d').date(),
'rest_framework_field_class': DateRestField,
},
{
'label': _('birthdate'),
'name': 'birthdate',
'field_class': BirthdateField,
'serialize': lambda x: x and x.isoformat(),
'deserialize': lambda x: x and datetime.datetime.strptime(x, '%Y-%m-%d').date(),
'rest_framework_field_class': BirthdateRestField,
'free_text_search': date_free_text_search,
},
{
'label': _('address (autocomplete)'),
'name': 'address_auto',
'field_class': AddressAutocompleteField,
},
{
'label': _('french postcode'),
'name': 'fr_postcode',
'field_class': FrPostcodeField,
'rest_framework_field_class': FrPostcodeDRFField,
},
{
'label': _('phone number'),
'name': 'phone_number',
'field_class': PhoneNumberField,
'rest_framework_field_class': PhoneNumberDRFField,
},
{
'label': _('profile image'),
'name': 'profile_image',
'field_class': fields.ProfileImageField,
'serialize': profile_image_serialize,
'deserialize': profile_image_deserialize,
'rest_framework_field_class': serializers.FileField,
'rest_framework_field_kwargs': {
'read_only': True,
'use_url': True,
},
'html_value': profile_image_html_value,
'attributes_ng_serialize': profile_attributes_ng_serialize,
'csv_importable': False,
},
]
def get_attribute_kinds():
attribute_kinds = {}
for attribute_kind in chain(DEFAULT_ATTRIBUTE_KINDS, app_settings.A2_ATTRIBUTE_KINDS):
attribute_kinds[attribute_kind['name']] = attribute_kind
for attribute_kind in chain(*collect_from_plugins('attribute_kinds')):
attribute_kinds[attribute_kind['name']] = attribute_kind
return attribute_kinds
@to_iter
def get_choices():
'''Produce a choice list to use in form fields'''
for d in get_attribute_kinds().values():
yield (d['name'], capfirst(d['label']))
def only_digits(value):
return u''.join(x for x in value if x in string.digits)
def validate_lun(value):
l = [(int(x) * (1 + i % 2)) for i, x in enumerate(reversed(value))] # noqa: E741
return sum(x - 9 if x > 10 else x for x in l) % 10 == 0
def validate_siret(value):
RegexValidator(r'^( *[0-9] *){14}$', _('SIRET number must contain 14 digits'), 'coin')(value)
value = only_digits(value)
if not validate_lun(value) or not validate_lun(value[:9]):
raise ValidationError(_('SIRET validation code does not match'))
class SIRETField(forms.CharField):
default_validators = [validate_siret]
def to_python(self, value):
value = super(SIRETField, self).to_python(value)
value = only_digits(value)
return value
def widget_attrs(self, widget):
return {'inputmode': 'numeric'}
def contribute_to_form(attribute_descriptions, form):
for attribute_description in attribute_descriptions:
attribute_description.contribute_to_form(form)
def get_form_field(kind, **kwargs):
defn = get_attribute_kinds()[kind]
if 'kwargs' in defn:
kwargs.update(defn['kwargs'])
return defn['field_class'](**kwargs)
def identity(x):
return x
def get_kind(kind):
d = get_attribute_kinds()[kind]
d.setdefault('default', None)
d.setdefault('serialize', identity)
d.setdefault('deserialize', identity)
rest_field_kwargs = d.setdefault('rest_framework_field_kwargs', {})
if 'rest_framework_field_class' not in d:
d['rest_framework_field_class'] = serializers.CharField
rest_field_kwargs.setdefault('allow_blank', DEFAULT_ALLOW_BLANK)
rest_field_kwargs.setdefault('max_length', DEFAULT_MAX_LENGTH)
return d