def merge_users(source, target, force=False): """Merge two users together, unifying all related data :param source: source user (will be set as deleted) :param target: target user (final) """ if source.is_deleted and not force: raise ValueError( 'Source user {} has been deleted. Merge aborted.'.format(source)) if target.is_deleted: raise ValueError( 'Target user {} has been deleted. Merge aborted.'.format(target)) # Move emails to the target user primary_source_email = source.email logger.info("Target %s initial emails: %s", target, ', '.join(target.all_emails)) logger.info("Source %s emails to be linked to target %s: %s", source, target, ', '.join(source.all_emails)) UserEmail.find(user_id=source.id).update({ UserEmail.user_id: target.id, UserEmail.is_primary: False }) # Make sure we don't have stale data after the bulk update we just performed db.session.expire_all() # Update favorites target.favorite_users |= source.favorite_users target.favorite_of |= source.favorite_of target.favorite_categories |= source.favorite_categories # Update category suggestions SuggestedCategory.merge_users(target, source) # Merge identities for identity in set(source.identities): identity.user = target # Notify signal listeners about the merge signals.users.merged.send(target, source=source) db.session.flush() # Mark source as merged source.merged_into_user = target source.is_deleted = True db.session.flush() # Restore the source user's primary email source.email = primary_source_email db.session.flush() logger.info("Successfully merged %s into %s", source, target)
def merge_users(source, target, force=False): """Merge two users together, unifying all related data :param source: source user (will be set as deleted) :param target: target user (final) """ if source.is_deleted and not force: raise ValueError('Source user {} has been deleted. Merge aborted.'.format(source)) if target.is_deleted: raise ValueError('Target user {} has been deleted. Merge aborted.'.format(target)) # Move emails to the target user primary_source_email = source.email logger.info("Target %s initial emails: %s", target, ', '.join(target.all_emails)) logger.info("Source %s emails to be linked to target %s: %s", source, target, ', '.join(source.all_emails)) UserEmail.find(user_id=source.id).update({ UserEmail.user_id: target.id, UserEmail.is_primary: False }) # Make sure we don't have stale data after the bulk update we just performed db.session.expire_all() # Update favorites target.favorite_users |= source.favorite_users target.favorite_of |= source.favorite_of target.favorite_categories |= source.favorite_categories # Update category suggestions SuggestedCategory.merge_users(target, source) # Merge identities for identity in set(source.identities): identity.user = target # Notify signal listeners about the merge signals.users.merged.send(target, source=source) db.session.flush() # Mark source as merged source.merged_into_user = target source.is_deleted = True db.session.flush() # Restore the source user's primary email source.email = primary_source_email db.session.flush() logger.info("Successfully merged %s into %s", source, target)
def _validate(self, data): if not data: flash(_('The verification token is invalid or expired.'), 'error') return False, None user = User.get(data['user_id']) if not user or user != self.user: flash(_('This token is for a different Indico user. Please login with the correct account'), 'error') return False, None existing = UserEmail.find_first(is_user_deleted=False, email=data['email']) if existing and not existing.user.is_pending: if existing.user == self.user: flash(_('This email address is already attached to your account.')) else: flash(_('This email address is already in use by another account.'), 'error') return False, existing.user return True, existing.user if existing else None
class User(PersonMixin, db.Model): """Indico users""" # Useful when dealing with both users and groups in the same code is_group = False is_single_person = True is_network = False principal_order = 0 principal_type = PrincipalType.user __tablename__ = 'users' __table_args__ = ( db.CheckConstraint('id != merged_into_id', 'not_merged_self'), db.CheckConstraint( "is_pending OR (first_name != '' AND last_name != '')", 'not_pending_proper_names'), { 'schema': 'users' }) #: the unique id of the user id = db.Column(db.Integer, primary_key=True) #: the first name of the user first_name = db.Column(db.String, nullable=False, index=True) #: the last/family name of the user last_name = db.Column(db.String, nullable=False, index=True) # the title of the user - you usually want the `title` property! _title = db.Column('title', PyIntEnum(UserTitle), nullable=False, default=UserTitle.none) #: the phone number of the user phone = db.Column(db.String, nullable=False, default='') #: the address of the user address = db.Column(db.Text, nullable=False, default='') #: the id of the user this user has been merged into merged_into_id = db.Column(db.Integer, db.ForeignKey('users.users.id'), nullable=True) #: if the user is an administrator with unrestricted access to everything is_admin = db.Column(db.Boolean, nullable=False, default=False, index=True) #: if the user has been blocked is_blocked = db.Column(db.Boolean, nullable=False, default=False) #: if the user is pending (e.g. never logged in, only added to some list) is_pending = db.Column(db.Boolean, nullable=False, default=False) #: if the user is deleted (e.g. due to a merge) is_deleted = db.Column('is_deleted', db.Boolean, nullable=False, default=False) _affiliation = db.relationship('UserAffiliation', lazy=False, uselist=False, cascade='all, delete-orphan', backref=db.backref('user', lazy=True)) _primary_email = db.relationship( 'UserEmail', lazy=False, uselist=False, cascade='all, delete-orphan', primaryjoin='(User.id == UserEmail.user_id) & UserEmail.is_primary') _secondary_emails = db.relationship( 'UserEmail', lazy=True, cascade='all, delete-orphan', collection_class=set, primaryjoin='(User.id == UserEmail.user_id) & ~UserEmail.is_primary') _all_emails = db.relationship('UserEmail', lazy=True, viewonly=True, primaryjoin='User.id == UserEmail.user_id', collection_class=set, backref=db.backref('user', lazy=False)) #: the affiliation of the user affiliation = association_proxy('_affiliation', 'name', creator=lambda v: UserAffiliation(name=v)) #: the primary email address of the user email = association_proxy( '_primary_email', 'email', creator=lambda v: UserEmail(email=v, is_primary=True)) #: any additional emails the user might have secondary_emails = association_proxy('_secondary_emails', 'email', creator=lambda v: UserEmail(email=v)) #: all emails of the user. read-only; use it only for searching by email! also, do not use it between #: modifying `email` or `secondary_emails` and a session expire/commit! all_emails = association_proxy('_all_emails', 'email') # read-only! #: the user this user has been merged into merged_into_user = db.relationship( 'User', lazy=True, backref=db.backref('merged_from_users', lazy=True), remote_side='User.id', ) #: the users's favorite users favorite_users = db.relationship( 'User', secondary=favorite_user_table, primaryjoin=id == favorite_user_table.c.user_id, secondaryjoin=(id == favorite_user_table.c.target_id) & ~is_deleted, lazy=True, collection_class=set, backref=db.backref('favorite_of', lazy=True, collection_class=set), ) #: the users's favorite categories favorite_categories = db.relationship( 'Category', secondary=favorite_category_table, lazy=True, collection_class=set, backref=db.backref('favorite_of', lazy=True, collection_class=set), ) #: the user's category suggestions suggested_categories = db.relationship( 'SuggestedCategory', lazy='dynamic', order_by='SuggestedCategory.score.desc()', cascade='all, delete-orphan', backref=db.backref('user', lazy=True)) #: the legacy objects the user is connected to linked_objects = db.relationship('UserLink', lazy='dynamic', cascade='all, delete-orphan', backref=db.backref('user', lazy=True)) #: the active API key of the user api_key = db.relationship( 'APIKey', lazy=True, uselist=False, cascade='all, delete-orphan', primaryjoin='(User.id == APIKey.user_id) & APIKey.is_active', back_populates='user') #: the previous API keys of the user old_api_keys = db.relationship( 'APIKey', lazy=True, cascade='all, delete-orphan', order_by='APIKey.created_dt.desc()', primaryjoin='(User.id == APIKey.user_id) & ~APIKey.is_active', back_populates='user') #: the identities used by this user identities = db.relationship('Identity', lazy=True, cascade='all, delete-orphan', collection_class=set, backref=db.backref('user', lazy=False)) # relationship backrefs: # - _all_settings (UserSetting.user) # - abstract_judgments (Judgment.judge) # - agreements (Agreement.user) # - attachment_files (AttachmentFile.user) # - attachments (Attachment.user) # - blockings (Blocking.created_by_user) # - created_events (Event.creator) # - event_log_entries (EventLogEntry.user) # - event_notes_revisions (EventNoteRevision.user) # - event_persons (EventPerson.user) # - event_reminders (EventReminder.creator) # - favorite_of (User.favorite_users) # - in_attachment_acls (AttachmentPrincipal.user) # - in_attachment_folder_acls (AttachmentFolderPrincipal.user) # - in_blocking_acls (BlockingPrincipal.user) # - in_category_acls (CategoryPrincipal.user) # - in_contribution_acls (ContributionPrincipal.user) # - in_event_acls (EventPrincipal.user) # - in_event_settings_acls (EventSettingPrincipal.user) # - in_session_acls (SessionPrincipal.user) # - in_settings_acls (SettingPrincipal.user) # - local_groups (LocalGroup.members) # - merged_from_users (User.merged_into_user) # - oauth_tokens (OAuthToken.user) # - owned_rooms (Room.owner) # - paper_reviewing_roles (PaperReviewingRole.user) # - registrations (Registration.user) # - requests_created (Request.created_by_user) # - requests_processed (Request.processed_by_user) # - reservations (Reservation.created_by_user) # - reservations_booked_for (Reservation.booked_for_user) # - static_sites (StaticSite.creator) # - survey_submissions (SurveySubmission.user) # - vc_rooms (VCRoom.created_by_user) @property def as_principal(self): """The serializable principal identifier of this user""" return 'User', self.id @property def as_avatar(self): # TODO: remove this after DB is free of Avatars from indico.modules.users.legacy import AvatarUserWrapper avatar = AvatarUserWrapper(self.id) # avoid garbage collection avatar.user return avatar as_legacy = as_avatar @property def external_identities(self): """The external identities of the user""" return {x for x in self.identities if x.provider != 'indico'} @property def local_identities(self): """The local identities of the user""" return {x for x in self.identities if x.provider == 'indico'} @property def local_identity(self): """The main (most recently used) local identity""" identities = sorted(self.local_identities, key=attrgetter('safe_last_login_dt'), reverse=True) return identities[0] if identities else None @property def secondary_local_identities(self): """The local identities of the user except the main one""" return self.local_identities - {self.local_identity} @locator_property def locator(self): return {'user_id': self.id} @cached_property def settings(self): """Returns the user settings proxy for this user""" from indico.modules.users import user_settings return user_settings.bind(self) @property def synced_fields(self): """The fields of the user whose values are currently synced. This set is always a subset of the synced fields define in synced fields of the idp in 'indico.conf'. """ synced_fields = self.settings.get('synced_fields') # If synced_fields is missing or None, then all fields are synced if synced_fields is None: return multipass.synced_fields else: return set(synced_fields) & multipass.synced_fields @synced_fields.setter def synced_fields(self, value): value = set(value) & multipass.synced_fields if value == multipass.synced_fields: self.settings.delete('synced_fields') else: self.settings.set('synced_fields', list(value)) @property def synced_values(self): """The values from the synced identity for the user. Those values are not the actual user's values and might differ if they are not set as synchronized. """ identity = self._get_synced_identity(refresh=False) if identity is None: return {} return { field: (identity.data.get(field) or '') for field in multipass.synced_fields } def __contains__(self, user): """Convenience method for `user in user_or_group`.""" return self == user @return_ascii def __repr__(self): return '<User({}, {}, {}, {})>'.format(self.id, self.first_name, self.last_name, self.email) def can_be_modified(self, user): """If this user can be modified by the given user""" return self == user or user.is_admin def iter_identifiers(self, check_providers=False, providers=None): """Yields ``(provider, identifier)`` tuples for the user. :param check_providers: If True, providers are searched for additional identifiers once all existing identifiers have been yielded. :param providers: May be a set containing provider names to get only identifiers from the specified providers. """ done = set() for identity in self.identities: if providers is not None and identity.provider not in providers: continue item = (identity.provider, identity.identifier) done.add(item) yield item if not check_providers: return for identity_info in multipass.search_identities( providers=providers, exact=True, email=self.all_emails): item = (identity_info.provider.name, identity_info.identifier) if item not in done: yield item def get_full_name(self, *args, **kwargs): kwargs['_show_empty_names'] = True return super(User, self).get_full_name(*args, **kwargs) def make_email_primary(self, email): """Promotes a secondary email address to the primary email address :param email: an email address that is currently a secondary email """ secondary = next( (x for x in self._secondary_emails if x.email == email), None) if secondary is None: raise ValueError('email is not a secondary email address') self._primary_email.is_primary = False db.session.flush() secondary.is_primary = True db.session.flush() def get_linked_roles(self, type_): """Retrieves the roles the user is linked to for a given type""" return UserLink.get_linked_roles(self, type_) def get_linked_objects(self, type_, role): """Retrieves linked objects for the user""" return UserLink.get_links(self, type_, role) def link_to(self, obj, role): """Adds a link between the user and an object :param obj: a legacy object :param role: the role to use in the link """ return UserLink.create_link(self, obj, role) def unlink_to(self, obj, role): """Removes a link between the user and an object :param obj: a legacy object :param role: the role to use in the link """ return UserLink.remove_link(self, obj, role) def synchronize_data(self, refresh=False): """Synchronize the fields of the user from the sync identity. This will take only into account :attr:`synced_fields`. :param refresh: bool -- Whether to refresh the synced identity with the sync provider before instead of using the stored data. (Only if the sync provider supports refresh.) """ identity = self._get_synced_identity(refresh=refresh) if identity is None: return for field in self.synced_fields: old_value = getattr(self, field) new_value = identity.data.get(field) or '' if field in ('first_name', 'last_name') and not new_value: continue if old_value == new_value: continue flash( _("Your {field_name} has been synchronised from '{old_value}' to '{new_value}'." ).format(field_name=syncable_fields[field], old_value=old_value, new_value=new_value)) setattr(self, field, new_value) def _get_synced_identity(self, refresh=False): sync_provider = multipass.sync_provider if sync_provider is None: return None identities = sorted( [x for x in self.identities if x.provider == sync_provider.name], key=attrgetter('safe_last_login_dt'), reverse=True) if not identities: return None identity = identities[0] if refresh and identity.multipass_data is not None: try: identity_info = sync_provider.refresh_identity( identity.identifier, identity.multipass_data) except IdentityRetrievalFailed: identity_info = None if identity_info: identity.data = identity_info.data return identity
def validate_email(self, field): if UserEmail.find(~User.is_pending, is_user_deleted=False, email=field.data, _join=User).count(): raise ValidationError(_('This email address is already in use.'))
class User(PersonMixin, db.Model): """Indico users.""" # Useful when dealing with both users and groups in the same code is_group = False is_single_person = True is_event_role = False is_category_role = False is_registration_form = False is_network = False principal_order = 0 principal_type = PrincipalType.user __tablename__ = 'users' __table_args__ = (db.Index(None, 'is_system', unique=True, postgresql_where=db.text('is_system')), db.CheckConstraint('NOT is_system OR (NOT is_blocked AND NOT is_pending AND NOT is_deleted)', 'valid_system_user'), db.CheckConstraint('id != merged_into_id', 'not_merged_self'), db.CheckConstraint("is_pending OR (first_name != '' AND last_name != '')", 'not_pending_proper_names'), db.CheckConstraint("(picture IS NULL) = (picture_metadata::text = 'null')", 'valid_picture'), {'schema': 'users'}) #: the unique id of the user id = db.Column( db.Integer, primary_key=True ) #: the first name of the user first_name = db.Column( db.String, nullable=False, index=True ) #: the last/family name of the user last_name = db.Column( db.String, nullable=False, index=True ) # the title of the user - you usually want the `title` property! _title = db.Column( 'title', PyIntEnum(UserTitle), nullable=False, default=UserTitle.none ) #: the phone number of the user phone = db.Column( db.String, nullable=False, default='' ) #: the address of the user address = db.Column( db.Text, nullable=False, default='' ) #: the id of the user this user has been merged into merged_into_id = db.Column( db.Integer, db.ForeignKey('users.users.id'), nullable=True ) #: if the user is the default system user is_system = db.Column( db.Boolean, nullable=False, default=False ) #: if the user is an administrator with unrestricted access to everything is_admin = db.Column( db.Boolean, nullable=False, default=False, index=True ) #: if the user has been blocked is_blocked = db.Column( db.Boolean, nullable=False, default=False ) #: if the user is pending (e.g. never logged in, only added to some list) is_pending = db.Column( db.Boolean, nullable=False, default=False ) #: if the user is deleted (e.g. due to a merge) is_deleted = db.Column( 'is_deleted', db.Boolean, nullable=False, default=False ) #: a unique secret used to generate signed URLs signing_secret = db.Column( UUID, nullable=False, default=lambda: str(uuid4()) ) #: the user profile picture picture = db.deferred(db.Column( db.LargeBinary, nullable=True )) #: user profile picture metadata picture_metadata = db.Column( JSONB, nullable=False, default=lambda: None ) #: user profile picture source picture_source = db.Column( PyIntEnum(ProfilePictureSource), nullable=False, default=ProfilePictureSource.standard, ) _affiliation = db.relationship( 'UserAffiliation', lazy=False, uselist=False, cascade='all, delete-orphan', backref=db.backref('user', lazy=True) ) _primary_email = db.relationship( 'UserEmail', lazy=False, uselist=False, cascade='all, delete-orphan', primaryjoin='(User.id == UserEmail.user_id) & UserEmail.is_primary' ) _secondary_emails = db.relationship( 'UserEmail', lazy=True, cascade='all, delete-orphan', collection_class=set, primaryjoin='(User.id == UserEmail.user_id) & ~UserEmail.is_primary' ) _all_emails = db.relationship( 'UserEmail', lazy=True, viewonly=True, sync_backref=False, primaryjoin='User.id == UserEmail.user_id', collection_class=set, backref=db.backref('user', lazy=False) ) #: the affiliation of the user affiliation = association_proxy('_affiliation', 'name', creator=lambda v: UserAffiliation(name=v)) #: the primary email address of the user email = association_proxy('_primary_email', 'email', creator=lambda v: UserEmail(email=v, is_primary=True)) #: any additional emails the user might have secondary_emails = association_proxy('_secondary_emails', 'email', creator=lambda v: UserEmail(email=v)) #: all emails of the user. read-only; use it only for searching by email! also, do not use it between #: modifying `email` or `secondary_emails` and a session expire/commit! all_emails = association_proxy('_all_emails', 'email') # read-only! #: the user this user has been merged into merged_into_user = db.relationship( 'User', lazy=True, backref=db.backref('merged_from_users', lazy=True), remote_side='User.id', ) #: the users's favorite users favorite_users = db.relationship( 'User', secondary=favorite_user_table, primaryjoin=id == favorite_user_table.c.user_id, secondaryjoin=(id == favorite_user_table.c.target_id) & ~is_deleted, lazy=True, collection_class=set, backref=db.backref('favorite_of', lazy=True, collection_class=set), ) #: the users's favorite categories favorite_categories = db.relationship( 'Category', secondary=favorite_category_table, lazy=True, collection_class=set, backref=db.backref('favorite_of', lazy=True, collection_class=set), ) #: the user's category suggestions suggested_categories = db.relationship( 'SuggestedCategory', lazy='dynamic', order_by='SuggestedCategory.score.desc()', cascade='all, delete-orphan', backref=db.backref('user', lazy=True) ) #: the active API key of the user api_key = db.relationship( 'APIKey', lazy=True, uselist=False, cascade='all, delete-orphan', primaryjoin='(User.id == APIKey.user_id) & APIKey.is_active', back_populates='user' ) #: the previous API keys of the user old_api_keys = db.relationship( 'APIKey', lazy=True, cascade='all, delete-orphan', order_by='APIKey.created_dt.desc()', primaryjoin='(User.id == APIKey.user_id) & ~APIKey.is_active', back_populates='user' ) #: the identities used by this user identities = db.relationship( 'Identity', lazy=True, cascade='all, delete-orphan', collection_class=set, backref=db.backref('user', lazy=False) ) # relationship backrefs: # - _all_settings (UserSetting.user) # - abstract_comments (AbstractComment.user) # - abstract_email_log_entries (AbstractEmailLogEntry.user) # - abstract_reviews (AbstractReview.user) # - abstracts (Abstract.submitter) # - agreements (Agreement.user) # - attachment_files (AttachmentFile.user) # - attachments (Attachment.user) # - blockings (Blocking.created_by_user) # - category_roles (CategoryRole.members) # - content_reviewer_for_contributions (Contribution.paper_content_reviewers) # - created_events (Event.creator) # - editing_comments (EditingRevisionComment.user) # - editing_revisions (EditingRevision.submitter) # - editor_for_editables (Editable.editor) # - editor_for_revisions (EditingRevision.editor) # - event_log_entries (EventLogEntry.user) # - event_notes_revisions (EventNoteRevision.user) # - event_persons (EventPerson.user) # - event_reminders (EventReminder.creator) # - event_roles (EventRole.members) # - favorite_of (User.favorite_users) # - favorite_rooms (Room.favorite_of) # - in_attachment_acls (AttachmentPrincipal.user) # - in_attachment_folder_acls (AttachmentFolderPrincipal.user) # - in_blocking_acls (BlockingPrincipal.user) # - in_category_acls (CategoryPrincipal.user) # - in_contribution_acls (ContributionPrincipal.user) # - in_event_acls (EventPrincipal.user) # - in_event_settings_acls (EventSettingPrincipal.user) # - in_room_acls (RoomPrincipal.user) # - in_session_acls (SessionPrincipal.user) # - in_settings_acls (SettingPrincipal.user) # - in_track_acls (TrackPrincipal.user) # - judge_for_contributions (Contribution.paper_judges) # - judged_abstracts (Abstract.judge) # - judged_papers (PaperRevision.judge) # - layout_reviewer_for_contributions (Contribution.paper_layout_reviewers) # - local_groups (LocalGroup.members) # - merged_from_users (User.merged_into_user) # - modified_abstract_comments (AbstractComment.modified_by) # - modified_abstracts (Abstract.modified_by) # - modified_review_comments (PaperReviewComment.modified_by) # - oauth_app_links (OAuthApplicationUserLink.user) # - owned_rooms (Room.owner) # - paper_competences (PaperCompetence.user) # - paper_reviews (PaperReview.user) # - paper_revisions (PaperRevision.submitter) # - registrations (Registration.user) # - requests_created (Request.created_by_user) # - requests_processed (Request.processed_by_user) # - reservations (Reservation.created_by_user) # - reservations_booked_for (Reservation.booked_for_user) # - review_comments (PaperReviewComment.user) # - static_sites (StaticSite.creator) # - survey_submissions (SurveySubmission.user) # - vc_rooms (VCRoom.created_by_user) @staticmethod def get_system_user(): return User.query.filter_by(is_system=True).one() @property def as_principal(self): """The serializable principal identifier of this user.""" return 'User', self.id @property def identifier(self): return f'User:{self.id}' @property def avatar_bg_color(self): from indico.modules.users.util import get_color_for_username return get_color_for_username(self.full_name) @property def external_identities(self): """The external identities of the user.""" return {x for x in self.identities if x.provider != 'indico'} @property def local_identities(self): """The local identities of the user.""" return {x for x in self.identities if x.provider == 'indico'} @property def local_identity(self): """The main (most recently used) local identity.""" identities = sorted(self.local_identities, key=attrgetter('safe_last_login_dt'), reverse=True) return identities[0] if identities else None @property def secondary_local_identities(self): """The local identities of the user except the main one.""" return self.local_identities - {self.local_identity} @property def last_login_dt(self): """The datetime when the user last logged in.""" if not self.identities: return None return max(self.identities, key=attrgetter('safe_last_login_dt')).last_login_dt @locator_property def locator(self): return {'user_id': self.id} @cached_property def settings(self): """Return the user settings proxy for this user.""" from indico.modules.users import user_settings return user_settings.bind(self) @property def synced_fields(self): """The fields of the user whose values are currently synced. This set is always a subset of the synced fields define in synced fields of the idp in 'indico.conf'. """ synced_fields = self.settings.get('synced_fields') # If synced_fields is missing or None, then all fields are synced if synced_fields is None: return multipass.synced_fields else: return set(synced_fields) & multipass.synced_fields @synced_fields.setter def synced_fields(self, value): value = set(value) & multipass.synced_fields if value == multipass.synced_fields: self.settings.delete('synced_fields') else: self.settings.set('synced_fields', list(value)) @property def synced_values(self): """The values from the synced identity for the user. Those values are not the actual user's values and might differ if they are not set as synchronized. """ identity = self._get_synced_identity(refresh=False) if identity is None: return {} return {field: (identity.data.get(field) or '') for field in multipass.synced_fields} @property def has_picture(self): return self.picture_metadata is not None @property def avatar_url(self): if self.is_system: return url_for('assets.image', filename='robot.svg') slug = self.picture_metadata['hash'] if self.picture_metadata else 'default' return url_for('users.user_profile_picture_display', self, slug=slug) def __contains__(self, user): """Convenience method for `user in user_or_group`.""" return self == user def __repr__(self): return format_repr(self, 'id', 'email', is_deleted=False, is_pending=False, _text=self.full_name) def can_be_modified(self, user): """If this user can be modified by the given user.""" return self == user or user.is_admin def iter_identifiers(self, check_providers=False, providers=None): """Yields ``(provider, identifier)`` tuples for the user. :param check_providers: If True, providers are searched for additional identifiers once all existing identifiers have been yielded. :param providers: May be a set containing provider names to get only identifiers from the specified providers. """ done = set() for identity in self.identities: if providers is not None and identity.provider not in providers: continue item = (identity.provider, identity.identifier) done.add(item) yield item if not check_providers: return for identity_info in multipass.search_identities(providers=providers, exact=True, email=self.all_emails): item = (identity_info.provider.name, identity_info.identifier) if item not in done: yield item @property def can_get_all_multipass_groups(self): """ Check whether it is possible to get all multipass groups the user is in. """ return all(multipass.identity_providers[x.provider].supports_get_identity_groups for x in self.identities if x.provider != 'indico' and x.provider in multipass.identity_providers) def iter_all_multipass_groups(self): """Iterate over all multipass groups the user is in.""" return itertools.chain.from_iterable(multipass.identity_providers[x.provider].get_identity_groups(x.identifier) for x in self.identities if x.provider != 'indico' and x.provider in multipass.identity_providers) def get_full_name(self, *args, **kwargs): kwargs['_show_empty_names'] = True return super().get_full_name(*args, **kwargs) def make_email_primary(self, email): """Promote a secondary email address to the primary email address. :param email: an email address that is currently a secondary email """ secondary = next((x for x in self._secondary_emails if x.email == email), None) if secondary is None: raise ValueError('email is not a secondary email address') self._primary_email.is_primary = False db.session.flush() secondary.is_primary = True db.session.flush() def reset_signing_secret(self): self.signing_secret = str(uuid4()) def synchronize_data(self, refresh=False): """Synchronize the fields of the user from the sync identity. This will take only into account :attr:`synced_fields`. :param refresh: bool -- Whether to refresh the synced identity with the sync provider before instead of using the stored data. (Only if the sync provider supports refresh.) """ identity = self._get_synced_identity(refresh=refresh) if identity is None: return for field in self.synced_fields: old_value = getattr(self, field) new_value = identity.data.get(field) or '' if field in ('first_name', 'last_name') and not new_value: continue if old_value == new_value: continue flash(_("Your {field_name} has been synchronised from '{old_value}' to '{new_value}'.").format( field_name=syncable_fields[field], old_value=old_value, new_value=new_value)) setattr(self, field, new_value) def _get_synced_identity(self, refresh=False): sync_provider = multipass.sync_provider if sync_provider is None: return None identities = sorted([x for x in self.identities if x.provider == sync_provider.name], key=attrgetter('safe_last_login_dt'), reverse=True) if not identities: return None identity = identities[0] if refresh and identity.multipass_data is not None and sync_provider.supports_refresh: try: identity_info = sync_provider.refresh_identity(identity.identifier, identity.multipass_data) except IdentityRetrievalFailed: identity_info = None if identity_info: identity.data = identity_info.data return identity
def merge_users(source, target, force=False): """Merge two users together, unifying all related data :param source: source user (will be set as deleted) :param target: target user (final) """ if source.is_deleted and not force: raise ValueError('Source user {} has been deleted. Merge aborted.'.format(source)) if target.is_deleted: raise ValueError('Target user {} has been deleted. Merge aborted.'.format(target)) # Merge links for link in source.linked_objects: if link.object is None: # remove link if object does no longer exist db.session.delete(link) else: link.user = target # De-duplicate links unique_links = {(link.object, link.role): link for link in target.linked_objects} to_delete = set(target.linked_objects) - set(unique_links.viewvalues()) for link in to_delete: db.session.delete(link) # Move emails to the target user primary_source_email = source.email logger.info("Target %s initial emails: %s", target, ', '.join(target.all_emails)) logger.info("Source %s emails to be linked to target %s: %s", source, target, ', '.join(source.all_emails)) UserEmail.find(user_id=source.id).update({ UserEmail.user_id: target.id, UserEmail.is_primary: False }) # Make sure we don't have stale data after the bulk update we just performed db.session.expire_all() # Update favorites target.favorite_users |= source.favorite_users target.favorite_of |= source.favorite_of target.favorite_categories |= source.favorite_categories # Merge identities for identity in set(source.identities): identity.user = target # Merge avatars in redis if redis_write_client: avatar_links.merge_avatars(target, source) suggestions.merge_avatars(target, source) # Notify signal listeners about the merge signals.users.merged.send(target, source=source) db.session.flush() # Mark source as merged source.merged_into_user = target source.is_deleted = True db.session.flush() # Restore the source user's primary email source.email = primary_source_email db.session.flush() logger.info("Successfully merged %s into %s", source, target)
class User(db.Model): """Indico users""" __tablename__ = 'users' __table_args__ = {'schema': 'users'} #: the unique id of the user id = db.Column(db.Integer, primary_key=True) #: the first name of the user first_name = db.Column(db.String, nullable=False, index=True) #: the last/family name of the user last_name = db.Column(db.String, nullable=False, index=True) # the title of the user - you usually want the `title` property! _title = db.Column('title', PyIntEnum(UserTitle), nullable=False, default=UserTitle.none) #: the phone number of the user phone = db.Column(db.String, nullable=False, default='') #: the address of the user address = db.Column(db.Text, nullable=False, default='') #: the id of the user this user has been merged into merged_into_id = db.Column(db.Integer, db.ForeignKey('users.users.id'), nullable=True) #: if the user is an administrator with unrestricted access to everything is_admin = db.Column(db.Boolean, nullable=False, default=False, index=True) #: if the user has been blocked is_blocked = db.Column(db.Boolean, nullable=False, default=False) #: if the user is pending (e.g. never logged in, only added to some list) is_pending = db.Column(db.Boolean, nullable=False, default=False) #: if the user is deleted (e.g. due to a merge) _is_deleted = db.Column('is_deleted', db.Boolean, nullable=False, default=False) _affiliation = db.relationship('UserAffiliation', lazy=False, uselist=False, cascade='all, delete-orphan', backref=db.backref('user', lazy=True)) _primary_email = db.relationship( 'UserEmail', lazy=False, uselist=False, cascade='all, delete-orphan', primaryjoin='(User.id == UserEmail.user_id) & UserEmail.is_primary') _secondary_emails = db.relationship( 'UserEmail', lazy=True, cascade='all, delete-orphan', collection_class=set, primaryjoin='(User.id == UserEmail.user_id) & ~UserEmail.is_primary') _all_emails = db.relationship('UserEmail', lazy=True, viewonly=True, primaryjoin='User.id == UserEmail.user_id', collection_class=set, backref=db.backref('user', lazy=False)) #: the affiliation of the user affiliation = association_proxy('_affiliation', 'name', creator=lambda v: UserAffiliation(name=v)) #: the primary email address of the user email = association_proxy( '_primary_email', 'email', creator=lambda v: UserEmail(email=v, is_primary=True)) #: any additional emails the user might have secondary_emails = association_proxy('_secondary_emails', 'email', creator=lambda v: UserEmail(email=v)) #: all emails of the user. read-only; use it only for searching by email! also, do not use it between #: modifying `email` or `secondary_emails` and a session expire/commit! all_emails = association_proxy('_all_emails', 'email') # read-only! #: the user this user has been merged into merged_into_user = db.relationship( 'User', lazy=True, backref=db.backref('merged_from_users', lazy=True), remote_side='User.id', ) #: the users's favorite users favorite_users = db.relationship( 'User', secondary=favorite_user_table, primaryjoin=id == favorite_user_table.c.user_id, secondaryjoin=(id == favorite_user_table.c.target_id) & ~_is_deleted, lazy=True, collection_class=set, backref=db.backref('favorite_of', lazy=True, collection_class=set), ) _favorite_categories = db.relationship('FavoriteCategory', lazy=True, cascade='all, delete-orphan', collection_class=set) #: the users's favorite categories favorite_categories = association_proxy( '_favorite_categories', 'target', creator=lambda x: FavoriteCategory(target=x)) #: the legacy objects the user is connected to linked_objects = db.relationship('UserLink', lazy='dynamic', cascade='all, delete-orphan', backref=db.backref('user', lazy=True)) #: the active API key of the user api_key = db.relationship( 'APIKey', lazy=True, uselist=False, cascade='all, delete-orphan', primaryjoin='(User.id == APIKey.user_id) & APIKey.is_active', back_populates='user') #: the previous API keys of the user old_api_keys = db.relationship( 'APIKey', lazy=True, cascade='all, delete-orphan', order_by='APIKey.created_dt.desc()', primaryjoin='(User.id == APIKey.user_id) & ~APIKey.is_active', back_populates='user') #: the identities used by this user identities = db.relationship('Identity', lazy=True, cascade='all, delete-orphan', collection_class=set, backref=db.backref('user', lazy=False)) # relationship backrefs: # - local_groups (User.local_groups) @property def as_principal(self): """The serializable principal identifier of this user""" return 'User', self.id @property def as_avatar(self): # TODO: remove this after DB is free of Avatars from indico.modules.users.legacy import AvatarUserWrapper avatar = AvatarUserWrapper(self.id) # avoid garbage collection avatar.user return avatar @property def external_identities(self): """The external identities of the user""" return {x for x in self.identities if x.provider != 'indico'} @property def local_identities(self): """The local identities of the user""" return {x for x in self.identities if x.provider == 'indico'} @property def local_identity(self): """The main (most recently used) local identity""" identities = sorted(self.local_identities, key=attrgetter('safe_last_login_dt'), reverse=True) return identities[0] if identities else None @property def secondary_local_identities(self): """The local identities of the user except the main one""" return self.local_identities - {self.local_identity} @property def locator(self): return {'user_id': self.id} @hybrid_property def title(self): """the title of the user""" return self._title.title @title.expression def title(cls): return cls._title @title.setter def title(self, value): self._title = value @hybrid_property def is_deleted(self): return self._is_deleted @is_deleted.setter def is_deleted(self, value): self._is_deleted = value # not using _all_emails here since it only contains newly added emails after an expire/commit if self._primary_email: self._primary_email.is_user_deleted = value for email in self._secondary_emails: email.is_user_deleted = value @cached_property def settings(self): """Returns the user settings proxy for this user""" from indico.modules.users import user_settings return user_settings.bind(self) @property def full_name(self): """Returns the user's name in 'Firstname Lastname' notation.""" return self.get_full_name(last_name_first=False, last_name_upper=False, abbrev_first_name=False) @property def synced_fields(self): """The fields of the user whose values are currently synced. This set is always a subset of the synced fields define in synced fields of the idp in 'indico.conf'. """ synced_fields = self.settings.get('synced_fields') # If synced_fields is missing or None, then all fields are synced if synced_fields is None: return multipass.synced_fields else: return set(synced_fields) & multipass.synced_fields @synced_fields.setter def synced_fields(self, value): value = set(value) & multipass.synced_fields if value == multipass.synced_fields: self.settings.delete('synced_fields') else: self.settings.set('synced_fields', list(value)) @property def synced_values(self): """The values from the synced identity for the user. Those values are not the actual user's values and might differ if they are not set as synchronized. """ identity = self._get_synced_identity(refresh=False) if identity is None: return {} return { field: (identity.data.get(field) or '') for field in multipass.synced_fields } @return_ascii def __repr__(self): return '<User({}, {}, {}, {})>'.format(self.id, self.first_name, self.last_name, self.email) def can_be_modified(self, user): """If this user can be modified by the given user""" return self == user or user.is_admin def get_full_name(self, last_name_first=True, last_name_upper=True, abbrev_first_name=True, show_title=False): """Returns the user's name in the specified notation. Note: Do not use positional arguments when calling this method. Always use keyword arguments! :param last_name_first: if "lastname, firstname" instead of "firstname lastname" should be used :param last_name_upper: if the last name should be all-uppercase :param abbrev_first_name: if the first name should be abbreviated to use only the first character :param show_title: if the title of the user should be included """ last_name = self.last_name.upper( ) if last_name_upper else self.last_name first_name = '{}.'.format(self.first_name[0].upper() ) if abbrev_first_name else self.first_name full_name = '{}, {}'.format( last_name, first_name) if last_name_first else '{} {}'.format( first_name, last_name) return full_name if not show_title or not self.title else '{} {}'.format( self.title, full_name) def iter_identifiers(self, check_providers=False, providers=None): """Yields ``(provider, identifier)`` tuples for the user. :param check_providers: If True, providers are searched for additional identifiers once all existing identifiers have been yielded. :param providers: May be a set containing provider names to get only identifiers from the specified providers. """ done = set() for identity in self.identities: if providers is not None and identity.provider not in providers: continue item = (identity.provider, identity.identifier) done.add(item) yield item if not check_providers: return for identity_info in multipass.search_identities( providers=providers, exact=True, email=self.all_emails): item = (identity_info.provider.name, identity_info.identifier) if item not in done: yield item def make_email_primary(self, email): """Promotes a secondary email address to the primary email address :param email: an email address that is currently a secondary email """ secondary = next( (x for x in self._secondary_emails if x.email == email), None) if secondary is None: raise ValueError('email is not a secondary email address') self._primary_email.is_primary = False db.session.flush() secondary.is_primary = True db.session.flush() def is_in_group(self, group): """Checks if the user is in a group :param group: A :class:`GroupProxy` """ return group.has_member(self) def get_linked_roles(self, type_): """Retrieves the roles the user is linked to for a given type""" return UserLink.get_linked_roles(self, type_) def get_linked_objects(self, type_, role): """Retrieves linked objects for the user""" return UserLink.get_links(self, type_, role) def link_to(self, obj, role): """Adds a link between the user and an object :param obj: a legacy object :param role: the role to use in the link """ return UserLink.create_link(self, obj, role) def unlink_to(self, obj, role): """Removes a link between the user and an object :param obj: a legacy object :param role: the role to use in the link """ return UserLink.remove_link(self, obj, role) def synchronize_data(self, refresh=False): """Synchronize the fields of the user from the sync identity. This will take only into account :attr:`synced_fields`. :param refresh: bool -- Whether to refresh the synced identity with the sync provider before instead of using the stored data. (Only if the sync provider supports refresh.) """ identity = self._get_synced_identity(refresh=refresh) if identity is None: return for field in self.synced_fields: old_value = getattr(self, field) new_value = identity.data.get(field) or '' if field in ('first_name', 'last_name') and not new_value: continue if old_value == new_value: continue flash( _("Your {field_name} has been synchronised from '{old_value}' to '{new_value}'." ).format(field_name=syncable_fields[field], old_value=old_value, new_value=new_value)) setattr(self, field, new_value) def _get_synced_identity(self, refresh=False): sync_provider = multipass.sync_provider if sync_provider is None: return None identities = sorted( [x for x in self.identities if x.provider == sync_provider.name], key=attrgetter('last_login_dt'), reverse=True) if not identities: return None identity = identities[0] if refresh and identity.multipass_data is not None: try: identity_info = sync_provider.refresh_identity( identity.identifier, identity.multipass_data) except IdentityRetrievalFailed: identity_info = None if identity_info: identity.data = identity_info.data return identity