authentic/src/authentic2/custom_user/managers.py

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)