class TimetableEntry(db.Model):
    __tablename__ = 'timetable_entries'

    @declared_attr
    def __table_args__(cls):
        return (db.Index('ix_timetable_entries_start_dt_desc',
                         cls.start_dt.desc()),
                _make_check(TimetableEntryType.SESSION_BLOCK,
                            'session_block_id'),
                _make_check(TimetableEntryType.CONTRIBUTION,
                            'contribution_id'),
                _make_check(TimetableEntryType.BREAK, 'break_id'),
                db.CheckConstraint(
                    "type != {} OR parent_id IS NULL".format(
                        TimetableEntryType.SESSION_BLOCK), 'valid_parent'), {
                            'schema': 'events'
                        })

    id = db.Column(db.Integer, primary_key=True)
    event_id = db.Column(db.Integer,
                         db.ForeignKey('events.events.id'),
                         index=True,
                         nullable=False)
    parent_id = db.Column(
        db.Integer,
        db.ForeignKey('events.timetable_entries.id'),
        index=True,
        nullable=True,
    )
    session_block_id = db.Column(db.Integer,
                                 db.ForeignKey('events.session_blocks.id'),
                                 index=True,
                                 unique=True,
                                 nullable=True)
    contribution_id = db.Column(db.Integer,
                                db.ForeignKey('events.contributions.id'),
                                index=True,
                                unique=True,
                                nullable=True)
    break_id = db.Column(db.Integer,
                         db.ForeignKey('events.breaks.id'),
                         index=True,
                         unique=True,
                         nullable=True)
    type = db.Column(PyIntEnum(TimetableEntryType), nullable=False)
    start_dt = db.Column(UTCDateTime, nullable=False)

    event = db.relationship('Event',
                            lazy=True,
                            backref=db.backref(
                                'timetable_entries',
                                order_by=lambda: TimetableEntry.start_dt,
                                cascade='all, delete-orphan',
                                lazy='dynamic'))
    session_block = db.relationship('SessionBlock',
                                    lazy=False,
                                    backref=db.backref(
                                        'timetable_entry',
                                        cascade='all, delete-orphan',
                                        uselist=False,
                                        lazy=True))
    contribution = db.relationship('Contribution',
                                   lazy=False,
                                   backref=db.backref(
                                       'timetable_entry',
                                       cascade='all, delete-orphan',
                                       uselist=False,
                                       lazy=True))
    break_ = db.relationship('Break',
                             cascade='all, delete-orphan',
                             single_parent=True,
                             lazy=False,
                             backref=db.backref('timetable_entry',
                                                cascade='all, delete-orphan',
                                                uselist=False,
                                                lazy=True))
    children = db.relationship('TimetableEntry',
                               order_by='TimetableEntry.start_dt',
                               lazy=True,
                               backref=db.backref('parent',
                                                  remote_side=[id],
                                                  lazy=True))

    # relationship backrefs:
    # - parent (TimetableEntry.children)

    @property
    def object(self):
        if self.type == TimetableEntryType.SESSION_BLOCK:
            return self.session_block
        elif self.type == TimetableEntryType.CONTRIBUTION:
            return self.contribution
        elif self.type == TimetableEntryType.BREAK:
            return self.break_

    @object.setter
    def object(self, value):
        from fossir.modules.events.contributions import Contribution
        from fossir.modules.events.sessions.models.blocks import SessionBlock
        from fossir.modules.events.timetable.models.breaks import Break
        self.session_block = self.contribution = self.break_ = None
        if isinstance(value, SessionBlock):
            self.session_block = value
        elif isinstance(value, Contribution):
            self.contribution = value
        elif isinstance(value, Break):
            self.break_ = value
        elif value is not None:
            raise TypeError('Unexpected object: {}'.format(value))

    @hybrid_property
    def duration(self):
        return self.object.duration if self.object is not None else None

    @duration.setter
    def duration(self, value):
        self.object.duration = value

    @duration.expression
    def duration(cls):
        from fossir.modules.events.contributions import Contribution
        from fossir.modules.events.sessions.models.blocks import SessionBlock
        from fossir.modules.events.timetable.models.breaks import Break
        return db.case(
            {
                TimetableEntryType.SESSION_BLOCK.value:
                db.select([SessionBlock.duration]).where(
                    SessionBlock.id == cls.session_block_id).correlate_except(
                        SessionBlock).as_scalar(),
                TimetableEntryType.CONTRIBUTION.value:
                db.select([Contribution.duration]).where(
                    Contribution.id == cls.contribution_id).correlate_except(
                        Contribution).as_scalar(),
                TimetableEntryType.BREAK.value:
                db.select([Break.duration]).where(Break.id == cls.break_id).
                correlate_except(Break).as_scalar(),
            },
            value=cls.type)

    @hybrid_property
    def end_dt(self):
        if self.start_dt is None or self.duration is None:
            return None
        return self.start_dt + self.duration

    @end_dt.expression
    def end_dt(cls):
        return cls.start_dt + cls.duration

    @property
    def session_siblings(self):
        if self.type == TimetableEntryType.SESSION_BLOCK:
            return [
                x for x in self.siblings if x.session_block
                and x.session_block.session == self.session_block.session
            ]
        elif self.parent:
            return self.siblings
        else:
            return []

    @property
    def siblings(self):
        from fossir.modules.events.timetable.util import get_top_level_entries, get_nested_entries
        tzinfo = self.event.tzinfo
        day = self.start_dt.astimezone(tzinfo).date()
        siblings = (get_nested_entries(self.event)[self.parent_id]
                    if self.parent_id else get_top_level_entries(self.event))
        return [
            x for x in siblings
            if x.start_dt.astimezone(tzinfo).date() == day and x.id != self.id
        ]

    @property
    def siblings_query(self):
        tzinfo = self.event.tzinfo
        day = self.start_dt.astimezone(tzinfo).date()
        criteria = (TimetableEntry.id != self.id,
                    TimetableEntry.parent == self.parent,
                    db.cast(TimetableEntry.start_dt.astimezone(tzinfo),
                            db.Date) == day)
        return TimetableEntry.query.with_parent(self.event).filter(*criteria)

    @locator_property
    def locator(self):
        return dict(self.event.locator, entry_id=self.id)

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           'type',
                           'start_dt',
                           'end_dt',
                           _repr=self.object)

    def can_view(self, user):
        """Checks whether the user will see this entry in the timetable."""
        if self.type in (TimetableEntryType.CONTRIBUTION,
                         TimetableEntryType.BREAK):
            return self.object.can_access(user)
        elif self.type == TimetableEntryType.SESSION_BLOCK:
            if self.object.can_access(user):
                return True
            return any(x.can_access(user) for x in self.object.contributions)

    def extend_start_dt(self, start_dt):
        assert start_dt < self.start_dt
        extension = self.start_dt - start_dt
        self.start_dt = start_dt
        self.duration = self.duration + extension

    def extend_end_dt(self, end_dt):
        diff = end_dt - self.end_dt
        if diff < timedelta(0):
            raise ValueError("New end_dt is before current end_dt.")
        self.duration += diff

    def extend_parent(self, by_start=True, by_end=True):
        """Extend start/end of parent objects if needed.

        No extension if performed for entries crossing a day boundary in the
        event timezone.

        :param by_start: Extend parent by start datetime.
        :param by_end: Extend parent by end datetime.
        """
        tzinfo = self.event.tzinfo
        if self.start_dt.astimezone(tzinfo).date() != self.end_dt.astimezone(
                tzinfo).date():
            return
        if self.parent is None:
            if by_start and self.start_dt < self.event.start_dt:
                self.event.start_dt = self.start_dt
            if by_end and self.end_dt > self.event.end_dt:
                self.event.end_dt = self.end_dt
        else:
            extended = False
            if by_start and self.start_dt < self.parent.start_dt:
                self.parent.extend_start_dt(self.start_dt)
                extended = True
            if by_end and self.end_dt > self.parent.end_dt:
                self.parent.extend_end_dt(self.end_dt)
                extended = True
            if extended:
                self.parent.extend_parent(by_start=by_start, by_end=by_end)

    def is_parallel(self, in_session=False):
        siblings = self.siblings if not in_session else self.session_siblings
        for sibling in siblings:
            if overlaps((self.start_dt, self.end_dt),
                        (sibling.start_dt, sibling.end_dt)):
                return True
        return False

    def move(self, start_dt):
        """Move the entry to start at a different time.

        This method automatically moves children of the entry to
        preserve their start time relative to the parent's start time.
        """
        if self.type == TimetableEntryType.SESSION_BLOCK:
            diff = start_dt - self.start_dt
            for child in self.children:
                child.start_dt += diff
        self.start_dt = start_dt

    def move_next_to(self, sibling, position='before'):
        if sibling not in self.siblings:
            raise ValueError("Not a sibling")
        if position not in ('before', 'after'):
            raise ValueError("Invalid position")
        if position == 'before':
            start_dt = sibling.start_dt - self.duration
        else:
            start_dt = sibling.end_dt
        self.move(start_dt)
class BlockedRoom(db.Model):
    __tablename__ = 'blocked_rooms'
    __table_args__ = {'schema': 'roombooking'}

    State = BlockedRoomState  # make it available here for convenience

    id = db.Column(db.Integer, primary_key=True)
    state = db.Column(PyIntEnum(BlockedRoomState),
                      nullable=False,
                      default=BlockedRoomState.pending)
    rejected_by = db.Column(db.String)
    rejection_reason = db.Column(db.String)
    blocking_id = db.Column(db.Integer,
                            db.ForeignKey('roombooking.blockings.id'),
                            nullable=False)
    room_id = db.Column(db.Integer,
                        db.ForeignKey('roombooking.rooms.id'),
                        nullable=False,
                        index=True)

    # relationship backrefs:
    # - blocking (Blocking.blocked_rooms)
    # - room (Room.blocked_rooms)

    @property
    def state_name(self):
        return BlockedRoomState(self.state).title

    @classmethod
    def find_with_filters(cls, filters):
        q = cls.find(_eager=BlockedRoom.blocking, _join=BlockedRoom.blocking)
        if filters.get('room_ids'):
            q = q.filter(BlockedRoom.room_id.in_(filters['room_ids']))
        if filters.get('start_date') and filters.get('end_date'):
            q = q.filter(Blocking.start_date <= filters['end_date'],
                         Blocking.end_date >= filters['start_date'])
        if 'state' in filters:
            q = q.filter(BlockedRoom.state == filters['state'])
        return q

    def reject(self, user=None, reason=None):
        """Reject the room blocking."""
        self.state = BlockedRoomState.rejected
        if reason:
            self.rejection_reason = reason
        if user:
            self.rejected_by = user.full_name
        notify_request_response(self)

    def approve(self, notify_blocker=True):
        """Approve the room blocking, rejecting all colliding reservations/occurrences."""
        self.state = BlockedRoomState.accepted

        # Get colliding reservations
        start_dt = datetime.combine(self.blocking.start_date, time())
        end_dt = datetime.combine(self.blocking.end_date, time(23, 59, 59))

        reservation_criteria = [
            Reservation.room_id == self.room_id, ~Reservation.is_rejected,
            ~Reservation.is_cancelled
        ]

        # Whole reservations to reject
        reservations = Reservation.find_all(Reservation.start_dt >= start_dt,
                                            Reservation.end_dt <= end_dt,
                                            *reservation_criteria)

        # Single occurrences to reject
        occurrences = ReservationOccurrence.find_all(
            ReservationOccurrence.start_dt >= start_dt,
            ReservationOccurrence.end_dt <= end_dt,
            ReservationOccurrence.is_valid,
            ~ReservationOccurrence.reservation_id.in_(
                map(attrgetter('id'), reservations)) if reservations else True,
            *reservation_criteria,
            _join=Reservation)

        reason = 'Conflict with blocking {}: {}'.format(
            self.blocking.id, self.blocking.reason)

        for reservation in reservations:
            if self.blocking.can_be_overridden(reservation.created_by_user,
                                               reservation.room):
                continue
            reservation.reject(self.blocking.created_by_user, reason)

        for occurrence in occurrences:
            reservation = occurrence.reservation
            if self.blocking.can_be_overridden(reservation.created_by_user,
                                               reservation.room):
                continue
            occurrence.reject(self.blocking.created_by_user, reason)

        if notify_blocker:
            # We only need to notify the blocking creator if the blocked room wasn't approved yet.
            # This is the case if it's a new blocking for a room managed by the creator
            notify_request_response(self)

    @return_ascii
    def __repr__(self):
        return '<BlockedRoom({0}, {1}, {2})>'.format(self.blocking_id,
                                                     self.room_id,
                                                     self.state_name)
class Abstract(ProposalMixin, ProposalRevisionMixin, DescriptionMixin,
               CustomFieldsMixin, AuthorsSpeakersMixin, db.Model):
    """Represents an abstract that can be associated to a Contribution."""

    __tablename__ = 'abstracts'
    __auto_table_args = (
        db.Index(None,
                 'friendly_id',
                 'event_id',
                 unique=True,
                 postgresql_where=db.text('NOT is_deleted')),
        db.CheckConstraint(
            '(state = {}) OR (accepted_track_id IS NULL)'.format(
                AbstractState.accepted),
            name='accepted_track_id_only_accepted'),
        db.CheckConstraint(
            '(state = {}) OR (accepted_contrib_type_id IS NULL)'.format(
                AbstractState.accepted),
            name='accepted_contrib_type_id_only_accepted'),
        db.CheckConstraint(
            '(state = {}) = (merged_into_id IS NOT NULL)'.format(
                AbstractState.merged),
            name='merged_into_id_only_merged'),
        db.CheckConstraint(
            '(state = {}) = (duplicate_of_id IS NOT NULL)'.format(
                AbstractState.duplicate),
            name='duplicate_of_id_only_duplicate'),
        db.CheckConstraint(
            '(state IN ({}, {}, {}, {})) = (judge_id IS NOT NULL)'.format(
                AbstractState.accepted, AbstractState.rejected,
                AbstractState.merged, AbstractState.duplicate),
            name='judge_if_judged'),
        db.CheckConstraint(
            '(state IN ({}, {}, {}, {})) = (judgment_dt IS NOT NULL)'.format(
                AbstractState.accepted, AbstractState.rejected,
                AbstractState.merged, AbstractState.duplicate),
            name='judgment_dt_if_judged'), {
                'schema': 'event_abstracts'
            })

    possible_render_modes = {RenderMode.markdown}
    default_render_mode = RenderMode.markdown
    marshmallow_aliases = {'_description': 'content'}

    # Proposal mixin properties
    proposal_type = 'abstract'
    call_for_proposals_attr = 'cfa'
    delete_comment_endpoint = 'abstracts.delete_abstract_comment'
    create_comment_endpoint = 'abstracts.comment_abstract'
    edit_comment_endpoint = 'abstracts.edit_abstract_comment'
    create_review_endpoint = 'abstracts.review_abstract'
    edit_review_endpoint = 'abstracts.edit_review'
    create_judgment_endpoint = 'abstracts.judge_abstract'
    revisions_enabled = False

    @declared_attr
    def __table_args__(cls):
        return auto_table_args(cls)

    id = db.Column(db.Integer, primary_key=True)
    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)
    title = db.Column(db.String, nullable=False)
    #: ID of the user who submitted the abstract
    submitter_id = db.Column(db.Integer,
                             db.ForeignKey('users.users.id'),
                             index=True,
                             nullable=False)
    submitted_contrib_type_id = db.Column(db.Integer,
                                          db.ForeignKey(
                                              'events.contribution_types.id',
                                              ondelete='SET NULL'),
                                          nullable=True,
                                          index=True)
    submitted_dt = db.Column(UTCDateTime, nullable=False, default=now_utc)
    modified_by_id = db.Column(db.Integer,
                               db.ForeignKey('users.users.id'),
                               nullable=True,
                               index=True)
    modified_dt = db.Column(
        UTCDateTime,
        nullable=True,
    )
    state = db.Column(PyIntEnum(AbstractState),
                      nullable=False,
                      default=AbstractState.submitted)
    submission_comment = db.Column(db.Text, nullable=False, default='')
    #: ID of the user who judged the abstract
    judge_id = db.Column(db.Integer,
                         db.ForeignKey('users.users.id'),
                         index=True,
                         nullable=True)
    _judgment_comment = db.Column('judgment_comment',
                                  db.Text,
                                  nullable=False,
                                  default='')
    judgment_dt = db.Column(
        UTCDateTime,
        nullable=True,
    )
    accepted_track_id = db.Column(db.Integer,
                                  db.ForeignKey('events.tracks.id',
                                                ondelete='SET NULL'),
                                  nullable=True,
                                  index=True)
    accepted_contrib_type_id = db.Column(db.Integer,
                                         db.ForeignKey(
                                             'events.contribution_types.id',
                                             ondelete='SET NULL'),
                                         nullable=True,
                                         index=True)
    merged_into_id = db.Column(db.Integer,
                               db.ForeignKey('event_abstracts.abstracts.id'),
                               index=True,
                               nullable=True)
    duplicate_of_id = db.Column(db.Integer,
                                db.ForeignKey('event_abstracts.abstracts.id'),
                                index=True,
                                nullable=True)
    is_deleted = db.Column(db.Boolean, nullable=False, default=False)
    event = db.relationship(
        'Event',
        lazy=True,
        backref=db.backref(
            'abstracts',
            primaryjoin=
            '(Abstract.event_id == Event.id) & ~Abstract.is_deleted',
            cascade='all, delete-orphan',
            lazy=True))
    #: User who submitted the abstract
    submitter = db.relationship(
        'User',
        lazy=True,
        foreign_keys=submitter_id,
        backref=db.backref(
            'abstracts',
            primaryjoin=
            '(Abstract.submitter_id == User.id) & ~Abstract.is_deleted',
            lazy='dynamic'))
    modified_by = db.relationship(
        'User',
        lazy=True,
        foreign_keys=modified_by_id,
        backref=db.backref(
            'modified_abstracts',
            primaryjoin=
            '(Abstract.modified_by_id == User.id) & ~Abstract.is_deleted',
            lazy='dynamic'))
    submitted_contrib_type = db.relationship(
        'ContributionType',
        lazy=True,
        foreign_keys=submitted_contrib_type_id,
        backref=db.backref(
            'proposed_abstracts',
            primaryjoin=
            '(Abstract.submitted_contrib_type_id == ContributionType.id) & ~Abstract.is_deleted',
            lazy=True,
            passive_deletes=True))
    submitted_for_tracks = db.relationship(
        'Track',
        secondary='event_abstracts.submitted_for_tracks',
        collection_class=set,
        backref=db.backref(
            'abstracts_submitted',
            primaryjoin=
            'event_abstracts.submitted_for_tracks.c.track_id == Track.id',
            secondaryjoin=
            '(event_abstracts.submitted_for_tracks.c.abstract_id == Abstract.id) & ~Abstract.is_deleted',
            collection_class=set,
            lazy=True,
            passive_deletes=True))
    reviewed_for_tracks = db.relationship(
        'Track',
        secondary='event_abstracts.reviewed_for_tracks',
        collection_class=set,
        backref=db.backref(
            'abstracts_reviewed',
            primaryjoin=
            'event_abstracts.reviewed_for_tracks.c.track_id == Track.id',
            secondaryjoin=
            '(event_abstracts.reviewed_for_tracks.c.abstract_id == Abstract.id) & ~Abstract.is_deleted',
            collection_class=set,
            lazy=True,
            passive_deletes=True))
    #: User who judged the abstract
    judge = db.relationship(
        'User',
        lazy=True,
        foreign_keys=judge_id,
        backref=db.backref(
            'judged_abstracts',
            primaryjoin='(Abstract.judge_id == User.id) & ~Abstract.is_deleted',
            lazy='dynamic'))
    accepted_track = db.relationship(
        'Track',
        lazy=True,
        backref=db.backref(
            'abstracts_accepted',
            primaryjoin=
            '(Abstract.accepted_track_id == Track.id) & ~Abstract.is_deleted',
            lazy=True,
            passive_deletes=True))
    accepted_contrib_type = db.relationship(
        'ContributionType',
        lazy=True,
        foreign_keys=accepted_contrib_type_id,
        backref=db.backref(
            'abstracts_accepted',
            primaryjoin=
            '(Abstract.accepted_contrib_type_id == ContributionType.id) & ~Abstract.is_deleted',
            lazy=True,
            passive_deletes=True))
    merged_into = db.relationship(
        'Abstract',
        lazy=True,
        remote_side=id,
        foreign_keys=merged_into_id,
        backref=db.backref('merged_abstracts',
                           primaryjoin=(db.remote(merged_into_id) == id)
                           & ~db.remote(is_deleted),
                           lazy=True))
    duplicate_of = db.relationship(
        'Abstract',
        lazy=True,
        remote_side=id,
        foreign_keys=duplicate_of_id,
        backref=db.backref('duplicate_abstracts',
                           primaryjoin=(db.remote(duplicate_of_id) == id)
                           & ~db.remote(is_deleted),
                           lazy=True))
    #: Data stored in abstract/contribution fields
    field_values = db.relationship('AbstractFieldValue',
                                   lazy=True,
                                   cascade='all, delete-orphan',
                                   backref=db.backref('abstract', lazy=True))
    #: Persons associated with this abstract
    person_links = db.relationship('AbstractPersonLink',
                                   lazy=True,
                                   cascade='all, delete-orphan',
                                   order_by='AbstractPersonLink.display_order',
                                   backref=db.backref('abstract', lazy=True))

    # relationship backrefs:
    # - comments (AbstractComment.abstract)
    # - contribution (Contribution.abstract)
    # - duplicate_abstracts (Abstract.duplicate_of)
    # - email_logs (AbstractEmailLogEntry.abstract)
    # - files (AbstractFile.abstract)
    # - merged_abstracts (Abstract.merged_into)
    # - proposed_related_abstract_reviews (AbstractReview.proposed_related_abstract)
    # - reviews (AbstractReview.abstract)

    @property
    def candidate_contrib_types(self):
        contrib_types = set()
        for track in self.reviewed_for_tracks:
            if self.get_track_reviewing_state(
                    track) == AbstractReviewingState.positive:
                review = next((x for x in self.reviews if x.track == track),
                              None)
                contrib_types.add(review.proposed_contribution_type)
        return contrib_types

    @property
    def candidate_tracks(self):
        states = {
            AbstractReviewingState.positive, AbstractReviewingState.conflicting
        }
        return {
            t
            for t in self.reviewed_for_tracks
            if self.get_track_reviewing_state(t) in states
        }

    @property
    def edit_track_mode(self):
        if not inspect(self).persistent:
            return EditTrackMode.both
        elif self.state not in {
                AbstractState.submitted, AbstractState.withdrawn
        }:
            return EditTrackMode.none
        elif (self.public_state
              in (AbstractPublicState.awaiting, AbstractPublicState.withdrawn)
              and self.reviewed_for_tracks == self.submitted_for_tracks):
            return EditTrackMode.both
        else:
            return EditTrackMode.reviewed_for

    @property
    def public_state(self):
        if self.state != AbstractState.submitted:
            return getattr(AbstractPublicState, self.state.name)
        elif self.reviews:
            return AbstractPublicState.under_review
        else:
            return AbstractPublicState.awaiting

    @property
    def reviewing_state(self):
        if not self.reviews:
            return AbstractReviewingState.not_started
        track_states = {
            x: self.get_track_reviewing_state(x)
            for x in self.reviewed_for_tracks
        }
        positiveish_states = {
            AbstractReviewingState.positive, AbstractReviewingState.conflicting
        }
        if any(x == AbstractReviewingState.not_started
               for x in track_states.itervalues()):
            return AbstractReviewingState.in_progress
        elif all(x == AbstractReviewingState.negative
                 for x in track_states.itervalues()):
            return AbstractReviewingState.negative
        elif all(x in positiveish_states for x in track_states.itervalues()):
            if len(self.reviewed_for_tracks) > 1:
                # Accepted for more than one track
                return AbstractReviewingState.conflicting
            elif any(x == AbstractReviewingState.conflicting
                     for x in track_states.itervalues()):
                # The only accepted track is in conflicting state
                return AbstractReviewingState.conflicting
            else:
                return AbstractReviewingState.positive
        else:
            return AbstractReviewingState.mixed

    @property
    def score(self):
        scores = [x.score for x in self.reviews if x.score is not None]
        if not scores:
            return None
        return sum(scores) / len(scores)

    @property
    def data_by_field(self):
        return {
            value.contribution_field_id: value
            for value in self.field_values
        }

    @locator_property
    def locator(self):
        return dict(self.event.locator, abstract_id=self.id)

    @hybrid_property
    def judgment_comment(self):
        return MarkdownText(self._judgment_comment)

    @judgment_comment.setter
    def judgment_comment(self, value):
        self._judgment_comment = value

    @judgment_comment.expression
    def judgment_comment(cls):
        return cls._judgment_comment

    @property
    def verbose_title(self):
        return '#{} ({})'.format(self.friendly_id, self.title)

    @property
    def is_in_final_state(self):
        return self.state != AbstractState.submitted

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           'event_id',
                           is_deleted=False,
                           _text=text_to_repr(self.title))

    def can_access(self, user):
        if not user:
            return False
        if self.submitter == user:
            return True
        if self.event.can_manage(user):
            return True
        if any(x.person.user == user for x in self.person_links):
            return True
        return self.can_judge(user) or self.can_convene(
            user) or self.can_review(user)

    def can_comment(self, user, check_state=False):
        if not user:
            return False
        if check_state and self.is_in_final_state:
            return False
        if not self.event.cfa.allow_comments:
            return False
        if self.user_owns(
                user) and self.event.cfa.allow_contributors_in_comments:
            return True
        return self.can_judge(user) or self.can_convene(
            user) or self.can_review(user)

    def can_convene(self, user):
        if not user:
            return False
        elif not self.event.can_manage(
                user, role='track_convener', explicit_role=True):
            return False
        elif self.event in user.global_convener_for_events:
            return True
        elif user.convener_for_tracks & self.reviewed_for_tracks:
            return True
        else:
            return False

    def can_review(self, user, check_state=False):
        # The total number of tracks/events a user is a reviewer for (fossir-wide)
        # is usually reasonably low so we just access the relationships instead of
        # sending a more specific query which would need to be cached to avoid
        # repeating it when performing this check on many abstracts.
        if not user:
            return False
        elif check_state and self.public_state not in (
                AbstractPublicState.under_review,
                AbstractPublicState.awaiting):
            return False
        elif not self.event.can_manage(
                user, role='abstract_reviewer', explicit_role=True):
            return False
        elif self.event in user.global_abstract_reviewer_for_events:
            return True
        elif user.abstract_reviewer_for_tracks & self.reviewed_for_tracks:
            return True
        else:
            return False

    def can_judge(self, user, check_state=False):
        if not user:
            return False
        elif check_state and self.state != AbstractState.submitted:
            return False
        elif self.event.can_manage(user):
            return True
        elif self.event.cfa.allow_convener_judgment and self.can_convene(user):
            return True
        else:
            return False

    def can_edit(self, user):
        if not user:
            return False
        is_manager = self.event.can_manage(user)
        if not self.user_owns(user) and not is_manager:
            return False
        elif is_manager and self.public_state in (
                AbstractPublicState.under_review,
                AbstractPublicState.withdrawn):
            return True
        elif (self.public_state == AbstractPublicState.awaiting
              and (is_manager or self.event.cfa.can_edit_abstracts(user))):
            return True
        else:
            return False

    def can_withdraw(self, user, check_state=False):
        if not user:
            return False
        elif self.event.can_manage(user) and (
                not check_state or self.state != AbstractState.withdrawn):
            return True
        elif user == self.submitter and (not check_state or self.state
                                         == AbstractState.submitted):
            return True
        else:
            return False

    def can_see_reviews(self, user):
        return self.can_judge(user) or self.can_convene(user)

    def get_timeline(self, user=None):
        comments = [x for x in self.comments
                    if x.can_view(user)] if user else self.comments
        reviews = [x for x in self.reviews
                   if x.can_view(user)] if user else self.reviews
        return sorted(chain(comments, reviews), key=attrgetter('created_dt'))

    def get_track_reviewing_state(self, track):
        if track not in self.reviewed_for_tracks:
            raise ValueError("Abstract not in review for given track")
        reviews = self.get_reviews(group=track)
        if not reviews:
            return AbstractReviewingState.not_started
        rejections = any(x.proposed_action == AbstractAction.reject
                         for x in reviews)
        acceptances = {
            x
            for x in reviews if x.proposed_action == AbstractAction.accept
        }
        if rejections and not acceptances:
            return AbstractReviewingState.negative
        elif acceptances and not rejections:
            proposed_contrib_types = {
                x.proposed_contribution_type
                for x in acceptances
                if x.proposed_contribution_type is not None
            }
            if len(proposed_contrib_types) <= 1:
                return AbstractReviewingState.positive
            else:
                return AbstractReviewingState.conflicting
        else:
            return AbstractReviewingState.mixed

    def get_track_question_scores(self):
        query = (db.session.query(
            AbstractReview.track_id, AbstractReviewQuestion,
            db.func.avg(AbstractReviewRating.value)).join(
                AbstractReviewRating.review).join(
                    AbstractReviewRating.question).filter(
                        AbstractReview.abstract == self,
                        ~AbstractReviewQuestion.is_deleted,
                        ~AbstractReviewQuestion.no_score).group_by(
                            AbstractReview.track_id,
                            AbstractReviewQuestion.id))
        scores = defaultdict(lambda: defaultdict(lambda: None))
        for track_id, question, score in query:
            scores[track_id][question] = score
        return scores

    def get_reviewed_for_groups(self, user, include_reviewed=False):
        already_reviewed = {
            each.track
            for each in self.get_reviews(user=user)
        } if include_reviewed else set()
        if self.event in user.global_abstract_reviewer_for_events:
            return self.reviewed_for_tracks | already_reviewed
        return (self.reviewed_for_tracks
                & user.abstract_reviewer_for_tracks) | already_reviewed

    def get_track_score(self, track):
        if track not in self.reviewed_for_tracks:
            raise ValueError("Abstract not in review for given track")
        reviews = [x for x in self.reviews if x.track == track]
        scores = [x.score for x in reviews if x.score is not None]
        if not scores:
            return None
        return sum(scores) / len(scores)

    def reset_state(self):
        self.state = AbstractState.submitted
        self.judgment_comment = ''
        self.judge = None
        self.judgment_dt = None
        self.accepted_track = None
        self.accepted_contrib_type = None
        self.merged_into = None
        self.duplicate_of = None

    def user_owns(self, user):
        if not user:
            return None
        return user == self.submitter or any(x.person.user == user
                                             for x in self.person_links)
Beispiel #4
0
class RegistrationForm(db.Model):
    """A registration form for an event"""

    __tablename__ = 'forms'
    __table_args__ = (
        db.Index(
            'ix_uq_forms_participation',
            'event_id',
            unique=True,
            postgresql_where=db.text('is_participation AND NOT is_deleted')),
        db.UniqueConstraint(
            'id', 'event_id'),  # useless but needed for the registrations fkey
        {
            'schema': 'event_registration'
        })

    #: The ID of the object
    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 registration form
    title = db.Column(db.String, nullable=False)
    #: Whether it's the 'Participants' form of a meeting/lecture
    is_participation = db.Column(db.Boolean, nullable=False, default=False)
    # An introduction text for users
    introduction = db.Column(db.Text, nullable=False, default='')
    #: Contact information for registrants
    contact_info = db.Column(db.String, nullable=False, default='')
    #: Datetime when the registration form is open
    start_dt = db.Column(UTCDateTime, nullable=True)
    #: Datetime when the registration form is closed
    end_dt = db.Column(UTCDateTime, nullable=True)
    #: Whether registration modifications are allowed
    modification_mode = db.Column(PyIntEnum(ModificationMode),
                                  nullable=False,
                                  default=ModificationMode.not_allowed)
    #: Datetime when the modification period is over
    modification_end_dt = db.Column(UTCDateTime, nullable=True)
    #: Whether the registration has been marked as deleted
    is_deleted = db.Column(db.Boolean, nullable=False, default=False)
    #: Whether users must be logged in to register
    require_login = db.Column(db.Boolean, nullable=False, default=False)
    #: Whether registrations must be associated with an fossir account
    require_user = db.Column(db.Boolean, nullable=False, default=False)
    #: Maximum number of registrations allowed
    registration_limit = db.Column(db.Integer, nullable=True)
    #: Whether registrations should be displayed in the participant list
    publish_registrations_enabled = db.Column(db.Boolean,
                                              nullable=False,
                                              default=False)
    #: Whether to display the number of registrations
    publish_registration_count = db.Column(db.Boolean,
                                           nullable=False,
                                           default=False)
    #: Whether checked-in status should be displayed in the event pages and participant list
    publish_checkin_enabled = db.Column(db.Boolean,
                                        nullable=False,
                                        default=False)
    #: Whether registrations must be approved by a manager
    moderation_enabled = db.Column(db.Boolean, nullable=False, default=False)
    #: The base fee users have to pay when registering
    base_price = db.Column(
        db.Numeric(8, 2),  # max. 999999.99
        nullable=False,
        default=0)
    #: Currency for prices in the registration form
    currency = db.Column(db.String, nullable=False)
    #: Notifications sender address
    notification_sender_address = db.Column(db.String, nullable=True)
    #: Custom message to include in emails for pending registrations
    message_pending = db.Column(db.Text, nullable=False, default='')
    #: Custom message to include in emails for unpaid registrations
    message_unpaid = db.Column(db.Text, nullable=False, default='')
    #: Custom message to include in emails for complete registrations
    message_complete = db.Column(db.Text, nullable=False, default='')
    #: Whether the manager notifications for this event are enabled
    manager_notifications_enabled = db.Column(db.Boolean,
                                              nullable=False,
                                              default=False)
    #: List of emails that should receive management notifications
    manager_notification_recipients = db.Column(ARRAY(db.String),
                                                nullable=False,
                                                default=[])
    #: Whether tickets are enabled for this form
    tickets_enabled = db.Column(db.Boolean, nullable=False, default=False)
    #: Whether to send tickets by e-mail
    ticket_on_email = db.Column(db.Boolean, nullable=False, default=True)
    #: Whether to show a ticket download link on the event homepage
    ticket_on_event_page = db.Column(db.Boolean, nullable=False, default=True)
    #: Whether to show a ticket download link on the registration summary page
    ticket_on_summary_page = db.Column(db.Boolean,
                                       nullable=False,
                                       default=True)
    #: The ID of the template used to generate tickets
    ticket_template_id = db.Column(db.Integer,
                                   db.ForeignKey(DesignerTemplate.id),
                                   nullable=True,
                                   index=True)

    #: The Event containing this registration form
    event = db.relationship(
        'Event',
        lazy=True,
        backref=db.backref(
            'registration_forms',
            primaryjoin=
            '(RegistrationForm.event_id == Event.id) & ~RegistrationForm.is_deleted',
            cascade='all, delete-orphan',
            lazy=True))
    #: The template used to generate tickets
    ticket_template = db.relationship('DesignerTemplate',
                                      lazy=True,
                                      foreign_keys=ticket_template_id,
                                      backref=db.backref('ticket_for_regforms',
                                                         lazy=True))
    # The items (sections, text, fields) in the form
    form_items = db.relationship('RegistrationFormItem',
                                 lazy=True,
                                 cascade='all, delete-orphan',
                                 order_by='RegistrationFormItem.position',
                                 backref=db.backref('registration_form',
                                                    lazy=True))
    #: The registrations associated with this form
    registrations = db.relationship(
        'Registration',
        lazy=True,
        cascade='all, delete-orphan',
        foreign_keys=[Registration.registration_form_id],
        backref=db.backref('registration_form', lazy=True))
    #: The registration invitations associated with this form
    invitations = db.relationship('RegistrationInvitation',
                                  lazy=True,
                                  cascade='all, delete-orphan',
                                  backref=db.backref('registration_form',
                                                     lazy=True))

    @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())

    @hybrid_property
    def is_modification_open(self):
        end_dt = self.modification_end_dt if self.modification_end_dt else self.end_dt
        return now_utc() <= end_dt if end_dt else True

    @is_modification_open.expression
    def is_modification_open(self):
        now = now_utc()
        return now <= db.func.coalesce(self.modification_end_dt, self.end_dt,
                                       now)

    @hybrid_property
    def is_open(self):
        return not self.is_deleted and self.has_started and not self.has_ended

    @is_open.expression
    def is_open(cls):
        return ~cls.is_deleted & cls.has_started & ~cls.has_ended

    @hybrid_property
    def is_scheduled(self):
        return not self.is_deleted and self.start_dt is not None

    @is_scheduled.expression
    def is_scheduled(cls):
        return ~cls.is_deleted & cls.start_dt.isnot(None)

    @property
    def locator(self):
        return dict(self.event.locator, reg_form_id=self.id)

    @property
    def active_fields(self):
        return [
            field for field in self.form_items
            if (field.is_field and field.is_enabled and not field.is_deleted
                and field.parent.is_enabled and not field.parent.is_deleted)
        ]

    @property
    def sections(self):
        return [x for x in self.form_items if x.is_section]

    @property
    def disabled_sections(self):
        return [
            x for x in self.sections if not x.is_visible and not x.is_deleted
        ]

    @property
    def limit_reached(self):
        return self.registration_limit and len(
            self.active_registrations) >= self.registration_limit

    @property
    def is_active(self):
        return self.is_open and not self.limit_reached

    @property
    @memoize_request
    def active_registrations(self):
        return (Registration.query.with_parent(self).filter(
            Registration.is_active).options(subqueryload('data')).all())

    @property
    def sender_address(self):
        contact_email = self.event.contact_emails[
            0] if self.event.contact_emails else None
        return self.notification_sender_address or contact_email

    @return_ascii
    def __repr__(self):
        return '<RegistrationForm({}, {}, {})>'.format(self.id, self.event_id,
                                                       self.title)

    def is_modification_allowed(self, registration):
        """Checks whether a registration may be modified"""
        if not registration.is_active:
            return False
        elif self.modification_mode == ModificationMode.allowed_always:
            return True
        elif self.modification_mode == ModificationMode.allowed_until_payment:
            return not registration.is_paid
        else:
            return False

    def can_submit(self, user):
        return self.is_active and (not self.require_login or user)

    @memoize_request
    def get_registration(self, user=None, uuid=None, email=None):
        """Retrieves registrations for this registration form by user or uuid"""
        if (bool(user) + bool(uuid) + bool(email)) != 1:
            raise ValueError(
                "Exactly one of `user`, `uuid` and `email` must be specified")
        if user:
            return user.registrations.filter_by(registration_form=self).filter(
                Registration.is_active).first()
        if uuid:
            try:
                UUID(hex=uuid)
            except ValueError:
                raise BadRequest('Malformed registration token')
            return Registration.query.with_parent(self).filter_by(
                uuid=uuid).filter(Registration.is_active).first()
        if email:
            return Registration.query.with_parent(self).filter_by(
                email=email).filter(Registration.is_active).first()

    def render_base_price(self):
        return format_currency(self.base_price,
                               self.currency,
                               locale=session.lang or 'en_GB')

    def get_personal_data_field_id(self, personal_data_type):
        """Returns the field id corresponding to the personal data field with the given name."""
        for field in self.active_fields:
            if (isinstance(field, RegistrationFormPersonalDataField)
                    and field.personal_data_type == personal_data_type):
                return field.id
class Agreement(db.Model):
    """Agreements between a person and fossir"""
    __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(JSON)

    #: 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 fossir.modules.events.agreements.util import get_agreement_definitions
        return get_agreement_definitions().get(self.type)

    @property
    def locator(self):
        return {'confId': self.event_id, 'id': self.id}

    @return_ascii
    def __repr__(self):
        state = self.state.name if self.state is not None else None
        return '<Agreement({}, {}, {}, {}, {}, {})>'.format(
            self.id, self.event_id, self.type, self.identifier,
            self.person_email, state)

    @staticmethod
    def create_from_data(event, type_, person):
        agreement = Agreement(event=event,
                              type=type_,
                              state=AgreementState.pending,
                              uuid=str(uuid4()))
        agreement.identifier = person.identifier
        agreement.person_email = person.email
        agreement.person_name = person.name
        if person.user:
            agreement.user = person.user
        agreement.data = person.data
        return agreement

    def accept(self, from_ip, reason=None, on_behalf=False):
        self.state = AgreementState.accepted if not on_behalf else AgreementState.accepted_on_behalf
        self.signed_from_ip = from_ip
        self.reason = reason
        self.signed_dt = now_utc()
        self.definition.handle_accepted(self)

    def reject(self, from_ip, reason=None, on_behalf=False):
        self.state = AgreementState.rejected if not on_behalf else AgreementState.rejected_on_behalf
        self.signed_from_ip = from_ip
        self.reason = reason
        self.signed_dt = now_utc()
        self.definition.handle_rejected(self)

    def reset(self):
        self.definition.handle_reset(self)
        self.state = AgreementState.pending
        self.attachment = None
        self.attachment_filename = None
        self.reason = None
        self.signed_dt = None
        self.signed_from_ip = None

    def render(self, form, **kwargs):
        definition = self.definition
        if definition is None:
            raise ServiceUnavailable(
                'This agreement type is currently not available.')
        return definition.render_form(self, form, **kwargs)

    def belongs_to(self, person):
        return self.identifier == person.identifier

    def is_orphan(self):
        definition = self.definition
        if definition is None:
            raise ServiceUnavailable(
                'This agreement type is currently not available.')
        return definition.is_agreement_orphan(self.event, self)
class PaymentTransaction(db.Model):
    """Payment transactions"""
    __tablename__ = 'payment_transactions'
    __table_args__ = (db.CheckConstraint('amount > 0', 'positive_amount'),
                      {'schema': 'events'})

    #: Entry ID
    id = db.Column(
        db.Integer,
        primary_key=True
    )
    #: ID of the associated registration
    registration_id = db.Column(
        db.Integer,
        db.ForeignKey('event_registration.registrations.id'),
        index=True,
        nullable=False
    )
    #: a :class:`TransactionStatus`
    status = db.Column(
        PyIntEnum(TransactionStatus),
        nullable=False
    )
    #: the base amount the user needs to pay (without payment-specific fees)
    amount = db.Column(
        db.Numeric(8, 2),  # max. 999999.99
        nullable=False
    )
    #: the currency of the payment (ISO string, e.g. EUR or USD)
    currency = db.Column(
        db.String,
        nullable=False
    )
    #: the provider of the payment (e.g. manual, PayPal etc.)
    provider = db.Column(
        db.String,
        nullable=False,
        default='_manual'
    )
    #: the date and time the transaction was recorded
    timestamp = db.Column(
        UTCDateTime,
        default=now_utc,
        nullable=False
    )
    #: plugin-specific data of the payment
    data = db.Column(
        JSON,
        nullable=False
    )

    #: The associated registration
    registration = db.relationship(
        'Registration',
        lazy=True,
        foreign_keys=[registration_id],
        backref=db.backref(
            'transactions',
            cascade='all, delete-orphan',
            lazy=True
        )
    )

    @property
    def plugin(self):
        from fossir.modules.events.payment.util import get_payment_plugins
        return get_payment_plugins().get(self.provider)

    @property
    def is_manual(self):
        return self.provider == '_manual'

    @return_ascii
    def __repr__(self):
        # in case of a new object we might not have the default status set
        status = TransactionStatus(self.status).name if self.status is not None else None
        return format_repr(self, 'id', 'registration_id', 'provider', 'amount', 'currency', 'timestamp', status=status)

    def render_details(self):
        """Renders the transaction details"""
        if self.is_manual:
            return render_template('events/payment/transaction_details_manual.html', transaction=self)
        plugin = self.plugin
        if plugin is None:
            return '[plugin not loaded: {}]'.format(self.provider)
        with plugin.plugin_context():
            return plugin.render_transaction_details(self)

    @classmethod
    def create_next(cls, registration, amount, currency, action, provider=None, data=None):
        previous_transaction = registration.transaction
        new_transaction = PaymentTransaction(amount=amount, currency=currency,
                                             provider=provider, data=data)
        registration.transaction = new_transaction
        try:
            next_status = TransactionStatusTransition.next(previous_transaction, action, provider)
        except InvalidTransactionStatus as e:
            Logger.get('payment').exception("%s (data received: %r)", e, data)
            return None
        except InvalidManualTransactionAction as e:
            Logger.get('payment').exception("Invalid manual action code '%s' on initial status (data received: %r)",
                                            e, data)
            return None
        except InvalidTransactionAction as e:
            Logger.get('payment').exception("Invalid action code '%s' on initial status (data received: %r)", e, data)
            return None
        except IgnoredTransactionAction as e:
            Logger.get('payment').warning("%s (data received: %r)", e, data)
            return None
        except DoublePaymentTransaction:
            next_status = TransactionStatus.successful
            Logger.get('payment').info("Received successful payment for an already paid registration")
        new_transaction.status = next_status
        return new_transaction
Beispiel #7
0
class Registration(db.Model):
    """Somebody's registration for an event through a registration form"""
    __tablename__ = 'registrations'
    __table_args__ = (db.CheckConstraint('email = lower(email)', 'lowercase_email'),
                      db.Index(None, 'friendly_id', 'event_id', unique=True,
                               postgresql_where=db.text('NOT is_deleted')),
                      db.Index(None, 'registration_form_id', 'user_id', unique=True,
                               postgresql_where=db.text('NOT is_deleted AND (state NOT IN (3, 4))')),
                      db.Index(None, 'registration_form_id', 'email', unique=True,
                               postgresql_where=db.text('NOT is_deleted AND (state NOT IN (3, 4))')),
                      db.ForeignKeyConstraint(['event_id', 'registration_form_id'],
                                              ['event_registration.forms.event_id', 'event_registration.forms.id']),
                      {'schema': 'event_registration'})

    #: The ID of the object
    id = db.Column(
        db.Integer,
        primary_key=True
    )
    #: The unguessable ID for the object
    uuid = db.Column(
        UUID,
        index=True,
        unique=True,
        nullable=False,
        default=lambda: unicode(uuid4())
    )
    #: The human-friendly ID for the object
    friendly_id = db.Column(
        db.Integer,
        nullable=False,
        default=_get_next_friendly_id
    )
    #: The ID of the event
    event_id = db.Column(
        db.Integer,
        db.ForeignKey('events.events.id'),
        index=True,
        nullable=False
    )
    #: The ID of the registration form
    registration_form_id = db.Column(
        db.Integer,
        db.ForeignKey('event_registration.forms.id'),
        index=True,
        nullable=False
    )
    #: The ID of the user who registered
    user_id = db.Column(
        db.Integer,
        db.ForeignKey('users.users.id'),
        index=True,
        nullable=True
    )
    #: The ID of the latest payment transaction associated with this registration
    transaction_id = db.Column(
        db.Integer,
        db.ForeignKey('events.payment_transactions.id'),
        index=True,
        unique=True,
        nullable=True
    )
    #: The state a registration is in
    state = db.Column(
        PyIntEnum(RegistrationState),
        nullable=False,
    )
    #: The base registration fee (that is not specific to form items)
    base_price = db.Column(
        db.Numeric(8, 2),  # max. 999999.99
        nullable=False,
        default=0
    )
    #: The price modifier applied to the final calculated price
    price_adjustment = db.Column(
        db.Numeric(8, 2),  # max. 999999.99
        nullable=False,
        default=0
    )
    #: Registration price currency
    currency = db.Column(
        db.String,
        nullable=False
    )
    #: The date/time when the registration was recorded
    submitted_dt = db.Column(
        UTCDateTime,
        nullable=False,
        default=now_utc,
    )
    #: The email of the registrant
    email = db.Column(
        db.String,
        nullable=False
    )
    #: The first name of the registrant
    first_name = db.Column(
        db.String,
        nullable=False
    )
    #: The last name of the registrant
    last_name = db.Column(
        db.String,
        nullable=False
    )
    #: If the registration has been deleted
    is_deleted = db.Column(
        db.Boolean,
        nullable=False,
        default=False
    )
    #: The unique token used in tickets
    ticket_uuid = db.Column(
        UUID,
        index=True,
        unique=True,
        nullable=False,
        default=lambda: unicode(uuid4())
    )
    #: Whether the person has checked in. Setting this also sets or clears
    #: `checked_in_dt`.
    checked_in = db.Column(
        db.Boolean,
        nullable=False,
        default=False
    )
    #: The date/time when the person has checked in
    checked_in_dt = db.Column(
        UTCDateTime,
        nullable=True
    )

    #: The Event containing this registration
    event = db.relationship(
        'Event',
        lazy=True,
        backref=db.backref(
            'registrations',
            lazy='dynamic'
        )
    )
    # The user linked to this registration
    user = db.relationship(
        'User',
        lazy=True,
        backref=db.backref(
            'registrations',
            lazy='dynamic'
            # XXX: a delete-orphan cascade here would delete registrations when NULLing the user
        )
    )
    #: The latest payment transaction associated with this registration
    transaction = db.relationship(
        'PaymentTransaction',
        lazy=True,
        foreign_keys=[transaction_id],
        post_update=True
    )
    #: The registration this data is associated with
    data = db.relationship(
        'RegistrationData',
        lazy=True,
        cascade='all, delete-orphan',
        backref=db.backref(
            'registration',
            lazy=True
        )
    )

    # relationship backrefs:
    # - invitation (RegistrationInvitation.registration)
    # - legacy_mapping (LegacyRegistrationMapping.registration)
    # - registration_form (RegistrationForm.registrations)
    # - transactions (PaymentTransaction.registration)

    @classmethod
    def get_all_for_event(cls, event):
        """Retrieve all registrations in all registration forms of an event."""
        from fossir.modules.events.registration.models.forms import RegistrationForm
        return Registration.find_all(Registration.is_active, ~RegistrationForm.is_deleted,
                                     RegistrationForm.event_id == event.id, _join=Registration.registration_form)

    @hybrid_property
    def is_active(self):
        return not self.is_cancelled and not self.is_deleted

    @is_active.expression
    def is_active(cls):
        return ~cls.is_cancelled & ~cls.is_deleted

    @hybrid_property
    def is_cancelled(self):
        return self.state in (RegistrationState.rejected, RegistrationState.withdrawn)

    @is_cancelled.expression
    def is_cancelled(self):
        return self.state.in_((RegistrationState.rejected, RegistrationState.withdrawn))

    @locator_property
    def locator(self):
        return dict(self.registration_form.locator, registration_id=self.id)

    @locator.registrant
    def locator(self):
        """A locator suitable for 'display' pages.

        It includes the UUID of the registration unless the current
        request doesn't contain the uuid and the registration is tied
        to the currently logged-in user.
        """
        loc = self.registration_form.locator
        if (not self.user or not has_request_context() or self.user != session.user or
                request.args.get('token') == self.uuid):
            loc['token'] = self.uuid
        return loc

    @locator.uuid
    def locator(self):
        """A locator that uses uuid instead of id"""
        return dict(self.registration_form.locator, token=self.uuid)

    @property
    def can_be_modified(self):
        regform = self.registration_form
        return regform.is_modification_open and regform.is_modification_allowed(self)

    @property
    def data_by_field(self):
        return {x.field_data.field_id: x for x in self.data}

    @property
    def billable_data(self):
        return [data for data in self.data if data.price]

    @property
    def full_name(self):
        """Returns the user's name in 'Firstname Lastname' notation."""
        return self.get_full_name(last_name_first=False)

    @property
    def display_full_name(self):
        """Return the full name using the user's preferred name format."""
        return format_display_full_name(session.user, self)

    @property
    def is_ticket_blocked(self):
        """Check whether the ticket is blocked by a plugin"""
        return any(values_from_signal(signals.event.is_ticket_blocked.send(self), single_value=True))

    @property
    def is_paid(self):
        """Returns whether the registration has been paid for."""
        paid_states = {TransactionStatus.successful, TransactionStatus.pending}
        return self.transaction is not None and self.transaction.status in paid_states

    @property
    def payment_dt(self):
        """The date/time when the registration has been paid for"""
        return self.transaction.timestamp if self.is_paid else None

    @property
    def price(self):
        """The total price of the registration.

        This includes the base price, the field-specific price, and
        the custom price adjustment for the registrant.

        :rtype: Decimal
        """
        # we convert the calculated price (float) to a string to avoid this:
        # >>> Decimal(100.1)
        # Decimal('100.099999999999994315658113919198513031005859375')
        # >>> Decimal('100.1')
        # Decimal('100.1')
        calc_price = Decimal(str(sum(data.price for data in self.data)))
        base_price = self.base_price or Decimal('0')
        price_adjustment = self.price_adjustment or Decimal('0')
        return (base_price + price_adjustment + calc_price).max(0)

    @property
    def summary_data(self):
        """Export registration data nested in sections and fields"""

        def _fill_from_regform():
            for section in self.registration_form.sections:
                if not section.is_visible:
                    continue
                summary[section] = OrderedDict()
                for field in section.fields:
                    if not field.is_visible:
                        continue
                    summary[section][field] = field_summary[field]

        def _fill_from_registration():
            for field, data in field_summary.iteritems():
                section = field.parent
                summary.setdefault(section, OrderedDict())
                if field not in summary[section]:
                    summary[section][field] = data

        summary = OrderedDict()
        field_summary = {x.field_data.field: x for x in self.data}
        _fill_from_regform()
        _fill_from_registration()
        return summary

    @property
    def has_files(self):
        return any(item.storage_file_id is not None for item in self.data)

    @property
    def sections_with_answered_fields(self):
        return [x for x in self.registration_form.sections
                if any(child.id in self.data_by_field for child in x.children)]

    @classproperty
    @classmethod
    def order_by_name(cls):
        return db.func.lower(cls.last_name), db.func.lower(cls.first_name), cls.friendly_id

    @return_ascii
    def __repr__(self):
        return format_repr(self, 'id', 'registration_form_id', 'email', 'state',
                           user_id=None, is_deleted=False, _text=self.full_name)

    def get_full_name(self, last_name_first=True, last_name_upper=False, abbrev_first_name=False):
        """Returns the user's in the specified notation.

        If not format options are specified, the name is returned in
        the 'Lastname, Firstname' notation.

        Note: Do not use positional arguments when calling this method.
        Always use keyword arguments!

        :param last_name_first: if "lastname, firstname" instead of
                                "firstname lastname" should be used
        :param last_name_upper: if the last name should be all-uppercase
        :param abbrev_first_name: if the first name should be abbreviated to
                                  use only the first character
        """
        return format_full_name(self.first_name, self.last_name,
                                last_name_first=last_name_first, last_name_upper=last_name_upper,
                                abbrev_first_name=abbrev_first_name)

    def get_personal_data(self):
        personal_data = {}
        for data in self.data:
            field = data.field_data.field
            if field.personal_data_type is not None and data.data:
                personal_data[field.personal_data_type.name] = data.friendly_data
        # might happen with imported legacy registrations (missing personal data)
        personal_data.setdefault('first_name', self.first_name)
        personal_data.setdefault('last_name', self.last_name)
        personal_data.setdefault('email', self.email)
        return personal_data

    def _render_price(self, price):
        return format_currency(price, self.currency, locale=session.lang or 'en_GB')

    def render_price(self):
        return self._render_price(self.price)

    def render_base_price(self):
        return self._render_price(self.base_price)

    def render_price_adjustment(self):
        return self._render_price(self.price_adjustment)

    def sync_state(self, _skip_moderation=True):
        """Sync the state of the registration"""
        initial_state = self.state
        regform = self.registration_form
        invitation = self.invitation
        moderation_required = (regform.moderation_enabled and not _skip_moderation and
                               (not invitation or not invitation.skip_moderation))
        with db.session.no_autoflush:
            payment_required = regform.event.has_feature('payment') and self.price and not self.is_paid
        if self.state is None:
            if moderation_required:
                self.state = RegistrationState.pending
            elif payment_required:
                self.state = RegistrationState.unpaid
            else:
                self.state = RegistrationState.complete
        elif self.state == RegistrationState.unpaid:
            if not self.price:
                self.state = RegistrationState.complete
        elif self.state == RegistrationState.complete:
            if payment_required:
                self.state = RegistrationState.unpaid
        if self.state != initial_state:
            signals.event.registration_state_updated.send(self, previous_state=initial_state)

    def update_state(self, approved=None, paid=None, rejected=None, _skip_moderation=False):
        """Update the state of the registration for a given action

        The accepted kwargs are the possible actions. ``True`` means that the
        action occured and ``False`` that it was reverted.
        """
        if sum(action is not None for action in (approved, paid, rejected)) > 1:
            raise Exception("More than one action specified")
        initial_state = self.state
        regform = self.registration_form
        invitation = self.invitation
        moderation_required = (regform.moderation_enabled and not _skip_moderation and
                               (not invitation or not invitation.skip_moderation))
        with db.session.no_autoflush:
            payment_required = regform.event.has_feature('payment') and self.price
        if self.state == RegistrationState.pending:
            if approved and payment_required:
                self.state = RegistrationState.unpaid
            elif approved:
                self.state = RegistrationState.complete
            elif rejected:
                self.state = RegistrationState.rejected
        elif self.state == RegistrationState.unpaid:
            if paid:
                self.state = RegistrationState.complete
            elif approved is False:
                self.state = RegistrationState.pending
        elif self.state == RegistrationState.complete:
            if approved is False and payment_required is False and moderation_required:
                self.state = RegistrationState.pending
            elif paid is False and payment_required:
                self.state = RegistrationState.unpaid
        if self.state != initial_state:
            signals.event.registration_state_updated.send(self, previous_state=initial_state)
Beispiel #8
0
class Request(db.Model):
    """Event-related requests, e.g. for a webcast"""
    __tablename__ = 'requests'
    __table_args__ = {'schema': 'events'}

    #: request ID
    id = db.Column(db.Integer, primary_key=True)
    #: ID of the event
    event_id = db.Column(db.Integer,
                         db.ForeignKey('events.events.id'),
                         index=True,
                         nullable=False)
    #: the request type name
    type = db.Column(db.String, nullable=False)
    #: the requests's date, a :class:`RequestState` value
    state = db.Column(PyIntEnum(RequestState),
                      nullable=False,
                      default=RequestState.pending)
    #: plugin-specific data of the payment
    data = db.Column(JSON, nullable=False)
    #: ID of the user creating the request
    created_by_id = db.Column(db.Integer,
                              db.ForeignKey('users.users.id'),
                              index=True,
                              nullable=False)
    #: the date/time the request was created
    created_dt = db.Column(UTCDateTime,
                           default=now_utc,
                           index=True,
                           nullable=False)
    #: ID of the user processing the request
    processed_by_id = db.Column(db.Integer,
                                db.ForeignKey('users.users.id'),
                                index=True,
                                nullable=True)
    #: the date/time the request was accepted/rejected
    processed_dt = db.Column(UTCDateTime, nullable=True)
    #: an optional comment for an accepted/rejected request
    comment = db.Column(db.Text, nullable=True)

    #: The user who created the request
    created_by_user = db.relationship('User',
                                      lazy=True,
                                      foreign_keys=[created_by_id],
                                      backref=db.backref('requests_created',
                                                         lazy='dynamic'))
    #: The user who processed the request
    processed_by_user = db.relationship('User',
                                        lazy=True,
                                        foreign_keys=[processed_by_id],
                                        backref=db.backref(
                                            'requests_processed',
                                            lazy='dynamic'))
    #: The Event this agreement is associated with
    event = db.relationship('Event',
                            lazy=True,
                            backref=db.backref('requests', lazy='dynamic'))

    @property
    def definition(self):
        return get_request_definitions().get(self.type)

    @definition.setter
    def definition(self, definition):
        assert self.type is None
        self.type = definition.name

    @property
    def can_be_modified(self):
        """Determines if the request can be modified or if a new one must be sent"""
        return self.state in {RequestState.pending, RequestState.accepted}

    @property
    def locator(self):
        return {'confId': self.event_id, 'type': self.type}

    @return_ascii
    def __repr__(self):
        state = self.state.name if self.state is not None else None
        return '<Request({}, {}, {}, {})>'.format(self.id, self.event_id,
                                                  self.type, state)

    @classmethod
    def find_latest_for_event(cls, event, type_=None):
        """Returns the latest requests for a given event.

        :param event: the event to find the requests for
        :param type_: the request type to retrieve, or `None` to get all
        :return: a dict mapping request types to a :class:`Request`
                 or if `type_` was specified, a single :class:`Request` or `None`
        """
        query = Request.query.with_parent(event)
        if type_ is not None:
            return (query.filter_by(type=type_).order_by(
                cls.created_dt.desc()).first())
        else:
            query = limit_groups(query, cls, cls.type, cls.created_dt.desc(),
                                 1)
            return {req.type: req for req in query}
 def protection_mode(cls):
     return db.Column(
         PyIntEnum(ProtectionMode, exclude_values=cls.disallowed_protection_modes),
         nullable=False,
         default=ProtectionMode.inheriting
     )
Beispiel #10
0
class SurveyItem(db.Model):
    __tablename__ = 'items'
    __table_args__ = (db.CheckConstraint(
        "type != {type} OR ("
        "title IS NOT NULL AND "
        "is_required IS NOT NULL AND "
        "field_type IS NOT NULL AND "
        "parent_id IS NOT NULL AND "
        "display_as_section IS NULL)".format(type=SurveyItemType.question),
        'valid_question'),
                      db.CheckConstraint(
                          "type != {type} OR ("
                          "title IS NOT NULL AND "
                          "is_required IS NULL AND "
                          "field_type IS NULL AND "
                          "field_data::text = '{{}}' AND "
                          "parent_id IS NULL AND "
                          "display_as_section IS NOT NULL)".format(
                              type=SurveyItemType.section), 'valid_section'),
                      db.CheckConstraint(
                          "type != {type} OR ("
                          "title IS NULL AND "
                          "is_required IS NULL AND "
                          "field_type IS NULL AND "
                          "field_data::text = '{{}}' AND "
                          "parent_id IS NOT NULL AND "
                          "display_as_section IS NULL)".format(
                              type=SurveyItemType.text), 'valid_text'), {
                                  'schema': 'event_surveys'
                              })
    __mapper_args__ = {'polymorphic_on': 'type', 'polymorphic_identity': None}

    #: The ID of the item
    id = db.Column(db.Integer, primary_key=True)
    #: The ID of the survey
    survey_id = db.Column(
        db.Integer,
        db.ForeignKey('event_surveys.surveys.id'),
        index=True,
        nullable=False,
    )
    #: The ID of the parent section item (NULL for top-level items, i.e. sections)
    parent_id = db.Column(
        db.Integer,
        db.ForeignKey('event_surveys.items.id'),
        index=True,
        nullable=True,
    )
    #: The position of the item in the survey form
    position = db.Column(db.Integer,
                         nullable=False,
                         default=_get_next_position)
    #: The type of the survey item
    type = db.Column(PyIntEnum(SurveyItemType), nullable=False)
    #: The title of the item
    title = db.Column(db.String,
                      nullable=True,
                      default=_get_item_default_title)
    #: The description of the item
    description = db.Column(db.Text, nullable=False, default='')
    #: If a section should be rendered as a section
    display_as_section = db.Column(db.Boolean, nullable=True)

    # The following columns are only used for SurveyQuestion objects, but by
    # specifying them here we can access them withouy an extra query when we
    # query SurveyItem objects directly instead of going through a subclass.
    # This is done e.g. when using the Survey.top_level_items relationship.

    #: If the question must be answered (wtforms DataRequired)
    is_required = db.Column(db.Boolean, nullable=True)
    #: The type of the field used for the question
    field_type = db.Column(db.String, nullable=True)
    #: Field-specific data (such as choices for multi-select fields)
    field_data = db.Column(JSON, nullable=False, default={})

    # relationship backrefs:
    # - parent (SurveySection.children)
    # - survey (Survey.items)

    def to_dict(self):
        """Return a json-serializable representation of this object.

        Subclasses must add their own data to the dict.
        """
        return {
            'type': self.type.name,
            'title': self.title,
            'description': self.description
        }
class PaperRevision(ProposalRevisionMixin, RenderModeMixin, db.Model):
    __tablename__ = 'revisions'
    __table_args__ = (
        db.Index(None,
                 'contribution_id',
                 unique=True,
                 postgresql_where=db.text('state = {}'.format(
                     PaperRevisionState.accepted))),
        db.UniqueConstraint('contribution_id', 'submitted_dt'),
        db.CheckConstraint(
            '(state IN ({}, {}, {})) = (judge_id IS NOT NULL)'.format(
                PaperRevisionState.accepted, PaperRevisionState.rejected,
                PaperRevisionState.to_be_corrected),
            name='judge_if_judged'),
        db.CheckConstraint(
            '(state IN ({}, {}, {})) = (judgment_dt IS NOT NULL)'.format(
                PaperRevisionState.accepted, PaperRevisionState.rejected,
                PaperRevisionState.to_be_corrected),
            name='judgment_dt_if_judged'), {
                'schema': 'event_paper_reviewing'
            })

    possible_render_modes = {RenderMode.markdown}
    default_render_mode = RenderMode.markdown
    proposal_attr = 'paper'

    id = db.Column(db.Integer, primary_key=True)
    state = db.Column(PyIntEnum(PaperRevisionState),
                      nullable=False,
                      default=PaperRevisionState.submitted)
    _contribution_id = db.Column('contribution_id',
                                 db.Integer,
                                 db.ForeignKey('events.contributions.id'),
                                 index=True,
                                 nullable=False)
    submitter_id = db.Column(db.Integer,
                             db.ForeignKey('users.users.id'),
                             index=True,
                             nullable=False)
    submitted_dt = db.Column(UTCDateTime, nullable=False, default=now_utc)
    judge_id = db.Column(db.Integer,
                         db.ForeignKey('users.users.id'),
                         index=True,
                         nullable=True)
    judgment_dt = db.Column(UTCDateTime, nullable=True)
    _judgment_comment = db.Column('judgment_comment',
                                  db.Text,
                                  nullable=False,
                                  default='')

    _contribution = db.relationship('Contribution',
                                    lazy=True,
                                    backref=db.backref(
                                        '_paper_revisions',
                                        lazy=True,
                                        order_by=submitted_dt.asc()))
    submitter = db.relationship('User',
                                lazy=True,
                                foreign_keys=submitter_id,
                                backref=db.backref('paper_revisions',
                                                   lazy='dynamic'))
    judge = db.relationship('User',
                            lazy=True,
                            foreign_keys=judge_id,
                            backref=db.backref('judged_papers',
                                               lazy='dynamic'))

    judgment_comment = RenderModeMixin.create_hybrid_property(
        '_judgment_comment')

    # relationship backrefs:
    # - comments (PaperReviewComment.paper_revision)
    # - files (PaperFile.paper_revision)
    # - reviews (PaperReview.revision)

    def __init__(self, *args, **kwargs):
        paper = kwargs.pop('paper', None)
        if paper:
            kwargs.setdefault('_contribution', paper.contribution)
        super(PaperRevision, self).__init__(*args, **kwargs)

    @return_ascii
    def __repr__(self):
        return format_repr(self, 'id', '_contribution_id', state=None)

    @locator_property
    def locator(self):
        return dict(self.paper.locator, revision_id=self.id)

    @property
    def paper(self):
        return self._contribution.paper

    @property
    def is_last_revision(self):
        return self == self.paper.last_revision

    @property
    def number(self):
        return self.paper.revisions.index(self) + 1

    @paper.setter
    def paper(self, paper):
        self._contribution = paper.contribution

    def get_timeline(self, user=None):
        comments = [x for x in self.comments
                    if x.can_view(user)] if user else self.comments
        reviews = [x for x in self.reviews
                   if x.can_view(user)] if user else self.reviews
        judgment = [
            PaperJudgmentProxy(self)
        ] if self.state == PaperRevisionState.to_be_corrected else []
        return sorted(chain(comments, reviews, judgment),
                      key=attrgetter('created_dt'))

    def get_reviews(self, group=None, user=None):
        reviews = []
        if user and group:
            reviews = [
                x for x in self.reviews
                if x.group.instance == group and x.user == user
            ]
        elif user:
            reviews = [x for x in self.reviews if x.user == user]
        elif group:
            reviews = [x for x in self.reviews if x.group.instance == group]
        return reviews

    def get_reviewed_for_groups(self, user, include_reviewed=False):
        from fossir.modules.events.papers.models.reviews import PaperTypeProxy
        reviewed_for = {x.type
                        for x in self.reviews
                        if x.user == user} if include_reviewed else set()
        if self.paper.cfp.content_reviewing_enabled and user in self.paper.cfp.content_reviewers:
            reviewed_for.add(PaperReviewType.content)
        if self.paper.cfp.layout_reviewing_enabled and user in self.paper.cfp.layout_reviewers:
            reviewed_for.add(PaperReviewType.layout)
        return set(map(PaperTypeProxy, reviewed_for))

    def has_user_reviewed(self, user, review_type=None):
        from fossir.modules.events.papers.models.reviews import PaperReviewType
        if review_type:
            if isinstance(review_type, basestring):
                review_type = PaperReviewType[review_type]
            return any(review.user == user and review.type == review_type
                       for review in self.reviews)
        else:
            layout_review = next(
                (review for review in self.reviews if review.user == user
                 and review.type == PaperReviewType.layout), None)
            content_review = next(
                (review for review in self.reviews if review.user == user
                 and review.type == PaperReviewType.content), None)
            if user in self._contribution.paper_layout_reviewers and user in self._contribution.paper_content_reviewers:
                return bool(layout_review and content_review)
            elif user in self._contribution.paper_layout_reviewers:
                return bool(layout_review)
            elif user in self._contribution.paper_content_reviewers:
                return bool(content_review)

    def get_spotlight_file(self):
        pdf_files = [
            paper_file for paper_file in self.files
            if paper_file.content_type == 'application/pdf'
        ]
        return pdf_files[0] if len(pdf_files) == 1 else None
class AbstractReview(ProposalReviewMixin, RenderModeMixin, db.Model):
    """Represents an abstract review, emitted by a reviewer"""

    possible_render_modes = {RenderMode.markdown}
    default_render_mode = RenderMode.markdown

    revision_attr = 'abstract'
    group_attr = 'track'

    marshmallow_aliases = {'_comment': 'comment'}

    __tablename__ = 'abstract_reviews'
    __table_args__ = (
        db.UniqueConstraint('abstract_id', 'user_id', 'track_id'),
        db.CheckConstraint(
            "proposed_action = {} OR (proposed_contribution_type_id IS NULL)".
            format(AbstractAction.accept),
            name='prop_contrib_id_only_accepted'),
        db.CheckConstraint(
            "(proposed_action IN ({}, {})) = (proposed_related_abstract_id IS NOT NULL)"
            .format(AbstractAction.mark_as_duplicate, AbstractAction.merge),
            name='prop_abstract_id_only_duplicate_merge'), {
                'schema': 'event_abstracts'
            })

    id = db.Column(db.Integer, primary_key=True)
    abstract_id = db.Column(db.Integer,
                            db.ForeignKey('event_abstracts.abstracts.id'),
                            index=True,
                            nullable=False)
    user_id = db.Column(db.Integer,
                        db.ForeignKey('users.users.id'),
                        index=True,
                        nullable=False)
    track_id = db.Column(db.Integer,
                         db.ForeignKey('events.tracks.id'),
                         index=True,
                         nullable=True)
    created_dt = db.Column(
        UTCDateTime,
        nullable=False,
        default=now_utc,
    )
    modified_dt = db.Column(UTCDateTime, nullable=True)
    _comment = db.Column('comment', db.Text, nullable=False, default='')
    proposed_action = db.Column(PyIntEnum(AbstractAction), nullable=False)
    proposed_related_abstract_id = db.Column(
        db.Integer,
        db.ForeignKey('event_abstracts.abstracts.id'),
        index=True,
        nullable=True)
    proposed_contribution_type_id = db.Column(
        db.Integer,
        db.ForeignKey('events.contribution_types.id'),
        nullable=True,
        index=True)
    abstract = db.relationship('Abstract',
                               lazy=True,
                               foreign_keys=abstract_id,
                               backref=db.backref('reviews',
                                                  cascade='all, delete-orphan',
                                                  lazy=True))
    user = db.relationship('User',
                           lazy=True,
                           backref=db.backref('abstract_reviews',
                                              lazy='dynamic'))
    track = db.relationship('Track',
                            lazy=True,
                            foreign_keys=track_id,
                            backref=db.backref('abstract_reviews',
                                               lazy='dynamic'))
    proposed_related_abstract = db.relationship(
        'Abstract',
        lazy=True,
        foreign_keys=proposed_related_abstract_id,
        backref=db.backref('proposed_related_abstract_reviews',
                           lazy='dynamic'))
    proposed_tracks = db.relationship(
        'Track',
        secondary='event_abstracts.proposed_for_tracks',
        lazy=True,
        collection_class=set,
        backref=db.backref('proposed_abstract_reviews',
                           lazy='dynamic',
                           passive_deletes=True))
    proposed_contribution_type = db.relationship('ContributionType',
                                                 lazy=True,
                                                 backref=db.backref(
                                                     'abstract_reviews',
                                                     lazy='dynamic'))

    # relationship backrefs:
    # - ratings (AbstractReviewRating.review)

    comment = RenderModeMixin.create_hybrid_property('_comment')

    @locator_property
    def locator(self):
        return dict(self.abstract.locator, review_id=self.id)

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           'abstract_id',
                           'user_id',
                           proposed_action=None)

    @property
    def visibility(self):
        return AbstractCommentVisibility.reviewers

    @property
    def score(self):
        ratings = [
            r for r in self.ratings
            if not r.question.no_score and not r.question.is_deleted
        ]
        if not ratings:
            return None
        return sum(x.value for x in ratings) / len(ratings)

    def can_edit(self, user, check_state=False):
        if user is None:
            return False
        if check_state and self.abstract.public_state.name != 'under_review':
            return False
        return self.user == user

    def can_view(self, user):
        if user is None:
            return False
        elif user == self.user:
            return True
        if self.abstract.can_judge(user):
            return True
        else:
            return self.track.can_convene(user)
class OAuthApplication(db.Model):
    """OAuth applications registered in fossir"""

    __tablename__ = 'applications'

    @declared_attr
    def __table_args__(cls):
        return (db.Index('ix_uq_applications_name_lower',
                         db.func.lower(cls.name),
                         unique=True),
                db.Index(None,
                         cls.system_app_type,
                         unique=True,
                         postgresql_where=db.text(
                             'system_app_type != {}'.format(
                                 SystemAppType.none.value))), {
                                     'schema': 'oauth'
                                 })

    #: the unique id of the application
    id = db.Column(db.Integer, primary_key=True)
    #: human readable name
    name = db.Column(db.String, nullable=False)
    #: human readable description
    description = db.Column(db.Text, nullable=False, default='')
    #: the OAuth client_id
    client_id = db.Column(UUID,
                          unique=True,
                          nullable=False,
                          default=lambda: unicode(uuid4()))
    #: the OAuth client_secret
    client_secret = db.Column(UUID,
                              nullable=False,
                              default=lambda: unicode(uuid4()))
    #: the OAuth default scopes the application may request access to
    default_scopes = db.Column(ARRAY(db.String), nullable=False)
    #: the OAuth absolute URIs that a application may use to redirect to after authorization
    redirect_uris = db.Column(ARRAY(db.String), nullable=False, default=[])
    #: whether the application is enabled or disabled
    is_enabled = db.Column(db.Boolean, nullable=False, default=True)
    #: whether the application can access user data without asking for permission
    is_trusted = db.Column(db.Boolean, nullable=False, default=False)
    #: the type of system app (if any). system apps cannot be deleted
    system_app_type = db.Column(PyIntEnum(SystemAppType),
                                nullable=False,
                                default=SystemAppType.none)

    # relationship backrefs:
    # - tokens (OAuthToken.application)

    @property
    def client_type(self):
        return 'public'

    @property
    def default_redirect_uri(self):
        return self.redirect_uris[0] if self.redirect_uris else None

    @property
    def locator(self):
        return {'id': self.id}

    @return_ascii
    def __repr__(self):  # pragma: no cover
        return '<OAuthApplication({}, {}, {})>'.format(self.id, self.name,
                                                       self.client_id)

    def reset_client_secret(self):
        self.client_secret = unicode(uuid4())
        logger.info("Client secret for %s has been reset.", self)

    def validate_redirect_uri(self, redirect_uri):
        """Called by flask-oauthlib to validate the redirect_uri.

        Uses a logic similar to the one at GitHub, i.e. protocol and
        host/port must match exactly and if there is a path in the
        whitelisted URL, the path of the redirect_uri must start with
        that path.
        """
        uri_data = url_parse(redirect_uri)
        for valid_uri_data in map(url_parse, self.redirect_uris):
            if (uri_data.scheme == valid_uri_data.scheme
                    and uri_data.netloc == valid_uri_data.netloc
                    and uri_data.path.startswith(valid_uri_data.path)):
                return True
        return False
Beispiel #14
0
class MenuEntry(MenuEntryMixin, db.Model):
    __tablename__ = 'menu_entries'
    __table_args__ = (
        db.CheckConstraint(
            '(type IN ({type.internal_link.value}, {type.plugin_link.value}) AND name IS NOT NULL) OR '
            '(type NOT IN ({type.internal_link.value}, {type.plugin_link.value}) and name IS NULL)'
            .format(type=MenuEntryType), 'valid_name'),
        db.CheckConstraint(
            '(type = {type.user_link.value}) = (link_url IS NOT NULL)'.format(
                type=MenuEntryType), 'valid_link_url'),
        db.CheckConstraint(
            '(type = {type.page.value} AND page_id IS NOT NULL) OR'
            ' (type != {type.page.value} AND page_id IS NULL)'.format(
                type=MenuEntryType), 'valid_page_id'),
        db.CheckConstraint(
            '(type = {type.plugin_link.value} AND plugin IS NOT NULL) OR'
            ' (type != {type.plugin_link.value} AND plugin IS NULL)'.format(
                type=MenuEntryType), 'valid_plugin'),
        db.CheckConstraint(
            '(type = {type.separator.value} AND title IS NULL) OR'
            ' (type IN ({type.user_link.value}, {type.page.value}) AND title IS NOT NULL) OR'
            ' (type NOT IN ({type.separator.value}, {type.user_link.value}, {type.page.value}))'
            .format(type=MenuEntryType), 'valid_title'),
        db.CheckConstraint("title != ''", 'title_not_empty'),
        db.Index(
            None,
            'event_id',
            'name',
            unique=True,
            postgresql_where=db.text(
                '(type = {type.internal_link.value} OR type = {type.plugin_link.value})'
                .format(type=MenuEntryType))), {
                    'schema': 'events'
                })

    #: The ID of the menu entry
    id = db.Column(db.Integer, primary_key=True)
    #: The ID of the parent menu entry (NULL if root menu entry)
    parent_id = db.Column(
        db.Integer,
        db.ForeignKey('events.menu_entries.id'),
        index=True,
        nullable=True,
    )
    #: The ID of the event which contains the menu
    event_id = db.Column(db.Integer,
                         db.ForeignKey('events.events.id'),
                         index=True,
                         nullable=False)
    #: Whether the entry is visible in the event's menu
    is_enabled = db.Column(db.Boolean, nullable=False, default=True)
    #: The title of the menu entry (to be displayed to the user)
    title = db.Column(
        db.String,
        nullable=True,
    )
    #: The name of the menu entry (to uniquely identify a default entry for a given event)
    name = db.Column(db.String, nullable=True)
    #: The relative position of the entry in the menu
    position = db.Column(db.Integer,
                         nullable=False,
                         default=_get_next_position)
    #: Whether the menu entry should be opened in a new tab or window
    new_tab = db.Column(db.Boolean, nullable=False, default=False)
    #: The target URL of a custom link
    link_url = db.Column(db.String, nullable=True, default=None)
    #: The name of the plugin from which the entry comes from (NULL if the entry does not come from a plugin)
    plugin = db.Column(db.String, nullable=True)
    #: The page ID if the entry is a page
    page_id = db.Column(db.Integer,
                        db.ForeignKey('events.pages.id'),
                        nullable=True,
                        index=True,
                        default=None)
    #: The type of the menu entry
    type = db.Column(PyIntEnum(MenuEntryType), nullable=False)

    #: The Event containing the menu entry
    event = db.relationship('Event',
                            lazy=True,
                            backref=db.backref('menu_entries', lazy='dynamic'))
    #: The page of the menu entry
    page = db.relationship(
        'EventPage',
        lazy=True,
        cascade='all, delete-orphan',
        single_parent=True,
        backref=db.backref('menu_entry', lazy=False, uselist=False),
    )
    #: The children menu entries and parent backref
    children = db.relationship(
        'MenuEntry',
        order_by='MenuEntry.position',
        backref=db.backref('parent', remote_side=[id]),
    )

    # relationship backrefs:
    # - parent (MenuEntry.children)

    @property
    def is_root(self):
        return self.parent_id is None

    @staticmethod
    def get_for_event(event):
        return (MenuEntry.query.with_parent(event).filter(
            MenuEntry.parent_id.is_(None)).options(
                joinedload('children')).order_by(MenuEntry.position).all())

    def move(self, to):
        from_ = self.position
        new_pos = to
        value = -1
        if to is None or to < 0:
            new_pos = to = -1

        if from_ > to:
            new_pos += 1
            from_, to = to, from_
            to -= 1
            value = 1

        entries = (MenuEntry.query.with_parent(self.event).filter(
            MenuEntry.parent == self.parent,
            MenuEntry.position.between(from_ + 1, to)))
        for e in entries:
            e.position += value
        self.position = new_pos

    def insert(self, parent, position):
        if position is None or position < 0:
            position = -1
        old_siblings = (MenuEntry.query.with_parent(self.event).filter(
            MenuEntry.position > self.position,
            MenuEntry.parent == self.parent))
        for sibling in old_siblings:
            sibling.position -= 1

        new_siblings = (MenuEntry.query.with_parent(self.event).filter(
            MenuEntry.position > position, MenuEntry.parent == parent))
        for sibling in new_siblings:
            sibling.position += 1

        self.parent = parent
        self.position = position + 1
Beispiel #15
0
class Category(SearchableTitleMixin, DescriptionMixin, ProtectionManagersMixin,
               AttachedItemsMixin, db.Model):
    """An fossir 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('fossir.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, role='create'))

    def move(self, target):
        """Move the category into another category."""
        assert not self.is_root
        old_parent = self.parent
        self.position = (max(x.position for x in target.children) +
                         1) if target.children else 1
        self.parent = target
        db.session.flush()
        signals.category.moved.send(self, old_parent=old_parent)

    @classmethod
    def get_tree_cte(cls, col='id'):
        """Create a CTE for the category tree.

        The CTE contains the following columns:

        - ``id`` -- the category id
        - ``path`` -- an array containing the path from the root to
                      the category itself
        - ``is_deleted`` -- whether the category is deleted

        :param col: The name of the column to use in the path or a
                    callable receiving the category alias that must
                    return the expression used for the 'path'
                    retrieved by the CTE.
        """
        cat_alias = db.aliased(cls)
        if callable(col):
            path_column = col(cat_alias)
        else:
            path_column = getattr(cat_alias, col)
        cte_query = (select([
            cat_alias.id,
            array([path_column]).label('path'), cat_alias.is_deleted
        ]).where(cat_alias.parent_id.is_(None)).cte(recursive=True))
        rec_query = (select([
            cat_alias.id,
            cte_query.c.path.op('||')(path_column),
            cte_query.c.is_deleted | cat_alias.is_deleted
        ]).where(cat_alias.parent_id == cte_query.c.id))
        return cte_query.union_all(rec_query)

    @classmethod
    def get_protection_cte(cls):
        cat_alias = db.aliased(cls)
        cte_query = (select([cat_alias.id, cat_alias.protection_mode]).where(
            cat_alias.parent_id.is_(None)).cte(recursive=True))
        rec_query = (select([
            cat_alias.id,
            db.case(
                {ProtectionMode.inheriting.value: cte_query.c.protection_mode},
                else_=cat_alias.protection_mode,
                value=cat_alias.protection_mode)
        ]).where(cat_alias.parent_id == cte_query.c.id))
        return cte_query.union_all(rec_query)

    def get_protection_parent_cte(self):
        cte_query = (select([
            Category.id,
            db.cast(literal(None), db.Integer).label('protection_parent')
        ]).where(Category.id == self.id).cte(recursive=True))
        rec_query = (select([
            Category.id,
            db.case(
                {
                    ProtectionMode.inheriting.value:
                    func.coalesce(cte_query.c.protection_parent, self.id)
                },
                else_=Category.id,
                value=Category.protection_mode)
        ]).where(Category.parent_id == cte_query.c.id))
        return cte_query.union_all(rec_query)

    @classmethod
    def get_icon_data_cte(cls):
        cat_alias = db.aliased(cls)
        cte_query = (select([
            cat_alias.id,
            cat_alias.id.label('source_id'), cat_alias.icon_metadata
        ]).where(cat_alias.parent_id.is_(None)).cte(recursive=True))
        rec_query = (select([
            cat_alias.id,
            db.case({'null': cte_query.c.source_id},
                    else_=cat_alias.id,
                    value=db.func.json_typeof(cat_alias.icon_metadata)),
            db.case({'null': cte_query.c.icon_metadata},
                    else_=cat_alias.icon_metadata,
                    value=db.func.json_typeof(cat_alias.icon_metadata))
        ]).where(cat_alias.parent_id == cte_query.c.id))
        return cte_query.union_all(rec_query)

    @property
    def deep_children_query(self):
        """Get a query object for all subcategories.

        This includes subcategories at any level of nesting.
        """
        cte = Category.get_tree_cte()
        return (Category.query.join(cte, Category.id == cte.c.id).filter(
            cte.c.path.contains([self.id]), cte.c.id != self.id,
            ~cte.c.is_deleted))

    @staticmethod
    def _get_chain_query(start_criterion):
        cte_query = (select([
            Category.id, Category.parent_id,
            literal(0).label('level')
        ]).where(start_criterion).cte('category_chain', recursive=True))
        parent_query = (select([
            Category.id, Category.parent_id, cte_query.c.level + 1
        ]).where(Category.id == cte_query.c.parent_id))
        cte_query = cte_query.union_all(parent_query)
        return Category.query.join(cte_query,
                                   Category.id == cte_query.c.id).order_by(
                                       cte_query.c.level.desc())

    @property
    def chain_query(self):
        """Get a query object for the category chain.

        The query retrieves the root category first and then all the
        intermediate categories up to (and including) this category.
        """
        return self._get_chain_query(Category.id == self.id)

    @property
    def parent_chain_query(self):
        """Get a query object for the category's parent chain.

        The query retrieves the root category first and then all the
        intermediate categories up to (excluding) this category.
        """
        return self._get_chain_query(Category.id == self.parent_id)

    def nth_parent(self, n_categs, fail_on_overflow=True):
        """Return the nth parent of the category.

        :param n_categs: the number of categories to go up
        :param fail_on_overflow: whether to fail if we try to go above the root category
        :return: `Category` object or None (only if ``fail_on_overflow`` is not set)
        """
        if n_categs == 0:
            return self
        chain = self.parent_chain_query.all()

        assert n_categs >= 0
        if n_categs > len(chain):
            if fail_on_overflow:
                raise IndexError("Root category has no parent!")
            else:
                return None
        return chain[::-1][n_categs - 1]

    def is_descendant_of(self, categ):
        return categ != self and self.parent_chain_query.filter(
            Category.id == categ.id).has_rows()

    @property
    def visibility_horizon_query(self):
        """Get a query object that returns the highest category this one is visible from."""
        cte_query = (select([
            Category.id, Category.parent_id,
            db.case([(Category.visibility.is_(None), None)],
                    else_=(Category.visibility - 1)).label('n'),
            literal(0).label('level')
        ]).where(Category.id == self.id).cte('visibility_horizon',
                                             recursive=True))
        parent_query = (select([
            Category.id, Category.parent_id,
            db.case([
                (Category.visibility.is_(None) & cte_query.c.n.is_(None), None)
            ],
                    else_=db.func.least(Category.visibility, cte_query.c.n) -
                    1), cte_query.c.level + 1
        ]).where(
            db.and_(Category.id == cte_query.c.parent_id,
                    (cte_query.c.n > 0) | cte_query.c.n.is_(None))))
        cte_query = cte_query.union_all(parent_query)
        return db.session.query(cte_query.c.id, cte_query.c.n).order_by(
            cte_query.c.level.desc()).limit(1)

    @property
    def own_visibility_horizon(self):
        """Get the highest category this one would like to be visible from (configured visibility)."""
        if self.visibility is None:
            return Category.get_root()
        else:
            return self.nth_parent(self.visibility - 1)

    @property
    def real_visibility_horizon(self):
        """Get the highest category this one is actually visible from (as limited by categories above)."""
        horizon_id, final_visibility = self.visibility_horizon_query.one()
        if final_visibility is not None and final_visibility < 0:
            return None  # Category is invisible
        return Category.get(horizon_id)

    @property
    def visible_categories_cte(self):
        """
        Get a sqlalchemy select for the visible categories within
        this category, including the category itself.
        """
        cte_query = (select([
            Category.id, literal(0).label('level')
        ]).where((Category.id == self.id) & (Category.visibility.is_(None)
                                             | (Category.visibility > 0))).cte(
                                                 'visibility_chain',
                                                 recursive=True))
        parent_query = (select([Category.id, cte_query.c.level + 1]).where(
            db.and_(
                Category.parent_id == cte_query.c.id,
                db.or_(Category.visibility.is_(None),
                       Category.visibility > cte_query.c.level + 1))))
        return cte_query.union_all(parent_query)

    @property
    def visible_categories_query(self):
        """
        Get a query object for the visible categories within
        this category, including the category itself.
        """
        cte_query = self.visible_categories_cte
        return Category.query.join(cte_query, Category.id == cte_query.c.id)

    @property
    def icon_url(self):
        """Get the HTTP URL of the icon."""
        return url_for('categories.display_icon',
                       self,
                       slug=self.icon_metadata['hash'])

    @property
    def effective_icon_url(self):
        """Get the HTTP URL of the icon (possibly inherited)."""
        data = self.effective_icon_data
        return url_for('categories.display_icon',
                       category_id=data['source_id'],
                       slug=data['metadata']['hash'])

    @property
    def logo_url(self):
        """Get the HTTP URL of the logo."""
        return url_for('categories.display_logo',
                       self,
                       slug=self.logo_metadata['hash'])
class RegistrationFormItem(db.Model):
    """Generic registration form item"""

    __tablename__ = 'form_items'
    __table_args__ = (
        db.CheckConstraint(
            "(input_type IS NULL) = (type NOT IN ({t.field}, {t.field_pd}))".
            format(t=RegistrationFormItemType),
            name='valid_input'),
        db.CheckConstraint("NOT is_manager_only OR type = {type}".format(
            type=RegistrationFormItemType.section),
                           name='valid_manager_only'),
        db.CheckConstraint(
            "(type IN ({t.section}, {t.section_pd})) = (parent_id IS NULL)".
            format(t=RegistrationFormItemType),
            name='top_level_sections'),
        db.CheckConstraint(
            "(type != {type}) = (personal_data_type IS NULL)".format(
                type=RegistrationFormItemType.field_pd),
            name='pd_field_type'),
        db.CheckConstraint(
            "NOT is_deleted OR (type NOT IN ({t.section_pd}, {t.field_pd}))".
            format(t=RegistrationFormItemType),
            name='pd_not_deleted'),
        db.CheckConstraint("is_enabled OR type != {type}".format(
            type=RegistrationFormItemType.section_pd),
                           name='pd_section_enabled'),
        db.CheckConstraint(
            "is_enabled OR type != {type} OR personal_data_type NOT IN "
            "({pt.email}, {pt.first_name}, {pt.last_name})".format(
                type=RegistrationFormItemType.field_pd, pt=PersonalDataType),
            name='pd_field_enabled'),
        db.CheckConstraint(
            "is_required OR type != {type} OR personal_data_type NOT IN "
            "({pt.email}, {pt.first_name}, {pt.last_name})".format(
                type=RegistrationFormItemType.field_pd, pt=PersonalDataType),
            name='pd_field_required'),
        db.CheckConstraint(
            "current_data_id IS NULL OR type IN ({t.field}, {t.field_pd})".
            format(t=RegistrationFormItemType),
            name='current_data_id_only_field'),
        db.Index('ix_uq_form_items_pd_section',
                 'registration_form_id',
                 unique=True,
                 postgresql_where=db.text('type = {type}'.format(
                     type=RegistrationFormItemType.section_pd))),
        db.Index('ix_uq_form_items_pd_field',
                 'registration_form_id',
                 'personal_data_type',
                 unique=True,
                 postgresql_where=db.text('type = {type}'.format(
                     type=RegistrationFormItemType.field_pd))), {
                         'schema': 'event_registration'
                     })
    __mapper_args__ = {'polymorphic_on': 'type', 'polymorphic_identity': None}

    #: The ID of the object
    id = db.Column(db.Integer, primary_key=True)
    #: The ID  of the registration form
    registration_form_id = db.Column(
        db.Integer,
        db.ForeignKey('event_registration.forms.id'),
        index=True,
        nullable=False)
    #: The type of the registration form item
    type = db.Column(PyIntEnum(RegistrationFormItemType), nullable=False)
    #: The type of a personal data field
    personal_data_type = db.Column(PyIntEnum(PersonalDataType), nullable=True)
    #: The ID of the parent form item
    parent_id = db.Column(db.Integer,
                          db.ForeignKey('event_registration.form_items.id'),
                          index=True,
                          nullable=True)
    position = db.Column(db.Integer,
                         nullable=False,
                         default=_get_next_position)
    #: The title of this field
    title = db.Column(db.String, nullable=False)
    #: Description of this field
    description = db.Column(db.String, nullable=False, default='')
    #: Whether the field is enabled
    is_enabled = db.Column(db.Boolean, nullable=False, default=True)
    #: Whether field has been "deleted"
    is_deleted = db.Column(db.Boolean, nullable=False, default=False)
    #: determines if the field is mandatory
    is_required = db.Column(db.Boolean, nullable=False, default=False)
    #: if the section is only accessible to managers
    is_manager_only = db.Column(db.Boolean, nullable=False, default=False)
    #: input type of this field
    input_type = db.Column(db.String, nullable=True)
    #: unversioned field data
    data = db.Column(JSON, nullable=False, default=lambda: None)

    #: The ID of the latest data
    current_data_id = db.Column(db.Integer,
                                db.ForeignKey(
                                    'event_registration.form_field_data.id',
                                    use_alter=True),
                                index=True,
                                nullable=True)

    #: The latest value of the field
    current_data = db.relationship(
        'RegistrationFormFieldData',
        primaryjoin=
        'RegistrationFormItem.current_data_id == RegistrationFormFieldData.id',
        foreign_keys=current_data_id,
        lazy=True,
        post_update=True)

    #: The list of all versions of the field data
    data_versions = db.relationship(
        'RegistrationFormFieldData',
        primaryjoin=
        'RegistrationFormItem.id == RegistrationFormFieldData.field_id',
        foreign_keys='RegistrationFormFieldData.field_id',
        lazy=True,
        cascade='all, delete-orphan',
        backref=db.backref('field', lazy=False))

    # The children of the item and the parent backref
    children = db.relationship('RegistrationFormItem',
                               lazy=True,
                               order_by='RegistrationFormItem.position',
                               backref=db.backref('parent',
                                                  lazy=False,
                                                  remote_side=[id]))

    # relationship backrefs:
    # - parent (RegistrationFormItem.children)
    # - registration_form (RegistrationForm.form_items)

    @property
    def view_data(self):
        """Returns object with data that Angular can understand"""
        return dict(id=self.id,
                    description=self.description,
                    position=self.position)

    @hybrid_property
    def is_section(self):
        return self.type in {
            RegistrationFormItemType.section,
            RegistrationFormItemType.section_pd
        }

    @is_section.expression
    def is_section(cls):
        return cls.type.in_([
            RegistrationFormItemType.section,
            RegistrationFormItemType.section_pd
        ])

    @hybrid_property
    def is_field(self):
        return self.type in {
            RegistrationFormItemType.field, RegistrationFormItemType.field_pd
        }

    @is_field.expression
    def is_field(cls):
        return cls.type.in_([
            RegistrationFormItemType.field, RegistrationFormItemType.field_pd
        ])

    @hybrid_property
    def is_visible(self):
        return self.is_enabled and not self.is_deleted and (
            self.parent_id is None or self.parent.is_visible)

    @is_visible.expression
    def is_visible(cls):
        sections = aliased(RegistrationFormSection)
        query = (db.session.query(literal(True)).filter(
            sections.id == cls.parent_id).filter(~sections.is_deleted).filter(
                sections.is_enabled).exists())
        return cls.is_enabled & ~cls.is_deleted & (
            (cls.parent_id == None) | query)  # noqa

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           'registration_form_id',
                           is_enabled=True,
                           is_deleted=False,
                           is_manager_only=False,
                           _text=self.title)
 def visibility(cls):
     return db.Column(
         PyIntEnum(AbstractCommentVisibility),
         nullable=False,
         default=AbstractCommentVisibility.contributors
     )
Beispiel #18
0
class User(PersonMixin, db.Model):
    """fossir users"""

    # Useful when dealing with both users and groups in the same code
    is_group = False
    is_single_person = True
    is_network = False
    principal_order = 0
    principal_type = PrincipalType.user

    __tablename__ = 'users'
    __table_args__ = (
        db.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'), {
                '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)

    _affiliation = db.relationship('UserAffiliation',
                                   lazy=False,
                                   uselist=False,
                                   cascade='all, delete-orphan',
                                   backref=db.backref('user', lazy=True))

    _primary_email = db.relationship(
        'UserEmail',
        lazy=False,
        uselist=False,
        cascade='all, delete-orphan',
        primaryjoin='(User.id == UserEmail.user_id) & UserEmail.is_primary')
    _secondary_emails = db.relationship(
        'UserEmail',
        lazy=True,
        cascade='all, delete-orphan',
        collection_class=set,
        primaryjoin='(User.id == UserEmail.user_id) & ~UserEmail.is_primary')
    _all_emails = db.relationship('UserEmail',
                                  lazy=True,
                                  viewonly=True,
                                  primaryjoin='User.id == UserEmail.user_id',
                                  collection_class=set,
                                  backref=db.backref('user', lazy=False))
    #: the affiliation of the user
    affiliation = association_proxy('_affiliation',
                                    'name',
                                    creator=lambda v: UserAffiliation(name=v))
    #: the primary email address of the user
    email = association_proxy(
        '_primary_email',
        'email',
        creator=lambda v: UserEmail(email=v, is_primary=True))
    #: any additional emails the user might have
    secondary_emails = association_proxy('_secondary_emails',
                                         'email',
                                         creator=lambda v: UserEmail(email=v))
    #: all emails of the user. read-only; use it only for searching by email! also, do not use it between
    #: modifying `email` or `secondary_emails` and a session expire/commit!
    all_emails = association_proxy('_all_emails', 'email')  # read-only!

    #: the user this user has been merged into
    merged_into_user = db.relationship(
        'User',
        lazy=True,
        backref=db.backref('merged_from_users', lazy=True),
        remote_side='User.id',
    )
    #: the users's favorite users
    favorite_users = db.relationship(
        'User',
        secondary=favorite_user_table,
        primaryjoin=id == favorite_user_table.c.user_id,
        secondaryjoin=(id == favorite_user_table.c.target_id) & ~is_deleted,
        lazy=True,
        collection_class=set,
        backref=db.backref('favorite_of', lazy=True, collection_class=set),
    )
    #: the users's favorite categories
    favorite_categories = db.relationship(
        'Category',
        secondary=favorite_category_table,
        lazy=True,
        collection_class=set,
        backref=db.backref('favorite_of', lazy=True, collection_class=set),
    )
    #: the user's category suggestions
    suggested_categories = db.relationship(
        'SuggestedCategory',
        lazy='dynamic',
        order_by='SuggestedCategory.score.desc()',
        cascade='all, delete-orphan',
        backref=db.backref('user', lazy=True))
    #: the 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_reviewer_for_tracks (Track.abstract_reviewers)
    # - abstract_reviews (AbstractReview.user)
    # - abstracts (Abstract.submitter)
    # - agreements (Agreement.user)
    # - attachment_files (AttachmentFile.user)
    # - attachments (Attachment.user)
    # - blockings (Blocking.created_by_user)
    # - content_reviewer_for_contributions (Contribution.paper_content_reviewers)
    # - convener_for_tracks (Track.conveners)
    # - created_events (Event.creator)
    # - event_log_entries (EventLogEntry.user)
    # - event_notes_revisions (EventNoteRevision.user)
    # - event_persons (EventPerson.user)
    # - event_reminders (EventReminder.creator)
    # - favorite_of (User.favorite_users)
    # - global_abstract_reviewer_for_events (Event.global_abstract_reviewers)
    # - global_convener_for_events (Event.global_conveners)
    # - in_attachment_acls (AttachmentPrincipal.user)
    # - in_attachment_folder_acls (AttachmentFolderPrincipal.user)
    # - in_blocking_acls (BlockingPrincipal.user)
    # - in_category_acls (CategoryPrincipal.user)
    # - in_contribution_acls (ContributionPrincipal.user)
    # - in_event_acls (EventPrincipal.user)
    # - in_event_settings_acls (EventSettingPrincipal.user)
    # - in_session_acls (SessionPrincipal.user)
    # - in_settings_acls (SettingPrincipal.user)
    # - 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_tokens (OAuthToken.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 as_avatar(self):
        # TODO: remove this after DB is free of Avatars
        from fossir.modules.users.legacy import AvatarUserWrapper
        avatar = AvatarUserWrapper(self.id)

        # avoid garbage collection
        avatar.user
        return avatar

    as_legacy = as_avatar

    @property
    def avatar_css(self):
        from fossir.modules.users.util import get_color_for_username
        return 'background-color: {};'.format(
            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 != 'fossir'}

    @property
    def local_identities(self):
        """The local identities of the user"""
        return {x for x in self.identities if x.provider == 'fossir'}

    @property
    def local_identity(self):
        """The main (most recently used) local identity"""
        identities = sorted(self.local_identities,
                            key=attrgetter('safe_last_login_dt'),
                            reverse=True)
        return identities[0] if identities else None

    @property
    def secondary_local_identities(self):
        """The local identities of the user except the main one"""
        return self.local_identities - {self.local_identity}

    @locator_property
    def locator(self):
        return {'user_id': self.id}

    @cached_property
    def settings(self):
        """Returns the user settings proxy for this user"""
        from fossir.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 'fossir.conf'.
        """
        synced_fields = self.settings.get('synced_fields')
        # If synced_fields is missing or None, then all fields are synced
        if synced_fields is None:
            return multipass.synced_fields
        else:
            return set(synced_fields) & multipass.synced_fields

    @synced_fields.setter
    def synced_fields(self, value):
        value = set(value) & multipass.synced_fields
        if value == multipass.synced_fields:
            self.settings.delete('synced_fields')
        else:
            self.settings.set('synced_fields', list(value))

    @property
    def synced_values(self):
        """The values from the synced identity for the user.

        Those values are not the actual user's values and might differ
        if they are not set as synchronized.
        """
        identity = self._get_synced_identity(refresh=False)
        if identity is None:
            return {}
        return {
            field: (identity.data.get(field) or '')
            for field in multipass.synced_fields
        }

    def __contains__(self, user):
        """Convenience method for `user in user_or_group`."""
        return self == user

    @return_ascii
    def __repr__(self):
        return 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

    def get_full_name(self, *args, **kwargs):
        kwargs['_show_empty_names'] = True
        return super(User, self).get_full_name(*args, **kwargs)

    def make_email_primary(self, email):
        """Promotes a secondary email address to the primary email address

        :param email: an email address that is currently a secondary email
        """
        secondary = next(
            (x for x in self._secondary_emails if x.email == email), None)
        if secondary is None:
            raise ValueError('email is not a secondary email address')
        self._primary_email.is_primary = False
        db.session.flush()
        secondary.is_primary = True
        db.session.flush()

    def synchronize_data(self, refresh=False):
        """Synchronize the fields of the user from the sync identity.

        This will take only into account :attr:`synced_fields`.

        :param refresh: bool -- Whether to refresh the synced identity
                        with the sync provider before instead of using
                        the stored data. (Only if the sync provider
                        supports refresh.)
        """
        identity = self._get_synced_identity(refresh=refresh)
        if identity is None:
            return
        for field in self.synced_fields:
            old_value = getattr(self, field)
            new_value = identity.data.get(field) or ''
            if field in ('first_name', 'last_name') and not new_value:
                continue
            if old_value == new_value:
                continue
            flash(
                _("Your {field_name} has been synchronised from '{old_value}' to '{new_value}'."
                  ).format(field_name=syncable_fields[field],
                           old_value=old_value,
                           new_value=new_value))
            setattr(self, field, new_value)

    def _get_synced_identity(self, refresh=False):
        sync_provider = multipass.sync_provider
        if sync_provider is None:
            return None
        identities = sorted(
            [x for x in self.identities if x.provider == sync_provider.name],
            key=attrgetter('safe_last_login_dt'),
            reverse=True)
        if not identities:
            return None
        identity = identities[0]
        if refresh and identity.multipass_data is not None:
            try:
                identity_info = sync_provider.refresh_identity(
                    identity.identifier, identity.multipass_data)
            except IdentityRetrievalFailed:
                identity_info = None
            if identity_info:
                identity.data = identity_info.data
        return identity
Beispiel #19
0
class PaperReview(ProposalReviewMixin, RenderModeMixin, db.Model):
    """Represents a paper review, emitted by a layout or content reviewer"""

    possible_render_modes = {RenderMode.markdown}
    default_render_mode = RenderMode.markdown

    revision_attr = 'revision'
    group_attr = 'type'
    group_proxy_cls = PaperTypeProxy

    __tablename__ = 'reviews'
    __table_args__ = (db.UniqueConstraint('revision_id', 'user_id', 'type'), {
        'schema': 'event_paper_reviewing'
    })
    TIMELINE_TYPE = 'review'

    id = db.Column(db.Integer, primary_key=True)
    revision_id = db.Column(
        db.Integer,
        db.ForeignKey('event_paper_reviewing.revisions.id'),
        index=True,
        nullable=False)
    user_id = db.Column(db.Integer,
                        db.ForeignKey('users.users.id'),
                        index=True,
                        nullable=False)
    created_dt = db.Column(
        UTCDateTime,
        nullable=False,
        default=now_utc,
    )
    modified_dt = db.Column(UTCDateTime, nullable=True)
    _comment = db.Column('comment', db.Text, nullable=False, default='')
    type = db.Column(PyIntEnum(PaperReviewType), nullable=False)
    proposed_action = db.Column(PyIntEnum(PaperAction), nullable=False)

    revision = db.relationship('PaperRevision',
                               lazy=True,
                               backref=db.backref('reviews',
                                                  lazy=True,
                                                  order_by=created_dt.desc()))
    user = db.relationship('User',
                           lazy=True,
                           backref=db.backref('paper_reviews', lazy='dynamic'))

    # relationship backrefs:
    # - ratings (PaperReviewRating.review)

    comment = RenderModeMixin.create_hybrid_property('_comment')

    @locator_property
    def locator(self):
        return dict(self.revision.locator, review_id=self.id)

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           'type',
                           'revision_id',
                           'user_id',
                           proposed_action=None)

    def can_edit(self, user, check_state=False):
        from fossir.modules.events.papers.models.revisions import PaperRevisionState
        if user is None:
            return False
        if check_state and self.revision.state != PaperRevisionState.submitted:
            return False
        return self.user == user

    def can_view(self, user):
        if user is None:
            return False
        elif user == self.user:
            return True
        elif self.revision.paper.can_judge(user):
            return True
        return False

    @property
    def visibility(self):
        return PaperCommentVisibility.reviewers

    @property
    def score(self):
        ratings = [r for r in self.ratings]
        if not ratings:
            return None
        return sum(x.value for x in ratings) / len(ratings)
class EventLogEntry(db.Model):
    """Log entries for events"""
    __tablename__ = 'logs'
    __table_args__ = {'schema': 'events'}

    #: The ID of the log entry
    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 ID of the user associated with the entry
    user_id = db.Column(db.Integer,
                        db.ForeignKey('users.users.id'),
                        index=True,
                        nullable=True)
    #: The date/time when the reminder was created
    logged_dt = db.Column(UTCDateTime, nullable=False, default=now_utc)
    #: The general area of the event the entry comes from
    realm = db.Column(PyIntEnum(EventLogRealm), nullable=False)
    #: The general kind of operation that was performed
    kind = db.Column(PyIntEnum(EventLogKind), nullable=False)
    #: The module the operation was related to (does not need to match
    #: something in fossir.modules and should be human-friendly but not
    #: translated).
    module = db.Column(db.String, nullable=False)
    #: The type of the log entry. This needs to match the name of a log renderer.
    type = db.Column(db.String, nullable=False)
    #: A short one-line description of the logged action.
    #: Should not be translated!
    summary = db.Column(db.String, nullable=False)
    #: Type-specific data
    data = db.Column(JSON, nullable=False)

    #: The user associated with the log entry
    user = db.relationship('User',
                           lazy=False,
                           backref=db.backref('event_log_entries',
                                              lazy='dynamic'))
    #: The Event this log entry is associated with
    event = db.relationship('Event',
                            lazy=True,
                            backref=db.backref('log_entries', lazy='dynamic'))

    @property
    def logged_date(self):
        return self.logged_dt.date()

    @property
    def renderer(self):
        from fossir.modules.events.logs.util import get_log_renderers
        return get_log_renderers().get(self.type)

    def render(self):
        """Renders the log entry to be displayed.

        If the renderer is not available anymore, e.g. because of a
        disabled plugin, ``None`` is returned.
        """
        renderer = self.renderer
        return renderer.render_entry(self) if renderer else None

    @return_ascii
    def __repr__(self):
        realm = self.realm.name if self.realm is not None else None
        return '<EventLogEntry({}, {}, {}, {}, {}): {}>'.format(
            self.id, self.event_id, self.logged_dt, realm, self.module,
            self.summary)