Added as_avatar (easy legacy convert)
[cds-indico.git] / indico / modules / users / models / users.py
blob894bb7fd31eae019d307b7886225e6c11ae3dae6
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.'))
43 none = 0
44 mr = 1
45 ms = 2
46 mrs = 3
47 dr = 4
48 prof = 5
51 #: Fields which can be synced as keys and a mapping to a more human
52 #: readable version, used for flashing messages
53 syncable_fields = {
54 'first_name': _("first name"),
55 'last_name': _("family name"),
56 'affiliation': _("affiliation"),
57 'address': _("address"),
58 'phone': _("phone number")
62 class User(db.Model):
63 """Indico users"""
65 # Useful when dealing with both users and groups in the same code
66 is_group = False
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'),
72 {'schema': 'users'})
74 #: the unique id of the user
75 id = db.Column(
76 db.Integer,
77 primary_key=True
79 #: the first name of the user
80 first_name = db.Column(
81 db.String,
82 nullable=False,
83 index=True
85 #: the last/family name of the user
86 last_name = db.Column(
87 db.String,
88 nullable=False,
89 index=True
91 # the title of the user - you usually want the `title` property!
92 _title = db.Column(
93 'title',
94 PyIntEnum(UserTitle),
95 nullable=False,
96 default=UserTitle.none
98 #: the phone number of the user
99 phone = db.Column(
100 db.String,
101 nullable=False,
102 default=''
104 #: the address of the user
105 address = db.Column(
106 db.Text,
107 nullable=False,
108 default=''
110 #: the id of the user this user has been merged into
111 merged_into_id = db.Column(
112 db.Integer,
113 db.ForeignKey('users.users.id'),
114 nullable=True
116 #: if the user is an administrator with unrestricted access to everything
117 is_admin = db.Column(
118 db.Boolean,
119 nullable=False,
120 default=False,
121 index=True
123 #: if the user has been blocked
124 is_blocked = db.Column(
125 db.Boolean,
126 nullable=False,
127 default=False
129 #: if the user is pending (e.g. never logged in, only added to some list)
130 is_pending = db.Column(
131 db.Boolean,
132 nullable=False,
133 default=False
135 #: if the user is deleted (e.g. due to a merge)
136 is_deleted = db.Column(
137 'is_deleted',
138 db.Boolean,
139 nullable=False,
140 default=False
143 _affiliation = db.relationship(
144 'UserAffiliation',
145 lazy=False,
146 uselist=False,
147 cascade='all, delete-orphan',
148 backref=db.backref('user', lazy=True)
151 _primary_email = db.relationship(
152 'UserEmail',
153 lazy=False,
154 uselist=False,
155 cascade='all, delete-orphan',
156 primaryjoin='(User.id == UserEmail.user_id) & UserEmail.is_primary'
158 _secondary_emails = db.relationship(
159 'UserEmail',
160 lazy=True,
161 cascade='all, delete-orphan',
162 collection_class=set,
163 primaryjoin='(User.id == UserEmail.user_id) & ~UserEmail.is_primary'
165 _all_emails = db.relationship(
166 'UserEmail',
167 lazy=True,
168 viewonly=True,
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(
185 'User',
186 lazy=True,
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(
192 'User',
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,
196 lazy=True,
197 collection_class=set,
198 backref=db.backref('favorite_of', lazy=True, collection_class=set),
200 _favorite_categories = db.relationship(
201 'FavoriteCategory',
202 lazy=True,
203 cascade='all, delete-orphan',
204 collection_class=set
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(
211 'UserLink',
212 lazy='dynamic',
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(
218 'APIKey',
219 lazy=True,
220 uselist=False,
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(
227 'APIKey',
228 lazy=True,
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(
236 'Identity',
237 lazy=True,
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)
260 @property
261 def as_principal(self):
262 """The serializable principal identifier of this user"""
263 return 'User', self.id
265 @property
266 def as_avatar(self):
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
272 avatar.user
273 return avatar
275 as_legacy = as_avatar
277 @property
278 def external_identities(self):
279 """The external identities of the user"""
280 return {x for x in self.identities if x.provider != 'indico'}
282 @property
283 def local_identities(self):
284 """The local identities of the user"""
285 return {x for x in self.identities if x.provider == 'indico'}
287 @property
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
293 @property
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}
298 @property
299 def locator(self):
300 return {'user_id': self.id}
302 @hybrid_property
303 def title(self):
304 """the title of the user"""
305 return self._title.title
307 @title.expression
308 def title(cls):
309 return cls._title
311 @title.setter
312 def title(self, value):
313 self._title = value
315 @cached_property
316 def settings(self):
317 """Returns the user settings proxy for this user"""
318 from indico.modules.users import user_settings
319 return user_settings.bind(self)
321 @property
322 def full_name(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
327 name = full_name
329 @property
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
340 else:
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')
348 else:
349 self.settings.set('synced_fields', list(value))
351 @property
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)
359 if identity is None:
360 return {}
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`."""
365 return self == user
367 @return_ascii
368 def __repr__(self):
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'
391 if last_name_upper:
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
405 providers.
407 done = set()
408 for identity in self.identities:
409 if providers is not None and identity.provider not in providers:
410 continue
411 item = (identity.provider, identity.identifier)
412 done.add(item)
413 yield item
414 if not check_providers:
415 return
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)
418 if item not in done:
419 yield item
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
430 db.session.flush()
431 secondary.is_primary = True
432 db.session.flush()
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
466 supports refresh.)
468 identity = self._get_synced_identity(refresh=refresh)
469 if identity is None:
470 return
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:
475 continue
476 if old_value == new_value:
477 continue
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:
485 return 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)
488 if not identities:
489 return None
490 identity = identities[0]
491 if refresh and identity.multipass_data is not None:
492 try:
493 identity_info = sync_provider.refresh_identity(identity.identifier, identity.multipass_data)
494 except IdentityRetrievalFailed:
495 identity_info = None
496 if identity_info:
497 identity.data = identity_info.data
498 return identity
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)