class Contribution(DescriptionMixin, ProtectionManagersMixin, LocationMixin, AttachedItemsMixin, AttachedNotesMixin, PersonLinkDataMixin, AuthorsSpeakersMixin, CustomFieldsMixin, db.Model): __tablename__ = 'contributions' __auto_table_args = (db.Index(None, 'friendly_id', 'event_id', unique=True, postgresql_where=db.text('NOT is_deleted')), db.Index(None, 'event_id', 'track_id'), db.Index(None, 'event_id', 'abstract_id'), db.Index(None, 'abstract_id', unique=True, postgresql_where=db.text('NOT is_deleted')), db.CheckConstraint("session_block_id IS NULL OR session_id IS NOT NULL", 'session_block_if_session'), db.ForeignKeyConstraint(['session_block_id', 'session_id'], ['events.session_blocks.id', 'events.session_blocks.session_id']), {'schema': 'events'}) location_backref_name = 'contributions' disallowed_protection_modes = frozenset() inheriting_have_acl = True possible_render_modes = {RenderMode.html, RenderMode.markdown} default_render_mode = RenderMode.markdown allow_relationship_preloading = True PRELOAD_EVENT_ATTACHED_ITEMS = True PRELOAD_EVENT_NOTES = True ATTACHMENT_FOLDER_ID_COLUMN = 'contribution_id' @classmethod def allocate_friendly_ids(cls, event, n): """Allocate n Contribution friendly_ids. This is needed so that we can allocate all IDs in one go. Not doing so could result in DB deadlocks. All operations that create more than one contribution should use this method. :param event: the :class:`Event` in question :param n: the number of ids to pre-allocate """ from indico.modules.events import Event fid = increment_and_get(Event._last_friendly_contribution_id, Event.id == event.id, n) friendly_ids = g.setdefault('friendly_ids', {}) friendly_ids.setdefault(cls, {})[event.id] = range(fid - n + 1, fid + 1) @declared_attr def __table_args__(cls): return auto_table_args(cls) id = db.Column( db.Integer, primary_key=True ) #: The human-friendly ID for the contribution friendly_id = db.Column( db.Integer, nullable=False, default=_get_next_friendly_id ) event_id = db.Column( db.Integer, db.ForeignKey('events.events.id'), index=True, nullable=False ) session_id = db.Column( db.Integer, db.ForeignKey('events.sessions.id'), index=True, nullable=True ) session_block_id = db.Column( db.Integer, db.ForeignKey('events.session_blocks.id'), index=True, nullable=True ) track_id = db.Column( db.Integer, db.ForeignKey('events.tracks.id', ondelete='SET NULL'), index=True, nullable=True ) abstract_id = db.Column( db.Integer, db.ForeignKey('event_abstracts.abstracts.id'), index=True, nullable=True ) type_id = db.Column( db.Integer, db.ForeignKey('events.contribution_types.id'), index=True, nullable=True ) title = db.Column( db.String, nullable=False ) code = db.Column( db.String, nullable=False, default='' ) duration = db.Column( db.Interval, nullable=False ) board_number = db.Column( db.String, nullable=False, default='' ) keywords = db.Column( ARRAY(db.String), nullable=False, default=[] ) is_deleted = db.Column( db.Boolean, nullable=False, default=False ) #: The last user-friendly sub-contribution ID _last_friendly_subcontribution_id = db.deferred(db.Column( 'last_friendly_subcontribution_id', db.Integer, nullable=False, default=0 )) event = db.relationship( 'Event', lazy=True, backref=db.backref( 'contributions', primaryjoin='(Contribution.event_id == Event.id) & ~Contribution.is_deleted', cascade='all, delete-orphan', lazy=True ) ) session = db.relationship( 'Session', lazy=True, backref=db.backref( 'contributions', primaryjoin='(Contribution.session_id == Session.id) & ~Contribution.is_deleted', lazy=True ) ) session_block = db.relationship( 'SessionBlock', lazy=True, foreign_keys=[session_block_id], backref=db.backref( 'contributions', primaryjoin='(Contribution.session_block_id == SessionBlock.id) & ~Contribution.is_deleted', lazy=True ) ) type = db.relationship( 'ContributionType', lazy=True, backref=db.backref( 'contributions', lazy=True ) ) acl_entries = db.relationship( 'ContributionPrincipal', lazy=True, cascade='all, delete-orphan', collection_class=set, backref='contribution' ) subcontributions = db.relationship( 'SubContribution', lazy=True, primaryjoin='(SubContribution.contribution_id == Contribution.id) & ~SubContribution.is_deleted', order_by='SubContribution.position', cascade='all, delete-orphan', backref=db.backref( 'contribution', primaryjoin='SubContribution.contribution_id == Contribution.id', lazy=True ) ) abstract = db.relationship( 'Abstract', lazy=True, backref=db.backref( 'contribution', primaryjoin='(Contribution.abstract_id == Abstract.id) & ~Contribution.is_deleted', lazy=True, uselist=False ) ) track = db.relationship( 'Track', lazy=True, backref=db.backref( 'contributions', primaryjoin='(Contribution.track_id == Track.id) & ~Contribution.is_deleted', lazy=True, passive_deletes=True ) ) #: External references associated with this contribution references = db.relationship( 'ContributionReference', lazy=True, cascade='all, delete-orphan', backref=db.backref( 'contribution', lazy=True ) ) #: Persons associated with this contribution person_links = db.relationship( 'ContributionPersonLink', lazy=True, cascade='all, delete-orphan', backref=db.backref( 'contribution', lazy=True ) ) #: Data stored in abstract/contribution fields field_values = db.relationship( 'ContributionFieldValue', lazy=True, cascade='all, delete-orphan', backref=db.backref( 'contribution', lazy=True ) ) #: The accepted paper revision _accepted_paper_revision = db.relationship( 'PaperRevision', lazy=True, viewonly=True, uselist=False, primaryjoin=('(PaperRevision._contribution_id == Contribution.id) & (PaperRevision.state == {})' .format(PaperRevisionState.accepted)), ) #: Paper files not submitted for reviewing pending_paper_files = db.relationship( 'PaperFile', lazy=True, viewonly=True, primaryjoin='(PaperFile._contribution_id == Contribution.id) & (PaperFile.revision_id.is_(None))', ) #: Paper reviewing judges paper_judges = db.relationship( 'User', secondary='event_paper_reviewing.judges', collection_class=set, lazy=True, backref=db.backref( 'judge_for_contributions', collection_class=set, lazy=True ) ) #: Paper content reviewers paper_content_reviewers = db.relationship( 'User', secondary='event_paper_reviewing.content_reviewers', collection_class=set, lazy=True, backref=db.backref( 'content_reviewer_for_contributions', collection_class=set, lazy=True ) ) #: Paper layout reviewers paper_layout_reviewers = db.relationship( 'User', secondary='event_paper_reviewing.layout_reviewers', collection_class=set, lazy=True, backref=db.backref( 'layout_reviewer_for_contributions', collection_class=set, lazy=True ) ) @declared_attr def _paper_last_revision(cls): # Incompatible with joinedload subquery = (db.select([db.func.max(PaperRevision.submitted_dt)]) .where(PaperRevision._contribution_id == cls.id) .correlate_except(PaperRevision) .as_scalar()) return db.relationship( 'PaperRevision', uselist=False, lazy=True, viewonly=True, primaryjoin=db.and_(PaperRevision._contribution_id == cls.id, PaperRevision.submitted_dt == subquery) ) # relationship backrefs: # - _paper_files (PaperFile._contribution) # - _paper_revisions (PaperRevision._contribution) # - attachment_folders (AttachmentFolder.contribution) # - editables (Editable.contribution) # - legacy_mapping (LegacyContributionMapping.contribution) # - note (EventNote.contribution) # - room_reservation_links (ReservationLink.contribution) # - timetable_entry (TimetableEntry.contribution) # - vc_room_associations (VCRoomEventAssociation.linked_contrib) @declared_attr def is_scheduled(cls): from indico.modules.events.timetable.models.entries import TimetableEntry query = (db.exists([1]) .where(TimetableEntry.contribution_id == cls.id) .correlate_except(TimetableEntry)) return db.column_property(query, deferred=True) @declared_attr def subcontribution_count(cls): from indico.modules.events.contributions.models.subcontributions import SubContribution query = (db.select([db.func.count(SubContribution.id)]) .where((SubContribution.contribution_id == cls.id) & ~SubContribution.is_deleted) .correlate_except(SubContribution)) return db.column_property(query, deferred=True) @declared_attr def _paper_revision_count(cls): query = (db.select([db.func.count(PaperRevision.id)]) .where(PaperRevision._contribution_id == cls.id) .correlate_except(PaperRevision)) return db.column_property(query, deferred=True) def __init__(self, **kwargs): # explicitly initialize those relationships with None to avoid # an extra query to check whether there is an object associated # when assigning a new one (e.g. during cloning) kwargs.setdefault('note', None) kwargs.setdefault('timetable_entry', None) super(Contribution, self).__init__(**kwargs) @classmethod def preload_acl_entries(cls, event): cls.preload_relationships(cls.query.with_parent(event), 'acl_entries') @property def location_parent(self): if self.session_block_id is not None: return self.session_block elif self.session_id is not None: return self.session else: return self.event @property def protection_parent(self): return self.session if self.session_id is not None else self.event @property def start_dt(self): return self.timetable_entry.start_dt if self.timetable_entry else None @property def end_dt(self): return self.timetable_entry.start_dt + self.duration if self.timetable_entry else None @property def start_dt_poster(self): if self.session and self.session.is_poster and self.timetable_entry and self.timetable_entry.parent: return self.timetable_entry.parent.start_dt @property def end_dt_poster(self): if self.session and self.session.is_poster and self.timetable_entry and self.timetable_entry.parent: return self.timetable_entry.parent.end_dt @property def duration_poster(self): if self.session and self.session.is_poster and self.timetable_entry and self.timetable_entry.parent: return self.timetable_entry.parent.duration @property def start_dt_display(self): """The displayed start time of the contribution. This is the start time of the poster session if applicable, otherwise the start time of the contribution itself. """ return self.start_dt_poster or self.start_dt @property def end_dt_display(self): """The displayed end time of the contribution. This is the end time of the poster session if applicable, otherwise the end time of the contribution itself. """ return self.end_dt_poster or self.end_dt @property def duration_display(self): """The displayed duration of the contribution. This is the duration of the poster session if applicable, otherwise the duration of the contribution itself. """ return self.duration_poster or self.duration @property def submitters(self): return {person_link for person_link in self.person_links if person_link.is_submitter} @locator_property def locator(self): return dict(self.event.locator, contrib_id=self.id) @property def verbose_title(self): return '#{} ({})'.format(self.friendly_id, self.title) @property def paper(self): return Paper(self) if self._paper_last_revision else None @property def allowed_types_for_editable(self): from indico.modules.events.editing.settings import editable_type_settings if not self.event.has_feature('editing'): return [] submitted_for = {editable.type.name for editable in self.editables} return [ editable_type for editable_type in self.event.editable_types if editable_type not in submitted_for and editable_type_settings[EditableType[editable_type]].get(self.event, 'submission_enabled') ] @property def enabled_editables(self): """Return all submitted editables with enabled types.""" from indico.modules.events.editing.settings import editing_settings if not self.event.has_feature('editing'): return [] enabled_editable_types = editing_settings.get(self.event, 'editable_types') enabled_editables = [editable for editable in self.editables if editable.type.name in enabled_editable_types] order = list(EditableType) return sorted(enabled_editables, key=lambda editable: order.index(editable.type)) @property def has_published_editables(self): return any(e.published_revision_id is not None for e in self.enabled_editables) def is_paper_reviewer(self, user): return user in self.paper_content_reviewers or user in self.paper_layout_reviewers @return_ascii def __repr__(self): return format_repr(self, 'id', is_deleted=False, _text=self.title) def can_manage(self, user, permission=None, allow_admin=True, check_parent=True, explicit_permission=False): if super(Contribution, self).can_manage(user, permission, allow_admin=allow_admin, check_parent=check_parent, explicit_permission=explicit_permission): return True if (check_parent and self.session_id is not None and self.session.can_manage(user, 'coordinate', allow_admin=allow_admin, explicit_permission=explicit_permission) and session_coordinator_priv_enabled(self.event, 'manage-contributions')): return True return False def get_non_inheriting_objects(self): """Get a set of child objects that do not inherit protection.""" return get_non_inheriting_objects(self) def is_user_associated(self, user, check_abstract=False): if user is None: return False if check_abstract and self.abstract and self.abstract.submitter == user: return True return any(pl.person.user == user for pl in self.person_links if pl.person.user) def can_submit_proceedings(self, user): """Whether the user can submit editables/papers.""" if user is None: return False # The submitter of the original abstract is always authorized if self.abstract and self.abstract.submitter == user: return True # Otherwise only users with submission rights are authorized return self.can_manage(user, 'submit', allow_admin=False, check_parent=False) def get_editable(self, editable_type): """Get the editable of the given type.""" return next((e for e in self.editables if e.type == editable_type), None) def log(self, *args, **kwargs): """Log with prefilled metadata for the contribution.""" self.event.log(*args, meta={'contribution_id': self.id}, **kwargs)
class Category(SearchableTitleMixin, DescriptionMixin, ProtectionManagersMixin, AttachedItemsMixin, db.Model): """An Indico category""" __tablename__ = 'categories' disallowed_protection_modes = frozenset() inheriting_have_acl = True possible_render_modes = {RenderMode.markdown} default_render_mode = RenderMode.markdown allow_no_access_contact = True ATTACHMENT_FOLDER_ID_COLUMN = 'category_id' @strict_classproperty @classmethod def __auto_table_args(cls): return (db.CheckConstraint( "(icon IS NULL) = (icon_metadata::text = 'null')", 'valid_icon'), db.CheckConstraint( "(logo IS NULL) = (logo_metadata::text = 'null')", 'valid_logo'), db.CheckConstraint("(parent_id IS NULL) = (id = 0)", 'valid_parent'), db.CheckConstraint("(id != 0) OR NOT is_deleted", 'root_not_deleted'), db.CheckConstraint( "(id != 0) OR (protection_mode != {})".format( ProtectionMode.inheriting), 'root_not_inheriting'), db.CheckConstraint('visibility IS NULL OR visibility > 0', 'valid_visibility'), { 'schema': 'categories' }) @declared_attr def __table_args__(cls): return auto_table_args(cls) id = db.Column(db.Integer, primary_key=True) parent_id = db.Column(db.Integer, db.ForeignKey('categories.categories.id'), index=True, nullable=True) is_deleted = db.Column(db.Boolean, nullable=False, default=False) position = db.Column(db.Integer, nullable=False, default=_get_next_position) visibility = db.Column(db.Integer, nullable=True, default=None) icon_metadata = db.Column(JSON, nullable=False, default=lambda: None) icon = db.deferred(db.Column(db.LargeBinary, nullable=True)) logo_metadata = db.Column(JSON, nullable=False, default=lambda: None) logo = db.deferred(db.Column(db.LargeBinary, nullable=True)) timezone = db.Column(db.String, nullable=False, default=lambda: config.DEFAULT_TIMEZONE) default_event_themes = db.Column(JSON, nullable=False, default=_get_default_event_themes) event_creation_restricted = db.Column(db.Boolean, nullable=False, default=True) event_creation_notification_emails = db.Column(ARRAY(db.String), nullable=False, default=[]) event_message_mode = db.Column(PyIntEnum(EventMessageMode), nullable=False, default=EventMessageMode.disabled) _event_message = db.Column('event_message', db.Text, nullable=False, default='') suggestions_disabled = db.Column(db.Boolean, nullable=False, default=False) notify_managers = db.Column(db.Boolean, nullable=False, default=False) default_ticket_template_id = db.Column( db.ForeignKey('indico.designer_templates.id'), nullable=True, index=True) children = db.relationship( 'Category', order_by='Category.position', primaryjoin=(id == db.remote(parent_id)) & ~db.remote(is_deleted), lazy=True, backref=db.backref('parent', primaryjoin=(db.remote(id) == parent_id), lazy=True)) acl_entries = db.relationship('CategoryPrincipal', backref='category', cascade='all, delete-orphan', collection_class=set) default_ticket_template = db.relationship( 'DesignerTemplate', lazy=True, foreign_keys=default_ticket_template_id, backref='default_ticket_template_of') # column properties: # - deep_events_count # relationship backrefs: # - attachment_folders (AttachmentFolder.category) # - designer_templates (DesignerTemplate.category) # - events (Event.category) # - favorite_of (User.favorite_categories) # - legacy_mapping (LegacyCategoryMapping.category) # - parent (Category.children) # - settings (CategorySetting.category) # - suggestions (SuggestedCategory.category) @hybrid_property def event_message(self): return MarkdownText(self._event_message) @event_message.setter def event_message(self, value): self._event_message = value @event_message.expression def event_message(cls): return cls._event_message @return_ascii def __repr__(self): return format_repr(self, 'id', is_deleted=False, _text=text_to_repr(self.title, max_length=75)) @property def protection_parent(self): return self.parent if not self.is_root else None @locator_property def locator(self): return {'category_id': self.id} @classmethod def get_root(cls): """Get the root category""" return cls.query.filter(cls.is_root).one() @property def url(self): return url_for('categories.display', self) @property def has_only_events(self): return self.has_events and not self.children @hybrid_property def is_root(self): return self.parent_id is None @is_root.expression def is_root(cls): return cls.parent_id.is_(None) @property def is_empty(self): return not self.deep_children_count and not self.deep_events_count @property def has_icon(self): return self.icon_metadata is not None @property def has_effective_icon(self): return self.effective_icon_data['metadata'] is not None @property def has_logo(self): return self.logo_metadata is not None @property def tzinfo(self): return pytz.timezone(self.timezone) @property def display_tzinfo(self): """The tzinfo of the category or the one specified by the user""" return get_display_tz(self, as_timezone=True) def can_create_events(self, user): """Check whether the user can create events in the category.""" # if creation is not restricted anyone who can access the category # can also create events in it, otherwise only people with the # creation role can return user and ( (not self.event_creation_restricted and self.can_access(user)) or self.can_manage(user, permission='create')) def move(self, target): """Move the category into another category.""" assert not self.is_root old_parent = self.parent self.position = (max(x.position for x in target.children) + 1) if target.children else 1 self.parent = target db.session.flush() signals.category.moved.send(self, old_parent=old_parent) @classmethod def get_tree_cte(cls, col='id'): """Create a CTE for the category tree. The CTE contains the following columns: - ``id`` -- the category id - ``path`` -- an array containing the path from the root to the category itself - ``is_deleted`` -- whether the category is deleted :param col: The name of the column to use in the path or a callable receiving the category alias that must return the expression used for the 'path' retrieved by the CTE. """ cat_alias = db.aliased(cls) if callable(col): path_column = col(cat_alias) else: path_column = getattr(cat_alias, col) cte_query = (select([ cat_alias.id, array([path_column]).label('path'), cat_alias.is_deleted ]).where(cat_alias.parent_id.is_(None)).cte(recursive=True)) rec_query = (select([ cat_alias.id, cte_query.c.path.op('||')(path_column), cte_query.c.is_deleted | cat_alias.is_deleted ]).where(cat_alias.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) @classmethod def get_protection_cte(cls): cat_alias = db.aliased(cls) cte_query = (select([cat_alias.id, cat_alias.protection_mode]).where( cat_alias.parent_id.is_(None)).cte(recursive=True)) rec_query = (select([ cat_alias.id, db.case( {ProtectionMode.inheriting.value: cte_query.c.protection_mode}, else_=cat_alias.protection_mode, value=cat_alias.protection_mode) ]).where(cat_alias.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) def get_protection_parent_cte(self): cte_query = (select([ Category.id, db.cast(literal(None), db.Integer).label('protection_parent') ]).where(Category.id == self.id).cte(recursive=True)) rec_query = (select([ Category.id, db.case( { ProtectionMode.inheriting.value: func.coalesce(cte_query.c.protection_parent, self.id) }, else_=Category.id, value=Category.protection_mode) ]).where(Category.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) @classmethod def get_icon_data_cte(cls): cat_alias = db.aliased(cls) cte_query = (select([ cat_alias.id, cat_alias.id.label('source_id'), cat_alias.icon_metadata ]).where(cat_alias.parent_id.is_(None)).cte(recursive=True)) rec_query = (select([ cat_alias.id, db.case({'null': cte_query.c.source_id}, else_=cat_alias.id, value=db.func.json_typeof(cat_alias.icon_metadata)), db.case({'null': cte_query.c.icon_metadata}, else_=cat_alias.icon_metadata, value=db.func.json_typeof(cat_alias.icon_metadata)) ]).where(cat_alias.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) @property def deep_children_query(self): """Get a query object for all subcategories. This includes subcategories at any level of nesting. """ cte = Category.get_tree_cte() return (Category.query.join(cte, Category.id == cte.c.id).filter( cte.c.path.contains([self.id]), cte.c.id != self.id, ~cte.c.is_deleted)) @staticmethod def _get_chain_query(start_criterion): cte_query = (select([ Category.id, Category.parent_id, literal(0).label('level') ]).where(start_criterion).cte('category_chain', recursive=True)) parent_query = (select([ Category.id, Category.parent_id, cte_query.c.level + 1 ]).where(Category.id == cte_query.c.parent_id)) cte_query = cte_query.union_all(parent_query) return Category.query.join(cte_query, Category.id == cte_query.c.id).order_by( cte_query.c.level.desc()) @property def chain_query(self): """Get a query object for the category chain. The query retrieves the root category first and then all the intermediate categories up to (and including) this category. """ return self._get_chain_query(Category.id == self.id) @property def parent_chain_query(self): """Get a query object for the category's parent chain. The query retrieves the root category first and then all the intermediate categories up to (excluding) this category. """ return self._get_chain_query(Category.id == self.parent_id) def nth_parent(self, n_categs, fail_on_overflow=True): """Return the nth parent of the category. :param n_categs: the number of categories to go up :param fail_on_overflow: whether to fail if we try to go above the root category :return: `Category` object or None (only if ``fail_on_overflow`` is not set) """ if n_categs == 0: return self chain = self.parent_chain_query.all() assert n_categs >= 0 if n_categs > len(chain): if fail_on_overflow: raise IndexError("Root category has no parent!") else: return None return chain[::-1][n_categs - 1] def is_descendant_of(self, categ): return categ != self and self.parent_chain_query.filter( Category.id == categ.id).has_rows() @property def visibility_horizon_query(self): """Get a query object that returns the highest category this one is visible from.""" cte_query = (select([ Category.id, Category.parent_id, db.case([(Category.visibility.is_(None), None)], else_=(Category.visibility - 1)).label('n'), literal(0).label('level') ]).where(Category.id == self.id).cte('visibility_horizon', recursive=True)) parent_query = (select([ Category.id, Category.parent_id, db.case([ (Category.visibility.is_(None) & cte_query.c.n.is_(None), None) ], else_=db.func.least(Category.visibility, cte_query.c.n) - 1), cte_query.c.level + 1 ]).where( db.and_(Category.id == cte_query.c.parent_id, (cte_query.c.n > 0) | cte_query.c.n.is_(None)))) cte_query = cte_query.union_all(parent_query) return db.session.query(cte_query.c.id, cte_query.c.n).order_by( cte_query.c.level.desc()).limit(1) @property def own_visibility_horizon(self): """Get the highest category this one would like to be visible from (configured visibility).""" if self.visibility is None: return Category.get_root() else: return self.nth_parent(self.visibility - 1) @property def real_visibility_horizon(self): """Get the highest category this one is actually visible from (as limited by categories above).""" horizon_id, final_visibility = self.visibility_horizon_query.one() if final_visibility is not None and final_visibility < 0: return None # Category is invisible return Category.get(horizon_id) @property def visible_categories_cte(self): """ Get a sqlalchemy select for the visible categories within this category, including the category itself. """ cte_query = (select([ Category.id, literal(0).label('level') ]).where((Category.id == self.id) & (Category.visibility.is_(None) | (Category.visibility > 0))).cte( 'visibility_chain', recursive=True)) parent_query = (select([Category.id, cte_query.c.level + 1]).where( db.and_( Category.parent_id == cte_query.c.id, db.or_(Category.visibility.is_(None), Category.visibility > cte_query.c.level + 1)))) return cte_query.union_all(parent_query) @property def visible_categories_query(self): """ Get a query object for the visible categories within this category, including the category itself. """ cte_query = self.visible_categories_cte return Category.query.join(cte_query, Category.id == cte_query.c.id) @property def icon_url(self): """Get the HTTP URL of the icon.""" return url_for('categories.display_icon', self, slug=self.icon_metadata['hash']) @property def effective_icon_url(self): """Get the HTTP URL of the icon (possibly inherited).""" data = self.effective_icon_data return url_for('categories.display_icon', category_id=data['source_id'], slug=data['metadata']['hash']) @property def logo_url(self): """Get the HTTP URL of the logo.""" return url_for('categories.display_logo', self, slug=self.logo_metadata['hash'])
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
class Survey(db.Model): __tablename__ = 'surveys' __table_args__ = (db.CheckConstraint('anonymous OR require_user', 'valid_anonymous_user'), { 'schema': 'event_surveys' }) #: The ID of the survey id = db.Column(db.Integer, primary_key=True) #: The ID of the event event_id = db.Column(db.Integer, db.ForeignKey('events.events.id'), index=True, nullable=False) #: The title of the survey title = db.Column(db.String, nullable=False) uuid = db.Column(UUID, unique=True, nullable=False, default=lambda: str(uuid4())) # An introduction text for users of the survey introduction = db.Column(db.Text, nullable=False, default='') #: Whether submissions will not be linked to a user anonymous = db.Column(db.Boolean, nullable=False, default=False) #: Whether submissions must be done by logged users require_user = db.Column(db.Boolean, nullable=False, default=True) # #: Whether the survey is only for selected users private = db.Column(db.Boolean, nullable=False, default=False) #: Maximum number of submissions allowed submission_limit = db.Column(db.Integer, nullable=True) #: Datetime when the survey is open start_dt = db.Column(UTCDateTime, nullable=True) #: Datetime when the survey is closed end_dt = db.Column(UTCDateTime, nullable=True) #: Whether the survey has been marked as deleted is_deleted = db.Column(db.Boolean, nullable=False, default=False) #: Whether start notification has been already sent start_notification_sent = db.Column(db.Boolean, nullable=False, default=False) #: Whether to send survey related notifications to users notifications_enabled = db.Column(db.Boolean, nullable=False, default=False) #: Whether include Participants / Registrants when sending start notifications notify_participants = db.Column(db.Boolean, nullable=False, default=False) #: Email addresses to notify about the start of a survey start_notification_emails = db.Column(ARRAY(db.String), nullable=False, default=[]) #: Email addresses to notify about new submissions new_submission_emails = db.Column(ARRAY(db.String), nullable=False, default=[]) #: Whether answers can be saved without submitting the survey partial_completion = db.Column(db.Boolean, nullable=False, default=False) #: The last user-friendly submission ID _last_friendly_submission_id = db.deferred( db.Column('last_friendly_submission_id', db.Integer, nullable=False, default=0)) #: The list of submissions submissions = db.relationship('SurveySubmission', cascade='all, delete-orphan', lazy=True, backref=db.backref('survey', lazy=True)) #: The list of items items = db.relationship('SurveyItem', cascade='all, delete-orphan', lazy=True, backref=db.backref('survey', lazy=True)) #: The list of sections sections = db.relationship('SurveySection', lazy=True, viewonly=True, order_by='SurveySection.position') #: The list of questions questions = db.relationship('SurveyQuestion', lazy=True, viewonly=True) #: The Event containing this survey event = db.relationship('Event', lazy=True, backref=db.backref('surveys', lazy=True)) # relationship backrefs: # - anonymous_submissions (AnonymousSurveySubmission.survey) @hybrid_property def has_ended(self): return self.end_dt is not None and self.end_dt <= now_utc() @has_ended.expression def has_ended(cls): return cls.end_dt.isnot(None) & (cls.end_dt <= now_utc()) @hybrid_property def has_started(self): return self.start_dt is not None and self.start_dt <= now_utc() @has_started.expression def has_started(cls): return cls.start_dt.isnot(None) & (cls.start_dt <= now_utc()) @locator_property def locator(self): return {'event_id': self.event_id, 'survey_id': self.id} @locator.token def locator(self): """A locator that adds the UUID if the survey is private.""" token = self.uuid if self.private else None return dict(self.locator, token=token) @property def state(self): if not self.questions: return SurveyState.not_ready if not self.has_started: return SurveyState.ready_to_open if self.limit_reached: return SurveyState.limit_reached if not self.has_ended: return SurveyState.active_and_answered if self.submissions else SurveyState.active_and_clean return SurveyState.finished @property def limit_reached(self): return self.submission_limit is not None and len( self.submissions) >= self.submission_limit @property def start_notification_recipients(self): """Return all recipients of the notifications. This includes both explicit recipients and, if enabled, participants of the event. """ recipients = set(self.start_notification_emails) if self.notify_participants: recipients.update( reg.email for reg in Registration.get_all_for_event(self.event)) recipients.discard( '') # just in case there was an empty email address somewhere return recipients @hybrid_property def is_active(self): return not self.is_deleted and self.state in { SurveyState.active_and_answered, SurveyState.active_and_clean } @is_active.expression def is_active(cls): submissions = (db.session.query(db.func.count( db.m.SurveySubmission.id)).filter( db.m.SurveySubmission.survey_id == cls.id).correlate( Survey).scalar_subquery()) limit_criterion = db.case([(cls.submission_limit.is_(None), True)], else_=(submissions < cls.submission_limit)) return ~cls.is_deleted & cls.questions.any( ) & cls.has_started & ~cls.has_ended & limit_criterion @hybrid_property def is_visible(self): return (not self.is_deleted and self.state in { SurveyState.active_and_answered, SurveyState.active_and_clean, SurveyState.finished, SurveyState.limit_reached }) @is_visible.expression def is_visible(cls): return ~cls.is_deleted & cls.questions.any() & cls.has_started def __repr__(self): return f'<Survey({self.id}, {self.event_id}): {self.title}>' def can_submit(self, user): return self.is_active and (not self.require_user or user) def open(self): if self.state != SurveyState.ready_to_open: raise IndicoError("Survey can't be opened") self.start_dt = now_utc() def close(self): if self.state not in (SurveyState.active_and_clean, SurveyState.active_and_answered, SurveyState.limit_reached): raise IndicoError("Survey can't be closed") self.end_dt = now_utc() def send_start_notification(self): if not self.notifications_enabled or self.start_notification_sent or not self.event.has_feature( 'surveys'): return template_module = get_template_module( 'events/surveys/emails/start_notification_email.txt', survey=self) email = make_email(bcc_list=self.start_notification_recipients, template=template_module) send_email(email, event=self.event, module='Surveys') logger.info('Sending start notification for survey %s', self) self.start_notification_sent = True def send_submission_notification(self, submission): if not self.notifications_enabled: return template_module = get_template_module( 'events/surveys/emails/new_submission_email.txt', submission=submission) email = make_email(bcc_list=self.new_submission_emails, template=template_module) send_email(email, event=self.event, module='Surveys') logger.info('Sending submission notification for survey %s', self)
class Agreement(db.Model): """Agreements between a person and Indico""" __tablename__ = 'agreements' __table_args__ = (db.UniqueConstraint('event_id', 'type', 'identifier'), {'schema': 'events'}) #: Entry ID id = db.Column( db.Integer, primary_key=True ) #: Entry universally unique ID uuid = db.Column( db.String, nullable=False ) #: ID of the event event_id = db.Column( db.Integer, db.ForeignKey('events.events.id'), nullable=False, index=True ) #: Type of agreement type = db.Column( db.String, nullable=False ) #: Unique identifier within the event and type identifier = db.Column( db.String, nullable=False ) #: Email of the person agreeing person_email = db.Column( db.String, nullable=True ) #: Full name of the person agreeing person_name = db.Column( db.String, nullable=False ) #: A :class:`AgreementState` state = db.Column( PyIntEnum(AgreementState), default=AgreementState.pending, nullable=False ) #: The date and time the agreement was created timestamp = db.Column( UTCDateTime, default=now_utc, nullable=False ) #: ID of a linked user user_id = db.Column( db.Integer, db.ForeignKey('users.users.id'), index=True, nullable=True ) #: The date and time the agreement was signed signed_dt = db.Column( UTCDateTime ) #: The IP from which the agreement was signed signed_from_ip = db.Column( db.String ) #: Explanation as to why the agreement was accepted/rejected reason = db.Column( db.String ) #: Attachment attachment = db.deferred(db.Column( db.LargeBinary )) #: Filename and extension of the attachment attachment_filename = db.Column( db.String ) #: Definition-specific data of the agreement data = db.Column( JSONB ) #: The user this agreement is linked to user = db.relationship( 'User', lazy=False, backref=db.backref( 'agreements', lazy='dynamic' ) ) #: The Event this agreement is associated with event = db.relationship( 'Event', lazy=True, backref=db.backref( 'agreements', lazy='dynamic' ) ) @hybrid_property def accepted(self): return self.state in {AgreementState.accepted, AgreementState.accepted_on_behalf} @accepted.expression def accepted(self): return self.state.in_((AgreementState.accepted, AgreementState.accepted_on_behalf)) @hybrid_property def pending(self): return self.state == AgreementState.pending @hybrid_property def rejected(self): return self.state in {AgreementState.rejected, AgreementState.rejected_on_behalf} @rejected.expression def rejected(self): return self.state.in_((AgreementState.rejected, AgreementState.rejected_on_behalf)) @hybrid_property def signed_on_behalf(self): return self.state in {AgreementState.accepted_on_behalf, AgreementState.rejected_on_behalf} @signed_on_behalf.expression def signed_on_behalf(self): return self.state.in_((AgreementState.accepted_on_behalf, AgreementState.rejected_on_behalf)) @property def definition(self): from indico.modules.events.agreements.util import get_agreement_definitions return get_agreement_definitions().get(self.type) @property def locator(self): return {'confId': self.event_id, 'id': self.id} @return_ascii def __repr__(self): state = self.state.name if self.state is not None else None return '<Agreement({}, {}, {}, {}, {}, {})>'.format(self.id, self.event_id, self.type, self.identifier, self.person_email, state) @staticmethod def create_from_data(event, type_, person): agreement = Agreement(event=event, type=type_, state=AgreementState.pending, uuid=str(uuid4())) agreement.identifier = person.identifier agreement.person_email = person.email agreement.person_name = person.name if person.user: agreement.user = person.user agreement.data = person.data return agreement def accept(self, from_ip, reason=None, on_behalf=False): self.state = AgreementState.accepted if not on_behalf else AgreementState.accepted_on_behalf self.signed_from_ip = from_ip self.reason = reason self.signed_dt = now_utc() self.definition.handle_accepted(self) def reject(self, from_ip, reason=None, on_behalf=False): self.state = AgreementState.rejected if not on_behalf else AgreementState.rejected_on_behalf self.signed_from_ip = from_ip self.reason = reason self.signed_dt = now_utc() self.definition.handle_rejected(self) def reset(self): self.definition.handle_reset(self) self.state = AgreementState.pending self.attachment = None self.attachment_filename = None self.reason = None self.signed_dt = None self.signed_from_ip = None def render(self, form, **kwargs): definition = self.definition if definition is None: raise ServiceUnavailable('This agreement type is currently not available.') return definition.render_form(self, form, **kwargs) def belongs_to(self, person): return self.identifier == person.identifier def is_orphan(self): definition = self.definition if definition is None: raise ServiceUnavailable('This agreement type is currently not available.') return definition.is_agreement_orphan(self.event, self)
class Contribution(DescriptionMixin, ProtectionManagersMixin, LocationMixin, AttachedItemsMixin, AttachedNotesMixin, PersonLinkDataMixin, CustomFieldsMixin, db.Model): __tablename__ = 'contributions' __auto_table_args = ( db.Index(None, 'friendly_id', 'event_id', unique=True, postgresql_where=db.text('NOT is_deleted')), db.Index(None, 'event_id', 'track_id'), db.Index(None, 'event_id', 'abstract_id'), db.Index(None, 'abstract_id', unique=True, postgresql_where=db.text('NOT is_deleted')), db.CheckConstraint( "session_block_id IS NULL OR session_id IS NOT NULL", 'session_block_if_session'), db.ForeignKeyConstraint( ['session_block_id', 'session_id'], ['events.session_blocks.id', 'events.session_blocks.session_id']), { 'schema': 'events' }) location_backref_name = 'contributions' disallowed_protection_modes = frozenset() inheriting_have_acl = True description_wrapper = MarkdownText allow_relationship_preloading = True PRELOAD_EVENT_ATTACHED_ITEMS = True PRELOAD_EVENT_NOTES = True ATTACHMENT_FOLDER_ID_COLUMN = 'contribution_id' @declared_attr def __table_args__(cls): return auto_table_args(cls) id = db.Column(db.Integer, primary_key=True) #: The human-friendly ID for the contribution friendly_id = db.Column(db.Integer, nullable=False, default=_get_next_friendly_id) event_id = db.Column(db.Integer, db.ForeignKey('events.events.id'), index=True, nullable=False) session_id = db.Column(db.Integer, db.ForeignKey('events.sessions.id'), index=True, nullable=True) session_block_id = db.Column(db.Integer, db.ForeignKey('events.session_blocks.id'), index=True, nullable=True) track_id = db.Column(db.Integer, nullable=True) abstract_id = db.Column(db.Integer, db.ForeignKey('event_abstracts.abstracts.id'), index=True, nullable=True) type_id = db.Column(db.Integer, db.ForeignKey('events.contribution_types.id'), index=True, nullable=True) title = db.Column(db.String, nullable=False) duration = db.Column(db.Interval, nullable=False) board_number = db.Column(db.String, nullable=False, default='') keywords = db.Column(ARRAY(db.String), nullable=False, default=[]) is_deleted = db.Column(db.Boolean, nullable=False, default=False) #: The last user-friendly sub-contribution ID _last_friendly_subcontribution_id = db.deferred( db.Column('last_friendly_subcontribution_id', db.Integer, nullable=False, default=0)) event_new = db.relationship( 'Event', lazy=True, backref=db.backref( 'contributions', primaryjoin= '(Contribution.event_id == Event.id) & ~Contribution.is_deleted', cascade='all, delete-orphan', lazy=True)) session = db.relationship( 'Session', lazy=True, backref=db.backref( 'contributions', primaryjoin= '(Contribution.session_id == Session.id) & ~Contribution.is_deleted', lazy=True)) session_block = db.relationship( 'SessionBlock', lazy=True, foreign_keys=[session_block_id], backref=db.backref( 'contributions', primaryjoin= '(Contribution.session_block_id == SessionBlock.id) & ~Contribution.is_deleted', lazy=True)) type = db.relationship('ContributionType', lazy=True, backref=db.backref('contributions', lazy=True)) acl_entries = db.relationship('ContributionPrincipal', lazy=True, cascade='all, delete-orphan', collection_class=set, backref='contribution') subcontributions = db.relationship( 'SubContribution', lazy=True, primaryjoin= '(SubContribution.contribution_id == Contribution.id) & ~SubContribution.is_deleted', order_by='SubContribution.position', cascade='all, delete-orphan', backref=db.backref( 'contribution', primaryjoin='SubContribution.contribution_id == Contribution.id', lazy=True)) abstract = db.relationship( 'Abstract', lazy=True, backref=db.backref( 'contribution', primaryjoin= '(Contribution.abstract_id == Abstract.id) & ~Contribution.is_deleted', lazy=True, uselist=False)) #: External references associated with this contribution references = db.relationship('ContributionReference', lazy=True, cascade='all, delete-orphan', backref=db.backref('contribution', lazy=True)) #: Persons associated with this contribution person_links = db.relationship('ContributionPersonLink', lazy=True, cascade='all, delete-orphan', backref=db.backref('contribution', lazy=True)) #: Data stored in abstract/contribution fields field_values = db.relationship('ContributionFieldValue', lazy=True, cascade='all, delete-orphan', backref=db.backref('contribution', lazy=True)) # relationship backrefs: # - attachment_folders (AttachmentFolder.contribution) # - legacy_mapping (LegacyContributionMapping.contribution) # - note (EventNote.contribution) # - paper_files (PaperFile.contribution) # - paper_reviewing_roles (PaperReviewingRole.contribution) # - timetable_entry (TimetableEntry.contribution) # - vc_room_associations (VCRoomEventAssociation.linked_contrib) @declared_attr def is_scheduled(cls): from indico.modules.events.timetable.models.entries import TimetableEntry query = (db.exists([1]).where(TimetableEntry.contribution_id == cls.id).correlate_except(TimetableEntry)) return db.column_property(query, deferred=True) @declared_attr def subcontribution_count(cls): from indico.modules.events.contributions.models.subcontributions import SubContribution query = (db.select([db.func.count(SubContribution.id)]).where( (SubContribution.contribution_id == cls.id) & ~SubContribution.is_deleted).correlate_except(SubContribution)) return db.column_property(query, deferred=True) def __init__(self, **kwargs): # explicitly initialize those relationships with None to avoid # an extra query to check whether there is an object associated # when assigning a new one (e.g. during cloning) kwargs.setdefault('note', None) kwargs.setdefault('timetable_entry', None) super(Contribution, self).__init__(**kwargs) @classmethod def preload_acl_entries(cls, event): cls.preload_relationships(cls.query.with_parent(event), 'acl_entries') @property def location_parent(self): if self.session_block_id is not None: return self.session_block elif self.session_id is not None: return self.session else: return self.event_new @property def protection_parent(self): return self.session if self.session_id is not None else self.event_new @property def track(self): return self.event_new.as_legacy.getTrackById(str(self.track_id)) @property def start_dt(self): return self.timetable_entry.start_dt if self.timetable_entry else None @property def end_dt(self): return self.timetable_entry.start_dt + self.duration if self.timetable_entry else None @property def speakers(self): return [ person_link for person_link in self.person_links if person_link.is_speaker ] @property def speaker_names(self): return [ person_link.full_name for person_link in self.person_links if person_link.is_speaker ] @property def primary_authors(self): return { person_link for person_link in self.person_links if person_link.author_type == AuthorType.primary } @property def secondary_authors(self): return { person_link for person_link in self.person_links if person_link.author_type == AuthorType.secondary } @property def submitters(self): return { person_link for person_link in self.person_links if person_link.is_submitter } @locator_property def locator(self): return dict(self.event_new.locator, contrib_id=self.id) @return_ascii def __repr__(self): return format_repr(self, 'id', is_deleted=False, _text=self.title) def can_manage(self, user, role=None, allow_admin=True, check_parent=True, explicit_role=False): if super(Contribution, self).can_manage(user, role, allow_admin=allow_admin, check_parent=check_parent, explicit_role=explicit_role): return True if (check_parent and self.session_id is not None and self.session.can_manage(user, 'coordinate', allow_admin=allow_admin, explicit_role=explicit_role) and session_coordinator_priv_enabled(self.event_new, 'manage-contributions')): return True return False def get_non_inheriting_objects(self): """Get a set of child objects that do not inherit protection.""" return get_non_inheriting_objects(self) def get_field_value(self, field_id, raw=False): fv = next((v for v in self.field_values if v.contribution_field_id == field_id), None) if raw: return fv else: return fv.friendly_data if fv else ''
class Category(SearchableTitleMixin, DescriptionMixin, ProtectionManagersMixin, AttachedItemsMixin, db.Model): """An Indico category.""" __tablename__ = 'categories' disallowed_protection_modes = frozenset() inheriting_have_acl = True possible_render_modes = {RenderMode.markdown} default_render_mode = RenderMode.markdown allow_no_access_contact = True allow_relationship_preloading = True ATTACHMENT_FOLDER_ID_COLUMN = 'category_id' @strict_classproperty @classmethod def __auto_table_args(cls): return (db.CheckConstraint("(icon IS NULL) = (icon_metadata::text = 'null')", 'valid_icon'), db.CheckConstraint("(logo IS NULL) = (logo_metadata::text = 'null')", 'valid_logo'), db.CheckConstraint('(parent_id IS NULL) = (id = 0)', 'valid_parent'), db.CheckConstraint('(id != 0) OR NOT is_deleted', 'root_not_deleted'), db.CheckConstraint(f'(id != 0) OR (protection_mode != {ProtectionMode.inheriting})', 'root_not_inheriting'), db.CheckConstraint('visibility IS NULL OR visibility > 0', 'valid_visibility'), {'schema': 'categories'}) @declared_attr def __table_args__(cls): return auto_table_args(cls) id = db.Column( db.Integer, primary_key=True ) parent_id = db.Column( db.Integer, db.ForeignKey('categories.categories.id'), index=True, nullable=True ) is_deleted = db.Column( db.Boolean, nullable=False, default=False ) position = db.Column( db.Integer, nullable=False, default=_get_next_position ) visibility = db.Column( db.Integer, nullable=True, default=None ) icon_metadata = db.Column( JSONB, nullable=False, default=lambda: None ) icon = db.deferred(db.Column( db.LargeBinary, nullable=True )) logo_metadata = db.Column( JSONB, nullable=False, default=lambda: None ) logo = db.deferred(db.Column( db.LargeBinary, nullable=True )) timezone = db.Column( db.String, nullable=False, default=lambda: config.DEFAULT_TIMEZONE ) default_event_themes = db.Column( JSONB, nullable=False, default=_get_default_event_themes ) event_creation_mode = db.Column( PyIntEnum(EventCreationMode), nullable=False, default=EventCreationMode.restricted ) event_creation_notification_emails = db.Column( ARRAY(db.String), nullable=False, default=[] ) event_message_mode = db.Column( PyIntEnum(EventMessageMode), nullable=False, default=EventMessageMode.disabled ) _event_message = db.Column( 'event_message', db.Text, nullable=False, default='' ) suggestions_disabled = db.Column( db.Boolean, nullable=False, default=False ) notify_managers = db.Column( db.Boolean, nullable=False, default=False ) is_flat_view_enabled = db.Column( db.Boolean, nullable=False, default=False ) default_ticket_template_id = db.Column( db.ForeignKey('indico.designer_templates.id'), nullable=True, index=True ) default_badge_template_id = db.Column( db.ForeignKey('indico.designer_templates.id'), nullable=True, index=True ) children = db.relationship( 'Category', order_by='Category.position', primaryjoin=(id == db.remote(parent_id)) & ~db.remote(is_deleted), lazy=True, backref=db.backref( 'parent', primaryjoin=(db.remote(id) == parent_id), lazy=True ) ) acl_entries = db.relationship( 'CategoryPrincipal', backref='category', cascade='all, delete-orphan', collection_class=set ) default_ticket_template = db.relationship( 'DesignerTemplate', lazy=True, foreign_keys=default_ticket_template_id, backref='default_ticket_template_of' ) default_badge_template = db.relationship( 'DesignerTemplate', lazy=True, foreign_keys=default_badge_template_id, backref='default_badge_template_of' ) # column properties: # - deep_events_count # relationship backrefs: # - attachment_folders (AttachmentFolder.category) # - designer_templates (DesignerTemplate.category) # - event_move_requests (EventMoveRequest.category) # - events (Event.category) # - favorite_of (User.favorite_categories) # - legacy_mapping (LegacyCategoryMapping.category) # - log_entries (CategoryLogEntry.event) # - parent (Category.children) # - roles (CategoryRole.category) # - settings (CategorySetting.category) # - suggestions (SuggestedCategory.category) @hybrid_property def event_message(self): return MarkdownText(self._event_message) @event_message.setter def event_message(self, value): self._event_message = value @event_message.expression def event_message(cls): return cls._event_message def __repr__(self): return format_repr(self, 'id', is_deleted=False, _text=text_to_repr(self.title, max_length=75)) @property def protection_parent(self): return self.parent if not self.is_root else None @locator_property def locator(self): return {'category_id': self.id} @classmethod def get_root(cls): """Get the root category.""" return cls.query.filter(cls.is_root).one() @property def url(self): return url_for('categories.display', self) @hybrid_property def is_root(self): return self.parent_id is None @is_root.expression def is_root(cls): return cls.parent_id.is_(None) @property def is_empty(self): return not self.deep_children_count and not self.deep_events_count @property def has_icon(self): return self.icon_metadata is not None @property def has_effective_icon(self): return self.effective_icon_data['metadata'] is not None @property def has_logo(self): return self.logo_metadata is not None @property def tzinfo(self): return pytz.timezone(self.timezone) @property def display_tzinfo(self): """The tzinfo of the category or the one specified by the user.""" return get_display_tz(self, as_timezone=True) def log(self, realm, kind, module, summary, user=None, type_='simple', data=None, meta=None): """Create a new log entry for the category. :param realm: A value from :class:`.CategoryLogRealm` indicating the realm of the action. :param kind: A value from :class:`.LogKind` indicating the kind of the action that was performed. :param module: A human-friendly string describing the module related to the action. :param summary: A one-line summary describing the logged action. :param user: The user who performed the action. :param type_: The type of the log entry. This is used for custom rendering of the log message/data :param data: JSON-serializable data specific to the log type. :param meta: JSON-serializable data that won't be displayed. :return: The newly created `EventLogEntry` In most cases the ``simple`` log type is fine. For this type, any items from data will be shown in the detailed view of the log entry. You may either use a dict (which will be sorted) alphabetically or a list of ``key, value`` pairs which will be displayed in the given order. """ entry = CategoryLogEntry(user=user, realm=realm, kind=kind, module=module, type=type_, summary=summary, data=(data or {}), meta=(meta or {})) self.log_entries.append(entry) return entry def can_propose_events(self, user): """Check whether the user can propose move requests to the category.""" return user and ((self.event_creation_mode == EventCreationMode.moderated and self.can_access(user)) or self.can_manage(user, permission='event_move_request')) def can_create_events(self, user): """Check whether the user can create events in the category.""" # if creation is not restricted anyone who can access the category # can also create events in it, otherwise only people with the # creation role can return user and ((self.event_creation_mode == EventCreationMode.open and self.can_access(user)) or self.can_manage(user, permission='create')) def move(self, target: 'Category'): """Move the category into another category.""" assert not self.is_root old_parent = self.parent self.position = (max(x.position for x in target.children) + 1) if target.children else 1 self.parent = target db.session.flush() signals.category.moved.send(self, old_parent=old_parent) sep = ' \N{RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK} ' self.log(CategoryLogRealm.category, LogKind.change, 'Category', 'Category moved', session.user, data={'From': sep.join(old_parent.chain_titles), 'To': sep.join(target.chain_titles)}) old_parent.log(CategoryLogRealm.category, LogKind.negative, 'Content', f'Subcategory moved out: "{self.title}"', session.user, data={'To': sep.join(target.chain_titles)}) target.log(CategoryLogRealm.category, LogKind.positive, 'Content', f'Subcategory moved in: "{self.title}"', session.user, data={'From': sep.join(old_parent.chain_titles)}) @classmethod def get_tree_cte(cls, col='id'): """Create a CTE for the category tree. The CTE contains the following columns: - ``id`` -- the category id - ``path`` -- an array containing the path from the root to the category itself - ``is_deleted`` -- whether the category is deleted :param col: The name of the column to use in the path or a callable receiving the category alias that must return the expression used for the 'path' retrieved by the CTE. """ cat_alias = db.aliased(cls) if callable(col): path_column = col(cat_alias) else: path_column = getattr(cat_alias, col) cte_query = (select([cat_alias.id, array([path_column]).label('path'), cat_alias.is_deleted]) .where(cat_alias.parent_id.is_(None)) .cte(recursive=True)) rec_query = (select([cat_alias.id, cte_query.c.path.op('||')(path_column), cte_query.c.is_deleted | cat_alias.is_deleted]) .where(cat_alias.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) @classmethod def get_subtree_ids_cte(cls, ids): """Create a CTE for a category subtree. This CTE contains a single ``id`` column that contains all the specified IDs and those of all their subcategories. This is likely to be much more performant than `get_tree_cte` when the query is used with LIMIT, especially in large databases. """ cat_alias = db.aliased(cls) cte_query = (select([cat_alias.id]) .where(cat_alias.id.in_(ids)) .cte(recursive=True)) rec_query = (select([cat_alias.id]) .where(cat_alias.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) @classmethod def get_protection_cte(cls): cat_alias = db.aliased(cls) cte_query = (select([cat_alias.id, cat_alias.protection_mode]) .where(cat_alias.parent_id.is_(None)) .cte(recursive=True)) rec_query = (select([cat_alias.id, db.case({ProtectionMode.inheriting.value: cte_query.c.protection_mode}, else_=cat_alias.protection_mode, value=cat_alias.protection_mode)]) .where(cat_alias.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) def get_protection_parent_cte(self): cte_query = (select([Category.id, db.cast(literal(None), db.Integer).label('protection_parent')]) .where(Category.id == self.id) .cte(recursive=True)) rec_query = (select([Category.id, db.case({ProtectionMode.inheriting.value: func.coalesce(cte_query.c.protection_parent, self.id)}, else_=Category.id, value=Category.protection_mode)]) .where(Category.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) @classmethod def get_icon_data_cte(cls): cat_alias = db.aliased(cls) cte_query = (select([cat_alias.id, cat_alias.id.label('source_id'), cat_alias.icon_metadata]) .where(cat_alias.parent_id.is_(None)) .cte(recursive=True)) rec_query = (select([cat_alias.id, db.case({'null': cte_query.c.source_id}, else_=cat_alias.id, value=db.func.jsonb_typeof(cat_alias.icon_metadata)), db.case({'null': cte_query.c.icon_metadata}, else_=cat_alias.icon_metadata, value=db.func.jsonb_typeof(cat_alias.icon_metadata))]) .where(cat_alias.parent_id == cte_query.c.id)) return cte_query.union_all(rec_query) @property def deep_children_query(self): """Get a query object for all subcategories. This includes subcategories at any level of nesting. """ cte = Category.get_tree_cte() return (Category.query .join(cte, Category.id == cte.c.id) .filter(cte.c.path.contains([self.id]), cte.c.id != self.id, ~cte.c.is_deleted)) @staticmethod def _get_chain_query(start_criterion): cte_query = (select([Category.id, Category.parent_id, literal(0).label('level')]) .where(start_criterion) .cte('category_chain', recursive=True)) parent_query = (select([Category.id, Category.parent_id, cte_query.c.level + 1]) .where(Category.id == cte_query.c.parent_id)) cte_query = cte_query.union_all(parent_query) return Category.query.join(cte_query, Category.id == cte_query.c.id).order_by(cte_query.c.level.desc()) @property def chain_query(self): """Get a query object for the category chain. The query retrieves the root category first and then all the intermediate categories up to (and including) this category. """ return self._get_chain_query(Category.id == self.id) @property def parent_chain_query(self): """Get a query object for the category's parent chain. The query retrieves the root category first and then all the intermediate categories up to (excluding) this category. """ return self._get_chain_query(Category.id == self.parent_id) def nth_parent(self, n_categs, fail_on_overflow=True): """Return the nth parent of the category. :param n_categs: the number of categories to go up :param fail_on_overflow: whether to fail if we try to go above the root category :return: `Category` object or None (only if ``fail_on_overflow`` is not set) """ if n_categs == 0: return self chain = self.parent_chain_query.all() assert n_categs >= 0 if n_categs > len(chain): if fail_on_overflow: raise IndexError('Root category has no parent!') else: return None return chain[::-1][n_categs - 1] def is_descendant_of(self, categ): return categ != self and self.parent_chain_query.filter(Category.id == categ.id).has_rows() @property def visibility_horizon_query(self): """Get a query object that returns the highest category this one is visible from.""" cte_query = (select([Category.id, Category.parent_id, db.case([(Category.visibility.is_(None), None)], else_=(Category.visibility - 1)).label('n'), literal(0).label('level')]) .where(Category.id == self.id) .cte('visibility_horizon', recursive=True)) parent_query = (select([Category.id, Category.parent_id, db.case([(Category.visibility.is_(None) & cte_query.c.n.is_(None), None)], else_=db.func.least(Category.visibility, cte_query.c.n) - 1), cte_query.c.level + 1]) .where(db.and_(Category.id == cte_query.c.parent_id, (cte_query.c.n > 0) | cte_query.c.n.is_(None)))) cte_query = cte_query.union_all(parent_query) return db.session.query(cte_query.c.id, cte_query.c.n).order_by(cte_query.c.level.desc()).limit(1) @property def own_visibility_horizon(self): """ Get the highest category this one would like to be visible from (configured visibility). """ if self.visibility is None: return Category.get_root() else: return self.nth_parent(self.visibility - 1) @property def real_visibility_horizon(self): """ Get the highest category this one is actually visible from (as limited by categories above). """ horizon_id, final_visibility = self.visibility_horizon_query.one() if final_visibility is not None and final_visibility < 0: return None # Category is invisible return Category.get(horizon_id) @staticmethod def get_visible_categories_cte(category_id): """ Get a sqlalchemy select for the visible categories within the given category, including the category itself. """ cte_query = (select([Category.id, literal(0).label('level')]) .where((Category.id == category_id) & (Category.visibility.is_(None) | (Category.visibility > 0))) .cte(recursive=True)) parent_query = (select([Category.id, cte_query.c.level + 1]) .where(db.and_(Category.parent_id == cte_query.c.id, db.or_(Category.visibility.is_(None), Category.visibility > cte_query.c.level + 1)))) return cte_query.union_all(parent_query) @property def visible_categories_query(self): """ Get a query object for the visible categories within this category, including the category itself. """ cte_query = Category.get_visible_categories_cte(self.id) return Category.query.join(cte_query, Category.id == cte_query.c.id) def get_hidden_events(self, user=None): """Get all hidden events within the given category and user.""" from indico.modules.events import Event hidden_events = Event.query.with_parent(self).filter_by(visibility=0).all() return [event for event in hidden_events if not event.can_display(user)] @property def icon_url(self): """Get the HTTP URL of the icon.""" return url_for('categories.display_icon', self, slug=self.icon_metadata['hash']) @property def effective_icon_url(self): """Get the HTTP URL of the icon (possibly inherited).""" data = self.effective_icon_data return url_for('categories.display_icon', category_id=data['source_id'], slug=data['metadata']['hash']) @property def logo_url(self): """Get the HTTP URL of the logo.""" return url_for('categories.display_logo', self, slug=self.logo_metadata['hash'])