Пример #1
0
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)
Пример #2
0
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'])
Пример #3
0
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
Пример #4
0
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)
Пример #5
0
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)
Пример #6
0
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 ''
Пример #7
0
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'])