1 # This file is part of Indico.
2 # Copyright (C) 2002 - 2015 European Organization for Nuclear Research (CERN).
4 # Indico is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License as
6 # published by the Free Software Foundation; either version 3 of the
7 # License, or (at your option) any later version.
9 # Indico is distributed in the hope that it will be useful, but
10 # WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
12 # General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with Indico; if not, see <http://www.gnu.org/licenses/>.
17 from __future__
import unicode_literals
19 from operator
import attrgetter
21 from flask
import flash
22 from flask_multipass
import IdentityRetrievalFailed
23 from sqlalchemy
.event
import listens_for
24 from sqlalchemy
.ext
.associationproxy
import association_proxy
25 from sqlalchemy
.ext
.hybrid
import hybrid_property
26 from werkzeug
.utils
import cached_property
28 from indico
.core
.auth
import multipass
29 from indico
.core
.db
import db
30 from indico
.core
.db
.sqlalchemy
import PyIntEnum
31 from indico
.core
.db
.sqlalchemy
.custom
.unaccent
import define_unaccented_lowercase_index
32 from indico
.modules
.users
.models
.affiliations
import UserAffiliation
33 from indico
.modules
.users
.models
.emails
import UserEmail
34 from indico
.modules
.users
.models
.favorites
import favorite_user_table
, FavoriteCategory
35 from indico
.modules
.users
.models
.links
import UserLink
36 from indico
.util
.i18n
import _
37 from indico
.util
.string
import return_ascii
38 from indico
.util
.struct
.enum
import TitledIntEnum
41 class UserTitle(TitledIntEnum
):
42 __titles__
= ('', _('Mr.'), _('Ms.'), _('Mrs.'), _('Dr.'), _('Prof.'))
51 #: Fields which can be synced as keys and a mapping to a more human
52 #: readable version, used for flashing messages
54 'first_name': _("first name"),
55 'last_name': _("family name"),
56 'affiliation': _("affiliation"),
57 'address': _("address"),
58 'phone': _("phone number")
65 # Useful when dealing with both users and groups in the same code
68 __tablename__
= 'users'
69 __table_args__
= (db
.CheckConstraint('id != merged_into_id', 'not_merged_self'),
70 db
.CheckConstraint("is_pending OR (first_name != '' AND last_name != '')",
71 'not_pending_proper_names'),
74 #: the unique id of the user
79 #: the first name of the user
80 first_name
= db
.Column(
85 #: the last/family name of the user
86 last_name
= db
.Column(
91 # the title of the user - you usually want the `title` property!
96 default
=UserTitle
.none
98 #: the phone number of the user
104 #: the address of the user
110 #: the id of the user this user has been merged into
111 merged_into_id
= db
.Column(
113 db
.ForeignKey('users.users.id'),
116 #: if the user is an administrator with unrestricted access to everything
117 is_admin
= db
.Column(
123 #: if the user has been blocked
124 is_blocked
= db
.Column(
129 #: if the user is pending (e.g. never logged in, only added to some list)
130 is_pending
= db
.Column(
135 #: if the user is deleted (e.g. due to a merge)
136 is_deleted
= db
.Column(
143 _affiliation
= db
.relationship(
147 cascade
='all, delete-orphan',
148 backref
=db
.backref('user', lazy
=True)
151 _primary_email
= db
.relationship(
155 cascade
='all, delete-orphan',
156 primaryjoin
='(User.id == UserEmail.user_id) & UserEmail.is_primary'
158 _secondary_emails
= db
.relationship(
161 cascade
='all, delete-orphan',
162 collection_class
=set,
163 primaryjoin
='(User.id == UserEmail.user_id) & ~UserEmail.is_primary'
165 _all_emails
= db
.relationship(
169 primaryjoin
='User.id == UserEmail.user_id',
170 collection_class
=set,
171 backref
=db
.backref('user', lazy
=False)
173 #: the affiliation of the user
174 affiliation
= association_proxy('_affiliation', 'name', creator
=lambda v
: UserAffiliation(name
=v
))
175 #: the primary email address of the user
176 email
= association_proxy('_primary_email', 'email', creator
=lambda v
: UserEmail(email
=v
, is_primary
=True))
177 #: any additional emails the user might have
178 secondary_emails
= association_proxy('_secondary_emails', 'email', creator
=lambda v
: UserEmail(email
=v
))
179 #: all emails of the user. read-only; use it only for searching by email! also, do not use it between
180 #: modifying `email` or `secondary_emails` and a session expire/commit!
181 all_emails
= association_proxy('_all_emails', 'email') # read-only!
183 #: the user this user has been merged into
184 merged_into_user
= db
.relationship(
187 backref
=db
.backref('merged_from_users', lazy
=True),
188 remote_side
='User.id',
190 #: the users's favorite users
191 favorite_users
= db
.relationship(
193 secondary
=favorite_user_table
,
194 primaryjoin
=id == favorite_user_table
.c
.user_id
,
195 secondaryjoin
=(id == favorite_user_table
.c
.target_id
) & ~is_deleted
,
197 collection_class
=set,
198 backref
=db
.backref('favorite_of', lazy
=True, collection_class
=set),
200 _favorite_categories
= db
.relationship(
203 cascade
='all, delete-orphan',
206 #: the users's favorite categories
207 favorite_categories
= association_proxy('_favorite_categories', 'target',
208 creator
=lambda x
: FavoriteCategory(target
=x
))
209 #: the legacy objects the user is connected to
210 linked_objects
= db
.relationship(
213 cascade
='all, delete-orphan',
214 backref
=db
.backref('user', lazy
=True)
216 #: the active API key of the user
217 api_key
= db
.relationship(
221 cascade
='all, delete-orphan',
222 primaryjoin
='(User.id == APIKey.user_id) & APIKey.is_active',
223 back_populates
='user'
225 #: the previous API keys of the user
226 old_api_keys
= db
.relationship(
229 cascade
='all, delete-orphan',
230 order_by
='APIKey.created_dt.desc()',
231 primaryjoin
='(User.id == APIKey.user_id) & ~APIKey.is_active',
232 back_populates
='user'
234 #: the identities used by this user
235 identities
= db
.relationship(
238 cascade
='all, delete-orphan',
239 collection_class
=set,
240 backref
=db
.backref('user', lazy
=False)
243 # relationship backrefs:
244 # - local_groups (User.local_groups)
245 # - reservations_booked_for (Reservation.booked_for_user)
246 # - reservations (Reservation.created_by_user)
247 # - blockings (Blocking.created_by_user)
248 # - owned_rooms (Room.owner) - use `Room.get_owned_by()` if you also want managed rooms!
249 # - agreements (Agreement.user)
250 # - requests_created (Request.created_by_user)
251 # - requests_processed (Request.processed_by_user)
252 # - static_sites (StaticSite.user)
253 # - event_reminders (EventReminder.creator)
254 # - oauth_tokens (OAuthToken.user)
255 # - event_log_entries (EventLogEntry.user)
256 # - event_notes_revisions (EventNoteRevision.user)
257 # - attachments (Attachment.user)
258 # - attachment_files (AttachmentFile.user)
261 def as_principal(self
):
262 """The serializable principal identifier of this user"""
263 return 'User', self
.id
267 # TODO: remove this after DB is free of Avatars
268 from indico
.modules
.users
.legacy
import AvatarUserWrapper
269 avatar
= AvatarUserWrapper(self
.id)
271 # avoid garbage collection
275 as_legacy
= as_avatar
278 def external_identities(self
):
279 """The external identities of the user"""
280 return {x
for x
in self
.identities
if x
.provider
!= 'indico'}
283 def local_identities(self
):
284 """The local identities of the user"""
285 return {x
for x
in self
.identities
if x
.provider
== 'indico'}
288 def local_identity(self
):
289 """The main (most recently used) local identity"""
290 identities
= sorted(self
.local_identities
, key
=attrgetter('safe_last_login_dt'), reverse
=True)
291 return identities
[0] if identities
else None
294 def secondary_local_identities(self
):
295 """The local identities of the user except the main one"""
296 return self
.local_identities
- {self
.local_identity
}
300 return {'user_id': self
.id}
304 """the title of the user"""
305 return self
._title
.title
312 def title(self
, value
):
317 """Returns the user settings proxy for this user"""
318 from indico
.modules
.users
import user_settings
319 return user_settings
.bind(self
)
323 """Returns the user's name in 'Firstname Lastname' notation."""
324 return self
.get_full_name(last_name_first
=False, last_name_upper
=False, abbrev_first_name
=False)
326 # Convenience property to have a common `name` property for both groups and users
330 def synced_fields(self
):
331 """The fields of the user whose values are currently synced.
333 This set is always a subset of the synced fields define in
334 synced fields of the idp in 'indico.conf'.
336 synced_fields
= self
.settings
.get('synced_fields')
337 # If synced_fields is missing or None, then all fields are synced
338 if synced_fields
is None:
339 return multipass
.synced_fields
341 return set(synced_fields
) & multipass
.synced_fields
343 @synced_fields.setter
344 def synced_fields(self
, value
):
345 value
= set(value
) & multipass
.synced_fields
346 if value
== multipass
.synced_fields
:
347 self
.settings
.delete('synced_fields')
349 self
.settings
.set('synced_fields', list(value
))
352 def synced_values(self
):
353 """The values from the synced identity for the user.
355 Those values are not the actual user's values and might differ
356 if they are not set as synchronized.
358 identity
= self
._get
_synced
_identity
(refresh
=False)
361 return {field
: (identity
.data
.get(field
) or '') for field
in multipass
.synced_fields
}
363 def __contains__(self
, user
):
364 """Convenience method for `user in user_or_group`."""
369 return '<User({}, {}, {}, {})>'.format(self
.id, self
.first_name
, self
.last_name
, self
.email
)
371 def can_be_modified(self
, user
):
372 """If this user can be modified by the given user"""
373 return self
== user
or user
.is_admin
375 def get_full_name(self
, last_name_first
=True, last_name_upper
=True, abbrev_first_name
=True, show_title
=False):
376 """Returns the user's name in the specified notation.
378 Note: Do not use positional arguments when calling this method.
379 Always use keyword arguments!
381 :param last_name_first: if "lastname, firstname" instead of
382 "firstname lastname" should be used
383 :param last_name_upper: if the last name should be all-uppercase
384 :param abbrev_first_name: if the first name should be abbreviated to
385 use only the first character
386 :param show_title: if the title of the user should be included
388 # Pending users might not have a first/last name...
389 first_name
= self
.first_name
or 'Unknown'
390 last_name
= self
.last_name
or 'Unknown'
392 last_name
= last_name
.upper()
393 first_name
= '{}.'.format(first_name
[0].upper()) if abbrev_first_name
else first_name
394 full_name
= '{}, {}'.format(last_name
, first_name
) if last_name_first
else '{} {}'.format(first_name
, last_name
)
395 return full_name
if not show_title
or not self
.title
else '{} {}'.format(self
.title
, full_name
)
397 def iter_identifiers(self
, check_providers
=False, providers
=None):
398 """Yields ``(provider, identifier)`` tuples for the user.
400 :param check_providers: If True, providers are searched for
401 additional identifiers once all existing
402 identifiers have been yielded.
403 :param providers: May be a set containing provider names to
404 get only identifiers from the specified
408 for identity
in self
.identities
:
409 if providers
is not None and identity
.provider
not in providers
:
411 item
= (identity
.provider
, identity
.identifier
)
414 if not check_providers
:
416 for identity_info
in multipass
.search_identities(providers
=providers
, exact
=True, email
=self
.all_emails
):
417 item
= (identity_info
.provider
.name
, identity_info
.identifier
)
421 def make_email_primary(self
, email
):
422 """Promotes a secondary email address to the primary email address
424 :param email: an email address that is currently a secondary email
426 secondary
= next((x
for x
in self
._secondary
_emails
if x
.email
== email
), None)
427 if secondary
is None:
428 raise ValueError('email is not a secondary email address')
429 self
._primary
_email
.is_primary
= False
431 secondary
.is_primary
= True
434 def get_linked_roles(self
, type_
):
435 """Retrieves the roles the user is linked to for a given type"""
436 return UserLink
.get_linked_roles(self
, type_
)
438 def get_linked_objects(self
, type_
, role
):
439 """Retrieves linked objects for the user"""
440 return UserLink
.get_links(self
, type_
, role
)
442 def link_to(self
, obj
, role
):
443 """Adds a link between the user and an object
445 :param obj: a legacy object
446 :param role: the role to use in the link
448 return UserLink
.create_link(self
, obj
, role
)
450 def unlink_to(self
, obj
, role
):
451 """Removes a link between the user and an object
453 :param obj: a legacy object
454 :param role: the role to use in the link
456 return UserLink
.remove_link(self
, obj
, role
)
458 def synchronize_data(self
, refresh
=False):
459 """Synchronize the fields of the user from the sync identity.
461 This will take only into account :attr:`synced_fields`.
463 :param refresh: bool -- Whether to refresh the synced identity
464 with the sync provider before instead of using
465 the stored data. (Only if the sync provider
468 identity
= self
._get
_synced
_identity
(refresh
=refresh
)
471 for field
in self
.synced_fields
:
472 old_value
= getattr(self
, field
)
473 new_value
= identity
.data
.get(field
) or ''
474 if field
in ('first_name', 'last_name') and not new_value
:
476 if old_value
== new_value
:
478 flash(_("Your {field_name} has been synchronised from '{old_value}' to '{new_value}'.").format(
479 field_name
=syncable_fields
[field
], old_value
=old_value
, new_value
=new_value
))
480 setattr(self
, field
, new_value
)
482 def _get_synced_identity(self
, refresh
=False):
483 sync_provider
= multipass
.sync_provider
484 if sync_provider
is None:
486 identities
= sorted([x
for x
in self
.identities
if x
.provider
== sync_provider
.name
],
487 key
=attrgetter('safe_last_login_dt'), reverse
=True)
490 identity
= identities
[0]
491 if refresh
and identity
.multipass_data
is not None:
493 identity_info
= sync_provider
.refresh_identity(identity
.identifier
, identity
.multipass_data
)
494 except IdentityRetrievalFailed
:
497 identity
.data
= identity_info
.data
501 @listens_for(User
._primary
_email
, 'set')
502 @listens_for(User
._secondary
_emails
, 'append')
503 def _user_email_added(target
, value
, *unused
):
504 # Make sure that a newly added email has the same deletion state as the user itself
505 value
.is_user_deleted
= target
.is_deleted
508 @listens_for(User
.is_deleted
, 'set')
509 def _user_deleted(target
, value
, *unused
):
510 # Reflect the user deletion state in the email table.
511 # Not using _all_emails here since it only contains newly added emails after an expire/commit
512 if target
._primary
_email
:
513 target
._primary
_email
.is_user_deleted
= value
514 for email
in target
._secondary
_emails
:
515 email
.is_user_deleted
= value
518 define_unaccented_lowercase_index(User
.first_name
)
519 define_unaccented_lowercase_index(User
.last_name
)
520 define_unaccented_lowercase_index(User
.phone
)
521 define_unaccented_lowercase_index(User
.address
)