Skip to content

Commit 3fc549e

Browse files
committed
Clean-up autocomplete API, non-expired results first
1 parent 801eb45 commit 3fc549e

File tree

5 files changed

+77
-79
lines changed

5 files changed

+77
-79
lines changed

collectives/api/autocomplete_user.py

+18-49
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from flask import request, abort
1010
from flask_login import current_user
11-
from sqlalchemy.sql import text
11+
from sqlalchemy.orm import Query
1212
from sqlalchemy import func, and_
1313
from marshmallow import fields
1414

@@ -31,39 +31,18 @@ class Meta:
3131
fields = (
3232
"id",
3333
"full_name",
34-
"license_expiry_date",
34+
"is_active",
3535
)
3636

3737

38-
def find_users_by_fuzzy_name(pattern, limit=8):
39-
"""Find user for autocomplete from a part of their full name.
38+
def _make_autocomplete_query(pattern: str) -> Query:
39+
"""Builds the autocomplete query for the provided pattern"""
4040

41-
Comparison are case insensitive.
41+
query = db.session.query(User)
42+
query = query.filter(func.lower(User.full_name()).like(f"%{pattern}%"))
43+
query = query.order_by(User.is_active.desc(), User.full_name(), User.id)
4244

43-
:param string pattern: Part of the name that will be searched.
44-
:param int limit: Maximum number of response.
45-
:return: List of users corresponding to ``pattern``
46-
:rtype: list(:py:class:`collectives.models.user.User`)
47-
"""
48-
if "sqlite" in db.engine.name:
49-
# SQLlite does not follow SQL standard
50-
concat_clause = "(first_name || ' ' || last_name)"
51-
else:
52-
concat_clause = "CONCAT(first_name, ' ', last_name)"
53-
54-
sql = (
55-
f"SELECT id, first_name, last_name from users WHERE "
56-
f"LOWER({concat_clause}) LIKE :pattern LIMIT :limit"
57-
)
58-
59-
sql_pattern = f"%{pattern.lower()}%"
60-
found_users = (
61-
db.session.query(User)
62-
.from_statement(text(sql))
63-
.params(pattern=sql_pattern, limit=limit)
64-
)
65-
66-
return found_users
45+
return query
6746

6847

6948
@blueprint.route("/users/autocomplete/create_rental")
@@ -90,8 +69,8 @@ def autocomplete_users_create_rental():
9069
if pattern is None or (len(pattern) < 2):
9170
found_users = []
9271
else:
93-
limit = request.args.get("l", type=int) or 8
94-
found_users = find_users_by_fuzzy_name(pattern, limit)
72+
limit = request.args.get("l", default=8, type=int)
73+
found_users = _make_autocomplete_query(pattern).limit(limit)
9574

9675
content = json.dumps(AutocompleteUserSchema(many=True).dump(found_users))
9776
return content, 200, {"content-type": "application/json"}
@@ -121,8 +100,8 @@ def autocomplete_users():
121100
if pattern is None or (len(pattern) < 2):
122101
found_users = []
123102
else:
124-
limit = request.args.get("l", type=int) or 8
125-
found_users = find_users_by_fuzzy_name(pattern, limit)
103+
limit = request.args.get("l", default=8, type=int)
104+
found_users = _make_autocomplete_query(pattern).limit(limit)
126105

127106
content = json.dumps(AutocompleteUserSchema(many=True).dump(found_users))
128107
return content, 200, {"content-type": "application/json"}
@@ -148,16 +127,11 @@ def autocomplete_leaders():
148127
if len(pattern) < 2:
149128
found_users = []
150129
else:
151-
limit = request.args.get("l", type=int) or 8
130+
limit = request.args.get("l", default=8, type=int)
152131

153-
query = db.session.query(User)
154-
condition = func.lower(User.first_name + " " + User.last_name).like(
155-
f"%{pattern}%"
156-
)
157-
query = query.filter(condition)
132+
query = _make_autocomplete_query(pattern)
158133
query = query.filter(User.led_events)
159-
found_users = query.order_by(User.id).all()
160-
found_users = found_users[0:limit]
134+
found_users = query.limit(limit)
161135

162136
content = json.dumps(AutocompleteUserSchema(many=True).dump(found_users))
163137
return content, 200, {"content-type": "application/json"}
@@ -188,15 +162,15 @@ def autocomplete_available_leaders():
188162
if pattern is None or (len(pattern) < 2):
189163
found_users = []
190164
else:
191-
limit = request.args.get("l", type=int) or 8
165+
limit = request.args.get("l", default=8, type=int)
166+
167+
query = _make_autocomplete_query(pattern)
192168
event_type = db.session.get(
193169
EventType, request.args.get("etype", type=int, default=0)
194170
)
195171
activity_ids = request.args.getlist("aid", type=int)
196172
existing_ids = request.args.getlist("eid", type=int)
197173

198-
query = db.session.query(User)
199-
200174
if event_type and event_type.requires_activity:
201175
ok_roles = RoleIds.all_activity_leader_roles()
202176
else:
@@ -206,12 +180,7 @@ def autocomplete_available_leaders():
206180
role_condition = and_(role_condition, Role.activity_id.in_(activity_ids))
207181
query = query.filter(User.roles.any(role_condition))
208182
query = query.filter(~User.id.in_(existing_ids))
209-
condition = func.lower(User.first_name + " " + User.last_name).ilike(
210-
f"%{pattern}%"
211-
)
212-
query = query.filter(condition)
213183

214-
query = query.order_by(User.first_name, User.last_name, User.id)
215184
found_users = query.limit(limit)
216185

217186
content = json.dumps(AutocompleteUserSchema(many=True).dump(found_users))

collectives/models/user/misc.py

-27
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from collectives.models.registration import Registration, RegistrationStatus
1515
from collectives.models.reservation import ReservationStatus
1616
from collectives.models.user.enum import Gender, UserType
17-
from collectives.utils.time import current_time
1817
from collectives.utils.misc import is_valid_image
1918

2019

@@ -115,13 +114,6 @@ def has_signed_legal_text(self):
115114

116115
# Format
117116

118-
def full_name(self):
119-
"""Get user full name.
120-
121-
:rtype: String
122-
"""
123-
return f"{self.first_name} {self.last_name.upper()}"
124-
125117
def __str__(self) -> str:
126118
"""Displays the user name."""
127119
return self.full_name() + f" (ID {self.id})"
@@ -158,25 +150,6 @@ def get_reservations_completed(self):
158150
reservation_list.append(reservation)
159151
return reservation_list
160152

161-
@property
162-
def is_active(self):
163-
"""Check if user is currently active.
164-
165-
An active user is not disabled, its license is valid and
166-
is not candidate user.
167-
168-
:return: True if user is active.
169-
:rtype: boolean
170-
"""
171-
if not self.enabled:
172-
return False
173-
if not self.check_license_valid_at_time(current_time()):
174-
return False
175-
if self.type == UserType.UnverifiedLocal:
176-
return False
177-
178-
return True
179-
180153
def has_valid_phone_number(self, emergency=False):
181154
"""Check if the user has a valid phone number.
182155

collectives/models/user/model.py

+57-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
from wtforms.validators import Email
66
from sqlalchemy_utils import PasswordType
77
from sqlalchemy.orm import validates
8+
from sqlalchemy.sql import and_, case
89
from sqlalchemy.ext.declarative import declared_attr
9-
10+
from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method
1011

1112
from collectives.models.globals import db
1213
from collectives.models.user.enum import Gender, UserType
1314
from collectives.utils.misc import truncate
15+
from collectives.utils.time import current_time
1416

1517

1618
class UserModelMixin:
@@ -317,3 +319,57 @@ def question_answers(self):
317319
backref="user",
318320
lazy=True,
319321
)
322+
323+
@hybrid_method
324+
def full_name(self) -> str:
325+
"""Returns the user full name as a string"""
326+
return self.first_name + " " + self.last_name.upper()
327+
328+
# pylint: disable=no-self-argument
329+
330+
@full_name.expression
331+
def full_name(cls):
332+
"""Returns the user full name as a SQL expression"""
333+
return cls.first_name + " " + cls.last_name
334+
335+
@hybrid_property
336+
def is_active(self) -> bool:
337+
"""Check if user is currently active.
338+
339+
An active user is not disabled, its license is valid and
340+
is not candidate user.
341+
342+
:return: True if user is active.
343+
"""
344+
if not self.enabled:
345+
return False
346+
if self.type == UserType.Extranet and not self.check_license_valid_at_time(
347+
current_time()
348+
):
349+
return False
350+
if self.type == UserType.UnverifiedLocal:
351+
return False
352+
353+
return True
354+
355+
@is_active.expression
356+
def is_active(cls):
357+
"""Check if user is currently active.
358+
359+
An active user is not disabled, its license is valid and
360+
is not candidate user.
361+
362+
:return: A SQL expression
363+
"""
364+
365+
return and_(
366+
cls.enabled,
367+
case(
368+
{
369+
UserType.UnverifiedLocal: False,
370+
UserType.Extranet: cls.license_expiry_date >= current_time(),
371+
},
372+
value=cls.type,
373+
else_=True,
374+
),
375+
)

collectives/static/js/utils/autocomplete.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function setupAutoComplete(
5454
const renderItem = function (item) {
5555
var val = itemValue(item);
5656
var innerHTML = settings.itemInnerHTML(item, val);
57-
if (new Date(item.license_expiry_date) < new Date() ){
57+
if (item.is_active === false){
5858
var style = "text-decoration-line: line-through; color: grey; font-style: italic;"
5959
var misc = `(expiré)`
6060
}

collectives/templates/event/partials/admin.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ <h5 class="heading-5 collective-display--administration-title"> Inscrire un adh
127127
<form action="{{url_for('event.register_user', event_id=event.id)}}" id="user-search-form" method="POST" class="form form-vertical" >
128128
<div id="user-search-data" class="form-search search-data">
129129
<p>
130-
<input id = "user-search" autocomplete="off" class="search-input" placeholder="Nom…" >
130+
<input id = "user-search" size="32" autocomplete="off" class="search-input" placeholder="Nom…" >
131131
</p>
132132
{{ register_user_form.user_id(id = 'user-search-resultid', type = 'hidden') }}
133133
{{ register_user_form.hidden_tag() }}

0 commit comments

Comments
 (0)