181 lines
7.0 KiB
Python
181 lines
7.0 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 unicodedata
|
|
import uuid
|
|
|
|
from django.contrib.auth.models import BaseUserManager
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.contrib.postgres.search import SearchQuery, TrigramDistance
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import connection, models
|
|
from django.db.models import F, FloatField, OuterRef, Q, Subquery, Value
|
|
from django.db.models.functions import Coalesce, Lower
|
|
from django.utils import timezone
|
|
|
|
from authentic2 import app_settings
|
|
from authentic2.attribute_kinds import clean_number
|
|
from authentic2.models import AttributeValue
|
|
from authentic2.utils.date import parse_date
|
|
from authentic2.utils.lookups import ImmutableConcat, Unaccent
|
|
|
|
|
|
class UserQuerySet(models.QuerySet):
|
|
def free_text_search(self, search):
|
|
search = search.strip()
|
|
|
|
def wrap_qs(qs):
|
|
return qs.annotate(dist=Value(0, output_field=FloatField()))
|
|
|
|
if len(search) == 0:
|
|
return wrap_qs(self.none())
|
|
|
|
if '@' in search and len(search.split()) == 1:
|
|
with connection.cursor() as cursor:
|
|
cursor.execute("SET pg_trgm.similarity_threshold = %f" % app_settings.A2_FTS_THRESHOLD)
|
|
qs = self.filter(email__icontains=search).order_by(Unaccent('last_name'), Unaccent('first_name'))
|
|
if qs.exists():
|
|
return wrap_qs(qs)
|
|
|
|
# not match, search by trigrams
|
|
qs = self.annotate(lower_email=Lower('email'))
|
|
value = Lower(Value(search))
|
|
qs = qs.filter(lower_email__trigram_similar=value)
|
|
qs = qs.annotate(dist=TrigramDistance('lower_email', value))
|
|
qs = qs.order_by('dist', 'last_name', 'first_name')
|
|
return qs
|
|
|
|
try:
|
|
guid = uuid.UUID(search)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
return wrap_qs(self.filter(uuid=guid.hex))
|
|
|
|
try:
|
|
phone_number = clean_number(search)
|
|
except ValidationError:
|
|
pass
|
|
else:
|
|
attribute_values = AttributeValue.objects.filter(
|
|
search_vector=SearchQuery(phone_number), attribute__kind='phone_number'
|
|
)
|
|
qs = self.filter(attribute_values__in=attribute_values).order_by('last_name', 'first_name')
|
|
if qs.exists():
|
|
return wrap_qs(qs)
|
|
|
|
try:
|
|
date = parse_date(search)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
attribute_values = AttributeValue.objects.filter(
|
|
search_vector=SearchQuery(date.isoformat()), attribute__kind='birthdate'
|
|
)
|
|
qs = self.filter(attribute_values__in=attribute_values).order_by('last_name', 'first_name')
|
|
if qs.exists():
|
|
return wrap_qs(qs)
|
|
|
|
qs = self.find_duplicates(fullname=search, limit=None, threshold=app_settings.A2_FTS_THRESHOLD)
|
|
extra_user_ids = set()
|
|
attribute_values = AttributeValue.objects.filter(
|
|
search_vector=SearchQuery(search), attribute__searchable=True
|
|
)
|
|
extra_user_ids.update(self.filter(attribute_values__in=attribute_values).values_list('id', flat=True))
|
|
if len(search.split()) == 1:
|
|
extra_user_ids.update(
|
|
self.filter(Q(username__istartswith=search) | Q(email__istartswith=search)).values_list(
|
|
'id', flat=True
|
|
)
|
|
)
|
|
if extra_user_ids:
|
|
qs = qs | self.filter(id__in=extra_user_ids)
|
|
qs = qs.order_by('dist', Unaccent('last_name'), Unaccent('first_name'))
|
|
return qs
|
|
|
|
def find_duplicates(
|
|
self, first_name=None, last_name=None, fullname=None, birthdate=None, limit=5, threshold=None
|
|
):
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"SET pg_trgm.similarity_threshold = %f" % (threshold or app_settings.A2_DUPLICATES_THRESHOLD)
|
|
)
|
|
|
|
if fullname is not None:
|
|
name = fullname
|
|
else:
|
|
assert first_name is not None and last_name is not None
|
|
name = '%s %s' % (first_name, last_name)
|
|
name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii').lower()
|
|
|
|
qs = self.annotate(name=Lower(Unaccent(ImmutableConcat('first_name', Value(' '), 'last_name'))))
|
|
qs = qs.filter(name__trigram_similar=name)
|
|
qs = qs.annotate(dist=TrigramDistance('name', name))
|
|
qs = qs.order_by('dist')
|
|
if limit is not None:
|
|
qs = qs[:limit]
|
|
|
|
# alter distance according to additionnal parameters
|
|
if birthdate:
|
|
bonus = app_settings.A2_DUPLICATES_BIRTHDATE_BONUS
|
|
content_type = ContentType.objects.get_for_model(self.model)
|
|
same_birthdate = AttributeValue.objects.filter(
|
|
object_id=OuterRef('pk'),
|
|
content_type=content_type,
|
|
attribute__kind='birthdate',
|
|
content=birthdate,
|
|
).annotate(bonus=Value(1 - bonus, output_field=FloatField()))
|
|
qs = qs.annotate(
|
|
dist=Coalesce(
|
|
Subquery(same_birthdate.values('bonus'), output_field=FloatField()) * F('dist'), F('dist')
|
|
)
|
|
)
|
|
|
|
return qs
|
|
|
|
|
|
class UserManager(BaseUserManager):
|
|
def _create_user(self, username, email, password, is_staff, is_superuser, **extra_fields):
|
|
"""
|
|
Creates and saves a User with the given username, email and password.
|
|
"""
|
|
now = timezone.now()
|
|
if not username:
|
|
raise ValueError('The given username must be set')
|
|
email = self.normalize_email(email)
|
|
user = self.model(
|
|
username=username,
|
|
email=email,
|
|
is_staff=is_staff,
|
|
is_active=True,
|
|
is_superuser=is_superuser,
|
|
last_login=now,
|
|
date_joined=now,
|
|
**extra_fields,
|
|
)
|
|
user.set_password(password)
|
|
user.save(using=self._db)
|
|
return user
|
|
|
|
def create_user(self, username, email=None, password=None, **extra_fields):
|
|
return self._create_user(username, email, password, False, False, **extra_fields)
|
|
|
|
def create_superuser(self, username, email, password, **extra_fields):
|
|
return self._create_user(username, email, password, True, True, **extra_fields)
|
|
|
|
def get_by_natural_key(self, uuid):
|
|
return self.get(uuid=uuid)
|