404 lines
12 KiB
Python
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
|