示例#1
0
class AbstractPersonLink(PersonLinkBase):
    """Association between EventPerson and Abstract."""

    __tablename__ = 'abstract_person_links'
    __auto_table_args = {'schema': 'event_abstracts'}
    person_link_backref_name = 'abstract_links'
    person_link_unique_columns = ('abstract_id', )
    object_relationship_name = 'abstract'

    abstract_id = db.Column(db.Integer,
                            db.ForeignKey('event_abstracts.abstracts.id'),
                            index=True,
                            nullable=False)
    is_speaker = db.Column(db.Boolean, nullable=False, default=False)
    author_type = db.Column(PyIntEnum(AuthorType),
                            nullable=False,
                            default=AuthorType.none)

    # relationship backrefs:
    # - abstract (Abstract.person_links)

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

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           'person_id',
                           'abstract_id',
                           is_speaker=False,
                           author_type=None,
                           _text=self.full_name)
class EventPersonLink(PersonLinkBase):
    """Association between EventPerson and Event.

    Chairperson or speaker (lecture)
    """

    __tablename__ = 'event_person_links'
    __auto_table_args = {'schema': 'events'}
    person_link_backref_name = 'event_links'
    person_link_unique_columns = ('event_id', )
    object_relationship_name = 'event'

    event_id = db.Column(db.Integer,
                         db.ForeignKey('events.events.id'),
                         index=True,
                         nullable=False)

    # relationship backrefs:
    # - event (Event.person_links)

    @property
    def is_submitter(self):
        if not self.event:
            raise Exception("No event to check submission rights against")
        return self.person.has_role('submit', self.event)

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           'person_id',
                           'event_id',
                           _text=self.full_name)
 def ip_network_group_id(cls):
     if not cls.allow_networks:
         return
     return db.Column(db.Integer,
                      db.ForeignKey('fossir.ip_network_groups.id'),
                      nullable=True,
                      index=True)
class PaperReviewQuestion(ReviewQuestionMixin, db.Model):
    __tablename__ = 'review_questions'
    __table_args__ = {'schema': 'event_paper_reviewing'}

    event_backref_name = 'paper_review_questions'

    type = db.Column(PyIntEnum(PaperReviewType), nullable=False)
class SubContributionPersonLink(PersonLinkBase):
    """Association between EventPerson and SubContribution."""

    __tablename__ = 'subcontribution_person_links'
    __auto_table_args = {'schema': 'events'}
    person_link_backref_name = 'subcontribution_links'
    person_link_unique_columns = ('subcontribution_id',)
    object_relationship_name = 'subcontribution'

    # subcontribution persons are always speakers and never authors
    # we provide these attributes to make subcontribution links
    # compatible with contribution links
    is_speaker = True
    author_type = AuthorType.none

    subcontribution_id = db.Column(
        db.Integer,
        db.ForeignKey('events.subcontributions.id'),
        index=True,
        nullable=False
    )

    # relationship backrefs:
    # - subcontribution (SubContribution.person_links)

    @return_ascii
    def __repr__(self):
        return format_repr(self, 'id', 'person_id', 'subcontribution_id', _text=self.full_name)
class SessionBlockPersonLink(PersonLinkBase):
    """Association between EventPerson and SessionBlock.

    Also known as a 'session convener'
    """

    __tablename__ = 'session_block_person_links'
    __auto_table_args = {'schema': 'events'}
    person_link_backref_name = 'session_block_links'
    person_link_unique_columns = ('session_block_id', )
    object_relationship_name = 'session_block'

    session_block_id = db.Column(db.Integer,
                                 db.ForeignKey('events.session_blocks.id'),
                                 index=True,
                                 nullable=False)

    # relationship backrefs:
    # - session_block (SessionBlock.person_links)

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           'person_id',
                           'session_block_id',
                           _text=self.full_name)
class ContributionPersonLink(PersonLinkBase):
    """Association between EventPerson and Contribution."""

    __tablename__ = 'contribution_person_links'
    __auto_table_args = {'schema': 'events'}
    person_link_backref_name = 'contribution_links'
    person_link_unique_columns = ('contribution_id',)
    object_relationship_name = 'contribution'

    contribution_id = db.Column(
        db.Integer,
        db.ForeignKey('events.contributions.id'),
        index=True,
        nullable=False
    )
    is_speaker = db.Column(
        db.Boolean,
        nullable=False,
        default=False
    )
    author_type = db.Column(
        PyIntEnum(AuthorType),
        nullable=False,
        default=AuthorType.none
    )

    # relationship backrefs:
    # - contribution (Contribution.person_links)

    @property
    def is_submitter(self):
        if not self.contribution:
            raise Exception("No contribution to check submission rights against")
        return self.person.has_role('submit', self.contribution)

    @property
    def is_author(self):
        return self.author_type != AuthorType.none

    @locator_property
    def locator(self):
        return dict(self.contribution.locator, person_id=self.id)

    @return_ascii
    def __repr__(self):
        return format_repr(self, 'id', 'person_id', 'contribution_id', is_speaker=False, author_type=AuthorType.none,
                           _text=self.full_name)
 def type(cls):
     exclude_values = set()
     if not cls.allow_emails:
         exclude_values.add(PrincipalType.email)
     if not cls.allow_networks:
         exclude_values.add(PrincipalType.network)
     return db.Column(PyIntEnum(PrincipalType,
                                exclude_values=(exclude_values or None)),
                      nullable=False)
 def person_id(cls):
     return db.Column(db.Integer,
                      db.ForeignKey('events.persons.id'),
                      index=True,
                      nullable=False)
class PrincipalRolesMixin(PrincipalMixin):
    #: The model for which we are a principal.  May also be a string
    #: containing the model's class name.
    principal_for = None

    @strict_classproperty
    @classmethod
    def __auto_table_args(cls):
        checks = [
            db.CheckConstraint(
                'read_access OR full_access OR array_length(roles, 1) IS NOT NULL',
                'has_privs')
        ]
        if cls.allow_networks:
            # you can match a network acl entry without being logged in.
            # we never want that for anything but simple read access
            checks.append(
                db.CheckConstraint(
                    'type != {} OR (NOT full_access AND array_length(roles, 1) IS NULL)'
                    .format(PrincipalType.network), 'networks_read_only'))
        return tuple(checks)

    read_access = db.Column(db.Boolean, nullable=False, default=False)
    full_access = db.Column(db.Boolean, nullable=False, default=False)
    roles = db.Column(ARRAY(db.String), nullable=False, default=[])

    @classproperty
    @classmethod
    def principal_for_obj(cls):
        if isinstance(cls.principal_for, basestring):
            return db.Model._decl_class_registry[cls.principal_for]
        else:
            return cls.principal_for

    @hybrid_method
    def has_management_role(self, role=None, explicit=False):
        """Checks whether a principal has a certain management role.

        The check always succeeds if the user is a full manager; in
        that case the list of roles is ignored.

        :param role: The role to check for or 'ANY' to check for any
                     management role.
        :param explicit: Whether to check for the role itself even if
                         the user has full management privileges.
        """
        if role is None:
            if explicit:
                raise ValueError('role must be specified if explicit=True')
            return self.full_access
        elif not explicit and self.full_access:
            return True
        valid_roles = get_available_roles(self.principal_for_obj).viewkeys()
        current_roles = set(self.roles) & valid_roles
        if role == 'ANY':
            return bool(current_roles)
        assert role in valid_roles, "invalid role '{}' for object '{}'".format(
            role, self.principal_for_obj)
        return role in current_roles

    @has_management_role.expression
    def has_management_role(cls, role=None, explicit=False):
        if role is None:
            if explicit:
                raise ValueError('role must be specified if explicit=True')
            return cls.full_access
        valid_roles = get_available_roles(cls.principal_for_obj).viewkeys()
        if role == 'ANY':
            crit = (cls.roles.op('&&')(db.func.cast(valid_roles,
                                                    ARRAY(db.String))))
        else:
            assert role in valid_roles, "invalid role '{}' for object '{}'".format(
                role, cls.principal_for_obj)
            crit = (cls.roles.op('&&')(db.func.cast([role], ARRAY(db.String))))
        if explicit:
            return crit
        else:
            return cls.full_access | crit

    def merge_privs(self, other):
        self.read_access = self.read_access or other.read_access
        self.full_access = self.full_access or other.full_access
        self.roles = sorted(set(self.roles) | set(other.roles))

    @property
    def current_data(self):
        return {
            'roles': set(self.roles),
            'read_access': self.read_access,
            'full_access': self.full_access
        }
示例#11
0
 def id(cls):
     return db.Column(db.Integer, primary_key=True)
示例#12
0
class Room(versioned_cache(_cache, 'id'), db.Model, Serializer):
    __tablename__ = 'rooms'
    __table_args__ = (
        db.UniqueConstraint(
            'id',
            'location_id'),  # useless but needed for the LocationMixin fkey
        {
            'schema': 'roombooking'
        })

    __public__ = [
        'id', 'name', 'location_name', 'floor', 'number', 'building',
        'booking_url', 'capacity', 'comments', 'owner_id', 'details_url',
        'large_photo_url', 'small_photo_url', 'has_photo', 'is_active',
        'is_reservable', 'is_auto_confirm', 'marker_description', 'kind',
        'booking_limit_days'
    ]

    __public_exhaustive__ = __public__ + [
        'has_webcast_recording', 'has_vc', 'has_projector', 'is_public',
        'has_booking_groups'
    ]

    __calendar_public__ = [
        'id', 'building', 'name', 'floor', 'number', 'kind', 'booking_url',
        'details_url', 'location_name', 'max_advance_days'
    ]

    __api_public__ = ('id', 'building', 'name', 'floor', 'longitude',
                      'latitude', ('number', 'roomNr'), ('location_name',
                                                         'location'),
                      ('full_name', 'fullName'), ('booking_url', 'bookingUrl'))

    __api_minimal_public__ = ('id', ('full_name', 'fullName'))

    id = db.Column(db.Integer, primary_key=True)
    location_id = db.Column(db.Integer,
                            db.ForeignKey('roombooking.locations.id'),
                            nullable=False)
    photo_id = db.Column(db.Integer, db.ForeignKey('roombooking.photos.id'))
    name = db.Column(db.String, nullable=False)
    site = db.Column(db.String, default='')
    division = db.Column(db.String)
    building = db.Column(db.String, nullable=False)
    floor = db.Column(db.String, default='', nullable=False)
    number = db.Column(db.String, default='', nullable=False)
    notification_before_days = db.Column(db.Integer)
    notification_before_days_weekly = db.Column(db.Integer)
    notification_before_days_monthly = db.Column(db.Integer)
    notification_for_assistance = db.Column(db.Boolean,
                                            nullable=False,
                                            default=False)
    reservations_need_confirmation = db.Column(db.Boolean,
                                               nullable=False,
                                               default=False)
    notifications_enabled = db.Column(db.Boolean, nullable=False, default=True)
    telephone = db.Column(db.String)
    key_location = db.Column(db.String)
    capacity = db.Column(db.Integer, default=20)
    surface_area = db.Column(db.Integer)
    latitude = db.Column(db.String)
    longitude = db.Column(db.String)
    comments = db.Column(db.String)
    owner_id = db.Column(db.Integer,
                         db.ForeignKey('users.users.id'),
                         index=True,
                         nullable=False)
    is_active = db.Column(db.Boolean, nullable=False, default=True, index=True)
    is_reservable = db.Column(db.Boolean, nullable=False, default=True)
    max_advance_days = db.Column(db.Integer)
    booking_limit_days = db.Column(db.Integer)

    attributes = db.relationship('RoomAttributeAssociation',
                                 backref='room',
                                 cascade='all, delete-orphan',
                                 lazy='dynamic')

    blocked_rooms = db.relationship('BlockedRoom',
                                    backref='room',
                                    cascade='all, delete-orphan',
                                    lazy='dynamic')

    bookable_hours = db.relationship('BookableHours',
                                     backref='room',
                                     order_by=BookableHours.start_time,
                                     cascade='all, delete-orphan',
                                     lazy='dynamic')

    available_equipment = db.relationship('EquipmentType',
                                          secondary=RoomEquipmentAssociation,
                                          backref='rooms',
                                          lazy='dynamic')

    nonbookable_periods = db.relationship(
        'NonBookablePeriod',
        backref='room',
        order_by=NonBookablePeriod.end_dt.desc(),
        cascade='all, delete-orphan',
        lazy='dynamic')

    photo = db.relationship('Photo',
                            backref='room',
                            cascade='all, delete-orphan',
                            single_parent=True,
                            lazy=True)

    reservations = db.relationship('Reservation',
                                   backref='room',
                                   cascade='all, delete-orphan',
                                   lazy='dynamic')

    #: The owner of the room. If the room has the `manager-group`
    #: attribute set, any users in that group are also considered
    #: owners when it comes to management privileges.
    #: Use :meth:`is_owned_by` for ownership checks that should
    #: also check against the management group.
    owner = db.relationship(
        'User',
        # subquery load since a normal joinedload breaks `get_with_data`
        lazy='subquery',
        backref=db.backref('owned_rooms', lazy='dynamic'))

    # relationship backrefs:
    # - breaks (Break.own_room)
    # - contributions (Contribution.own_room)
    # - events (Event.own_room)
    # - location (Location.rooms)
    # - session_blocks (SessionBlock.own_room)
    # - sessions (Session.own_room)

    @hybrid_property
    def is_auto_confirm(self):
        return not self.reservations_need_confirmation

    @is_auto_confirm.expression
    def is_auto_confirm(self):
        return ~self.reservations_need_confirmation

    @property
    def booking_url(self):
        if self.id is None:
            return None
        return url_for('rooms.room_book', self)

    @property
    def details_url(self):
        if self.id is None:
            return None
        return url_for('rooms.roomBooking-roomDetails', self)

    @property
    def large_photo_url(self):
        if self.id is None:
            return None
        return url_for('rooms.photo', self, size='large')

    @property
    def small_photo_url(self):
        if self.id is None:
            return None
        return url_for('rooms.photo', self, size='small')

    @property
    def map_url(self):
        if self.location.map_url_template:
            return self.location.map_url_template.format(
                building=self.building, floor=self.floor, number=self.number)
        else:
            return None

    @property
    def has_photo(self):
        return self.photo_id is not None

    @property
    def full_name(self):
        if self.has_special_name:
            return u'{} - {}'.format(self.generate_name(), self.name)
        else:
            return u'{}'.format(self.generate_name())

    @property
    def has_special_name(self):
        return self.name and self.name != self.generate_name()

    @property
    @cached(_cache)
    def has_booking_groups(self):
        return self.has_attribute('allowed-booking-group')

    @property
    @cached(_cache)
    def has_projector(self):
        return self.has_equipment(u'Computer Projector',
                                  u'Video projector 4:3',
                                  u'Video projector 16:9')

    @property
    @cached(_cache)
    def has_webcast_recording(self):
        return self.has_equipment('Webcast/Recording')

    @property
    @cached(_cache)
    def has_vc(self):
        return self.has_equipment('Video conference')

    @property
    @cached(_cache)
    def is_public(self):
        return self.is_reservable and not self.has_booking_groups

    @property
    def kind(self):
        if not self.is_reservable or self.has_booking_groups:
            return 'privateRoom'
        elif self.reservations_need_confirmation:
            return 'moderatedRoom'
        else:
            return 'basicRoom'

    @property
    def location_name(self):
        return self.location.name

    @property
    def marker_description(self):
        infos = []

        infos.append(u'{capacity} {label}'.format(
            capacity=self.capacity,
            label=_(u'person') if self.capacity == 1 else _(u'people')))
        infos.append(_(u'public') if self.is_public else _(u'private'))
        infos.append(
            _(u'auto-confirmation') if self.
            is_auto_confirm else _(u'needs confirmation'))
        if self.has_vc:
            infos.append(_(u'videoconference'))

        return u', '.join(map(unicode, infos))

    @property
    def manager_emails(self):
        manager_group = self.get_attribute_value('manager-group')
        if not manager_group:
            return set()
        group = GroupProxy.get_named_default_group(manager_group)
        return {u.email for u in group.get_members()}

    @property
    def notification_emails(self):
        return set(
            filter(
                None,
                map(
                    unicode.strip,
                    self.get_attribute_value(u'notification-email',
                                             u'').split(u','))))

    @return_ascii
    def __repr__(self):
        return u'<Room({0}, {1}, {2})>'.format(self.id, self.location_id,
                                               self.name)

    @cached(_cache)
    def has_equipment(self, *names):
        return self.available_equipment.filter(
            EquipmentType.name.in_(names)).count() > 0

    def find_available_vc_equipment(self):
        vc_equipment = (self.available_equipment.correlate(Room).with_entities(
            EquipmentType.id).filter_by(name='Video conference').as_scalar())
        return self.available_equipment.filter(
            EquipmentType.parent_id == vc_equipment)

    def get_attribute_by_name(self, attribute_name):
        return (self.attributes.join(RoomAttribute).filter(
            RoomAttribute.name == attribute_name).first())

    def has_attribute(self, attribute_name):
        return self.get_attribute_by_name(attribute_name) is not None

    @cached(_cache)
    def get_attribute_value(self, name, default=None):
        attr = self.get_attribute_by_name(name)
        return attr.value if attr else default

    def set_attribute_value(self, name, value):
        attr = self.get_attribute_by_name(name)
        if attr:
            if value:
                attr.value = value
            else:
                self.attributes.filter(RoomAttributeAssociation.attribute_id == attr.attribute_id) \
                    .delete(synchronize_session='fetch')
        elif value:
            attr = self.location.get_attribute_by_name(name)
            if not attr:
                raise ValueError(
                    "Attribute {} not supported in location {}".format(
                        name, self.location_name))
            attr_assoc = RoomAttributeAssociation()
            attr_assoc.value = value
            attr_assoc.attribute = attr
            self.attributes.append(attr_assoc)
        db.session.flush()

    @locator_property
    def locator(self):
        return {'roomLocation': self.location_name, 'roomID': self.id}

    def generate_name(self):
        return u'{}-{}-{}'.format(self.building, self.floor, self.number)

    def update_name(self):
        if not self.has_special_name and self.building and self.floor and self.number:
            self.name = self.generate_name()

    @classmethod
    def find_all(cls, *args, **kwargs):
        """Retrieves rooms, sorted by location and full name"""
        rooms = super(Room, cls).find_all(*args, **kwargs)
        rooms.sort(
            key=lambda r: natural_sort_key(r.location_name + r.full_name))
        return rooms

    @classmethod
    def find_with_attribute(cls, attribute):
        """Search rooms which have a specific attribute"""
        return (Room.query.with_entities(
            Room, RoomAttributeAssociation.value).join(
                Room.attributes, RoomAttributeAssociation.attribute).filter(
                    RoomAttribute.name == attribute).all())

    @staticmethod
    def get_with_data(*args, **kwargs):
        from fossir.modules.rb.models.locations import Location

        only_active = kwargs.pop('only_active', True)
        filters = kwargs.pop('filters', None)
        order = kwargs.pop(
            'order',
            [Location.name, Room.building, Room.floor, Room.number, Room.name])
        if kwargs:
            raise ValueError('Unexpected kwargs: {}'.format(kwargs))

        query = Room.query
        entities = [Room]

        if 'equipment' in args:
            entities.append(static_array.array_agg(EquipmentType.name))
            query = query.outerjoin(RoomEquipmentAssociation).outerjoin(
                EquipmentType)
        if 'vc_equipment' in args or 'non_vc_equipment' in args:
            vc_id_subquery = db.session.query(EquipmentType.id) \
                                       .correlate(Room) \
                                       .filter_by(name='Video conference') \
                                       .join(RoomEquipmentAssociation) \
                                       .filter(RoomEquipmentAssociation.c.room_id == Room.id) \
                                       .as_scalar()

            if 'vc_equipment' in args:
                # noinspection PyTypeChecker
                entities.append(
                    static_array.array(
                        db.session.query(EquipmentType.name).join(
                            RoomEquipmentAssociation).filter(
                                RoomEquipmentAssociation.c.room_id == Room.id,
                                EquipmentType.parent_id ==
                                vc_id_subquery).order_by(
                                    EquipmentType.name).as_scalar()))
            if 'non_vc_equipment' in args:
                # noinspection PyTypeChecker
                entities.append(
                    static_array.array(
                        db.session.query(EquipmentType.name).join(
                            RoomEquipmentAssociation).filter(
                                RoomEquipmentAssociation.c.room_id == Room.id,
                                (EquipmentType.parent_id == None) |
                                (EquipmentType.parent_id !=
                                 vc_id_subquery)).order_by(
                                     EquipmentType.name).as_scalar()))

        query = (query.with_entities(*entities).outerjoin(
            Location,
            Location.id == Room.location_id).group_by(Location.name, Room.id))

        if only_active:
            query = query.filter(Room.is_active)
        if filters:  # pragma: no cover
            query = query.filter(*filters)
        if order:  # pragma: no cover
            query = query.order_by(*order)

        keys = ('room', ) + tuple(args)
        return (dict(zip(keys, row if args else [row])) for row in query)

    @classproperty
    @staticmethod
    def max_capacity():
        return db.session.query(db.func.max(Room.capacity)).scalar() or 0

    @staticmethod
    def filter_available(start_dt,
                         end_dt,
                         repetition,
                         include_pre_bookings=True,
                         include_pending_blockings=True):
        """Returns a SQLAlchemy filter criterion ensuring that the room is available during the given time."""
        # Check availability against reservation occurrences
        dummy_occurrences = ReservationOccurrence.create_series(
            start_dt, end_dt, repetition)
        overlap_criteria = ReservationOccurrence.filter_overlap(
            dummy_occurrences)
        reservation_criteria = [
            Reservation.room_id == Room.id, ReservationOccurrence.is_valid,
            overlap_criteria
        ]
        if not include_pre_bookings:
            reservation_criteria.append(Reservation.is_accepted)
        occurrences_filter = Reservation.occurrences.any(
            and_(*reservation_criteria))
        # Check availability against blockings
        if include_pending_blockings:
            valid_states = (BlockedRoom.State.accepted,
                            BlockedRoom.State.pending)
        else:
            valid_states = (BlockedRoom.State.accepted, )
        blocking_criteria = [
            BlockedRoom.blocking_id == Blocking.id,
            BlockedRoom.state.in_(valid_states),
            Blocking.start_date <= start_dt.date(),
            Blocking.end_date >= end_dt.date()
        ]
        blockings_filter = Room.blocked_rooms.any(and_(*blocking_criteria))
        return ~occurrences_filter & ~blockings_filter

    @staticmethod
    def find_with_filters(filters, user=None):
        from fossir.modules.rb.models.locations import Location

        equipment_count = len(filters.get('available_equipment', ()))
        equipment_subquery = None
        if equipment_count:
            equipment_subquery = (
                db.session.query(RoomEquipmentAssociation).with_entities(
                    func.count(RoomEquipmentAssociation.c.room_id)).filter(
                        RoomEquipmentAssociation.c.room_id == Room.id,
                        RoomEquipmentAssociation.c.equipment_id.in_(
                            eq.id for eq in filters['available_equipment'])
                    ).correlate(Room).as_scalar())

        capacity = filters.get('capacity')
        q = (Room.query.join(Location.rooms).filter(
            Location.id == filters['location'].id if filters.get('location')
            else True, ((Room.capacity >= (capacity * 0.8)) |
                        (Room.capacity == None)) if capacity else True,
            Room.is_reservable if filters.get('is_only_public') else True,
            Room.is_auto_confirm if filters.get('is_auto_confirm') else True,
            Room.is_active if filters.get('is_only_active', False) else True,
            (equipment_subquery == equipment_count)
            if equipment_subquery is not None else True))

        if filters.get('available', -1) != -1:
            repetition = RepeatMapping.convert_legacy_repeatability(
                ast.literal_eval(filters['repeatability']))
            is_available = Room.filter_available(
                filters['start_dt'],
                filters['end_dt'],
                repetition,
                include_pre_bookings=filters.get('include_pre_bookings', True),
                include_pending_blockings=filters.get(
                    'include_pending_blockings', True))
            # Filter the search results
            if filters['available'] == 0:  # booked/unavailable
                q = q.filter(~is_available)
            elif filters['available'] == 1:  # available
                q = q.filter(is_available)
            else:
                raise ValueError('Unexpected availability value')

        free_search_columns = ('name', 'site', 'division', 'building', 'floor',
                               'number', 'telephone', 'key_location',
                               'comments')
        if filters.get('details'):
            # Attributes are stored JSON-encoded, so we need to JSON-encode the provided string and remove the quotes
            # afterwards since PostgreSQL currently does not expose a function to decode a JSON string:
            # http://www.postgresql.org/message-id/[email protected]
            details = filters['details'].lower()
            details_str = u'%{}%'.format(escape_like(details))
            details_json = u'%{}%'.format(
                escape_like(json.dumps(details)[1:-1]))
            free_search_criteria = [
                getattr(Room, c).ilike(details_str)
                for c in free_search_columns
            ]
            free_search_criteria.append(
                Room.attributes.any(
                    cast(RoomAttributeAssociation.value,
                         db.String).ilike(details_json)))
            q = q.filter(or_(*free_search_criteria))

        q = q.order_by(Room.capacity)
        rooms = q.all()
        # Apply a bunch of filters which are *much* easier to do here than in SQL!
        if filters.get('is_only_public'):
            # This may trigger additional SQL queries but is_public is cached and doing this check here is *much* easier
            rooms = [r for r in rooms if r.is_public]
        if filters.get('is_only_my_rooms'):
            assert user is not None
            rooms = [r for r in rooms if r.is_owned_by(user)]
        if capacity:
            # Unless it would result in an empty resultset we don't want to show rooms with >20% more capacity
            # than requested. This cannot be done easily in SQL so we do that logic here after the SQL query already
            # weeded out rooms that are too small
            matching_capacity_rooms = [
                r for r in rooms
                if r.capacity is None or r.capacity <= capacity * 1.2
            ]
            if matching_capacity_rooms:
                rooms = matching_capacity_rooms
        return rooms

    def has_live_reservations(self):
        return self.reservations.filter_by(is_archived=False,
                                           is_cancelled=False,
                                           is_rejected=False).count() > 0

    def get_blocked_rooms(self, *dates, **kwargs):
        states = kwargs.get('states', (BlockedRoom.State.accepted, ))
        return (self.blocked_rooms.join(BlockedRoom.blocking).options(
            contains_eager(BlockedRoom.blocking)).filter(
                or_(Blocking.is_active_at(d) for d in dates),
                BlockedRoom.state.in_(states)).all())

    @unify_user_args
    def _can_be_booked(self, user, prebook=False, ignore_admin=False):
        if not user or not rb_check_user_access(user):
            return False

        if (not ignore_admin and rb_is_admin(user)) or (self.is_owned_by(user)
                                                        and self.is_active):
            return True

        if self.is_active and self.is_reservable and (
                prebook or not self.reservations_need_confirmation):
            group_name = self.get_attribute_value('allowed-booking-group')
            if not group_name or user in GroupProxy.get_named_default_group(
                    group_name):
                return True

        return False

    def can_be_booked(self, user, ignore_admin=False):
        """
        Reservable rooms which does not require pre-booking can be booked by anyone.
        Other rooms - only by their responsibles.
        """
        return self._can_be_booked(user, ignore_admin=ignore_admin)

    def can_be_prebooked(self, user, ignore_admin=False):
        """
        Reservable rooms can be pre-booked by anyone.
        Other rooms - only by their responsibles.
        """
        return self._can_be_booked(user,
                                   prebook=True,
                                   ignore_admin=ignore_admin)

    def can_be_overridden(self, user):
        if not user:
            return False
        return rb_is_admin(user) or self.is_owned_by(user)

    def can_be_modified(self, user):
        """Only admin can modify rooms."""
        if not user:
            return False
        return rb_is_admin(user)

    def can_be_deleted(self, user):
        return self.can_be_modified(user)

    @unify_user_args
    @cached(_cache)
    def is_owned_by(self, user):
        """Checks if the user is managing the room (owner or manager)"""
        if self.owner == user:
            return True
        manager_group = self.get_attribute_value('manager-group')
        if not manager_group:
            return False
        return user in GroupProxy.get_named_default_group(manager_group)

    @classmethod
    def get_owned_by(cls, user):
        return [
            room for room in cls.find(is_active=True) if room.is_owned_by(user)
        ]

    @classmethod
    def user_owns_rooms(cls, user):
        return any(room for room in cls.find(is_active=True)
                   if room.is_owned_by(user))

    def check_advance_days(self, end_date, user=None, quiet=False):
        if not self.max_advance_days:
            return True
        if user and (rb_is_admin(user) or self.is_owned_by(user)):
            return True
        advance_days = (end_date - date.today()).days
        ok = advance_days < self.max_advance_days
        if quiet or ok:
            return ok
        else:
            msg = _(u'You cannot book this room more than {} days in advance')
            raise NoReportError(msg.format(self.max_advance_days))

    def check_bookable_hours(self,
                             start_time,
                             end_time,
                             user=None,
                             quiet=False):
        if user and (rb_is_admin(user) or self.is_owned_by(user)):
            return True
        bookable_hours = self.bookable_hours.all()
        if not bookable_hours:
            return True
        for bt in bookable_hours:
            if bt.fits_period(start_time, end_time):
                return True
        if quiet:
            return False
        raise NoReportError(u'Room cannot be booked at this time')
示例#13
0
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)
示例#14
0
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with fossir; if not, see <http://www.gnu.org/licenses/>.

from __future__ import unicode_literals

from fossir.core.db.sqlalchemy import db

_reviewed_for_tracks = db.Table(
    'reviewed_for_tracks',
    db.metadata,
    db.Column('abstract_id',
              db.Integer,
              db.ForeignKey('event_abstracts.abstracts.id'),
              primary_key=True,
              autoincrement=False,
              index=True),
    db.Column('track_id',
              db.Integer,
              db.ForeignKey('events.tracks.id', ondelete='CASCADE'),
              primary_key=True,
              autoincrement=False,
              index=True),
    schema='event_abstracts')

_submitted_for_tracks = db.Table(
    'submitted_for_tracks',
    db.metadata,
    db.Column('abstract_id',
              db.Integer,
示例#15
0
class Track(DescriptionMixin, db.Model):
    __tablename__ = 'tracks'
    __table_args__ = {'schema': 'events'}

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

    id = db.Column(
        db.Integer,
        primary_key=True
    )
    title = db.Column(
        db.String,
        nullable=False
    )
    code = db.Column(
        db.String,
        nullable=False,
        default=''
    )
    event_id = db.Column(
        db.Integer,
        db.ForeignKey('events.events.id'),
        index=True,
        nullable=False
    )
    position = db.Column(
        db.Integer,
        nullable=False,
        default=_get_next_position
    )

    event = db.relationship(
        'Event',
        lazy=True,
        backref=db.backref(
            'tracks',
            cascade='all, delete-orphan',
            lazy=True,
            order_by=position
        )
    )
    abstract_reviewers = db.relationship(
        'User',
        secondary='events.track_abstract_reviewers',
        collection_class=set,
        lazy=True,
        backref=db.backref(
            'abstract_reviewer_for_tracks',
            collection_class=set,
            lazy=True
        )
    )
    conveners = db.relationship(
        'User',
        secondary='events.track_conveners',
        collection_class=set,
        lazy=True,
        backref=db.backref(
            'convener_for_tracks',
            collection_class=set,
            lazy=True
        )
    )

    # relationship backrefs:
    # - abstract_reviews (AbstractReview.track)
    # - abstracts_accepted (Abstract.accepted_track)
    # - abstracts_reviewed (Abstract.reviewed_for_tracks)
    # - abstracts_submitted (Abstract.submitted_for_tracks)
    # - contributions (Contribution.track)
    # - proposed_abstract_reviews (AbstractReview.proposed_tracks)

    @property
    def short_title(self):
        return self.code if self.code else self.title

    @property
    def full_title(self):
        return '{} - {}'.format(self.code, self.title) if self.code else self.title

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

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

    def can_delete(self, user):
        return self.event.can_manage(user) and not self.abstracts_accepted

    def can_review_abstracts(self, user):
        if not user:
            return False
        elif not self.event.can_manage(user, role='abstract_reviewer', explicit_role=True):
            return False
        elif user in self.event.global_abstract_reviewers:
            return True
        elif user in self.abstract_reviewers:
            return True
        else:
            return False

    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 user in self.event.global_conveners:
            return True
        elif user in self.conveners:
            return True
        else:
            return False
示例#16
0
 def _phone(cls):
     return db.Column('phone', db.String, nullable=True)
示例#17
0
 def _affiliation(cls):
     return db.Column('affiliation', db.String, nullable=True)
示例#18
0
 def _last_name(cls):
     return db.Column('last_name', db.String, nullable=True)
示例#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)
from __future__ import unicode_literals

from fossir.core.db.sqlalchemy import db

_track_abstract_reviewers_table = db.Table(
    'track_abstract_reviewers',
    db.metadata,
    db.Column('id', db.Integer, primary_key=True),
    db.Column('user_id',
              db.Integer,
              db.ForeignKey('users.users.id'),
              index=True,
              nullable=False),
    db.Column('event_id',
              db.Integer,
              db.ForeignKey('events.events.id'),
              index=True),
    db.Column('track_id',
              db.Integer,
              db.ForeignKey('events.tracks.id'),
              index=True),
    db.CheckConstraint('(track_id IS NULL) != (event_id IS NULL)',
                       name='track_xor_event_id_null'),
    schema='events')
示例#21
0
 def _title(cls):
     return db.Column('title', PyIntEnum(UserTitle), nullable=True)
 def user_id(cls):
     return db.Column(db.Integer,
                      db.ForeignKey('users.users.id'),
                      nullable=True,
                      index=True)
示例#23
0
 def _address(cls):
     return db.Column('address', db.Text, nullable=True)
 def local_group_id(cls):
     return db.Column(db.Integer,
                      db.ForeignKey('users.groups.id'),
                      nullable=True,
                      index=True)
示例#25
0
 def display_order(cls):
     return db.Column(db.Integer, nullable=False, default=0)
 def multipass_group_name(cls):
     return db.Column(
         'mp_group_name',  # otherwise the index name doesn't fit in 60 chars
         db.String,
         nullable=True)
示例#27
0
class EventPerson(PersonMixin, db.Model):
    """A person inside an event, e.g. a speaker/author etc."""

    __tablename__ = 'persons'
    __table_args__ = (db.UniqueConstraint('event_id', 'user_id'),
                      db.CheckConstraint('email = lower(email)',
                                         'lowercase_email'),
                      db.Index(None,
                               'event_id',
                               'email',
                               unique=True,
                               postgresql_where=db.text("email != ''")), {
                                   'schema': 'events'
                               })

    id = db.Column(db.Integer, primary_key=True)
    event_id = db.Column(db.Integer,
                         db.ForeignKey('events.events.id'),
                         nullable=False,
                         index=True)
    user_id = db.Column(db.Integer,
                        db.ForeignKey('users.users.id'),
                        nullable=True,
                        index=True)
    first_name = db.Column(db.String, nullable=False, default='')
    last_name = db.Column(db.String, nullable=False)
    email = db.Column(db.String, nullable=False, index=True, default='')
    # the title of the user - you usually want the `title` property!
    _title = db.Column('title',
                       PyIntEnum(UserTitle),
                       nullable=False,
                       default=UserTitle.none)
    affiliation = db.Column(db.String, nullable=False, default='')
    address = db.Column(db.Text, nullable=False, default='')
    phone = db.Column(db.String, nullable=False, default='')
    invited_dt = db.Column(UTCDateTime, nullable=True)
    is_untrusted = db.Column(db.Boolean, nullable=False, default=False)

    event = db.relationship('Event',
                            lazy=True,
                            backref=db.backref('persons',
                                               cascade='all, delete-orphan',
                                               cascade_backrefs=False,
                                               lazy='dynamic'))
    user = db.relationship('User',
                           lazy=True,
                           backref=db.backref('event_persons',
                                              cascade_backrefs=False,
                                              lazy='dynamic'))

    # relationship backrefs:
    # - abstract_links (AbstractPersonLink.person)
    # - contribution_links (ContributionPersonLink.person)
    # - event_links (EventPersonLink.person)
    # - session_block_links (SessionBlockPersonLink.person)
    # - subcontribution_links (SubContributionPersonLink.person)

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

    @return_ascii
    def __repr__(self):
        return format_repr(self,
                           'id',
                           is_untrusted=False,
                           _text=self.full_name)

    @property
    def principal(self):
        if self.user is not None:
            return self.user
        elif self.email:
            return EmailPrincipal(self.email)
        return None

    @classmethod
    def create_from_user(cls, user, event=None, is_untrusted=False):
        return EventPerson(user=user,
                           event=event,
                           first_name=user.first_name,
                           last_name=user.last_name,
                           email=user.email,
                           affiliation=user.affiliation,
                           address=user.address,
                           phone=user.phone,
                           is_untrusted=is_untrusted)

    @classmethod
    def for_user(cls, user, event=None, is_untrusted=False):
        """Return EventPerson for a matching User in Event creating if needed"""
        person = event.persons.filter_by(user=user).first() if event else None
        return person or cls.create_from_user(
            user, event, is_untrusted=is_untrusted)

    @classmethod
    def merge_users(cls, target, source):
        """Merge the EventPersons of two users.

        :param target: The target user of the merge
        :param source: The user that is being merged into `target`
        """
        existing_persons = {ep.event_id: ep for ep in target.event_persons}
        for event_person in source.event_persons:
            existing = existing_persons.get(event_person.event_id)
            if existing is None:
                event_person.user = target
            else:
                existing.merge_person_info(event_person)
                db.session.delete(event_person)
        db.session.flush()

    @classmethod
    def link_user_by_email(cls, user):
        """
        Links all email-based persons matching the user's
        email addresses with the user.

        :param user: A User object.
        """
        from fossir.modules.events.models.events import Event
        query = (cls.query.join(EventPerson.event).filter(
            ~Event.is_deleted, cls.email.in_(user.all_emails),
            cls.user_id.is_(None)))
        for event_person in query:
            existing = (cls.query.filter_by(
                user_id=user.id, event_id=event_person.event_id).one_or_none())
            if existing is None:
                event_person.user = user
            else:
                existing.merge_person_info(event_person)
                db.session.delete(event_person)
        db.session.flush()

    @no_autoflush
    def merge_person_info(self, other):
        from fossir.modules.events.contributions.models.persons import AuthorType
        for column_name in {
                '_title', 'affiliation', 'address', 'phone', 'first_name',
                'last_name'
        }:
            value = getattr(self, column_name) or getattr(other, column_name)
            setattr(self, column_name, value)

        for event_link in other.event_links:
            existing_event_link = next(
                (link for link in self.event_links
                 if link.event_id == event_link.event_id), None)
            if existing_event_link is None:
                event_link.person = self
            else:
                other.event_links.remove(event_link)

        for abstract_link in other.abstract_links:
            existing_abstract_link = next(
                (link for link in self.abstract_links
                 if link.abstract_id == abstract_link.abstract_id), None)

            if existing_abstract_link is None:
                abstract_link.person = self
            else:
                existing_abstract_link.is_speaker |= abstract_link.is_speaker
                existing_abstract_link.author_type = AuthorType.get_highest(
                    existing_abstract_link.author_type,
                    abstract_link.author_type)
                other.abstract_links.remove(abstract_link)

        for contribution_link in other.contribution_links:
            existing_contribution_link = next(
                (link for link in self.contribution_links
                 if link.contribution_id == contribution_link.contribution_id),
                None)

            if existing_contribution_link is None:
                contribution_link.person = self
            else:
                existing_contribution_link.is_speaker |= contribution_link.is_speaker
                existing_contribution_link.author_type = AuthorType.get_highest(
                    existing_contribution_link.author_type,
                    contribution_link.author_type)
                other.contribution_links.remove(contribution_link)

        for subcontribution_link in other.subcontribution_links:
            existing_subcontribution_link = next(
                (link for link in self.subcontribution_links
                 if link.subcontribution_id ==
                 subcontribution_link.subcontribution_id), None)
            if existing_subcontribution_link is None:
                subcontribution_link.person = self
            else:
                other.subcontribution_links.remove(subcontribution_link)

        for session_block_link in other.session_block_links:
            existing_session_block_link = next(
                (link
                 for link in self.session_block_links if link.session_block_id
                 == session_block_link.session_block_id), None)
            if existing_session_block_link is None:
                session_block_link.person = self
            else:
                other.session_block_links.remove(session_block_link)

        db.session.flush()

    def has_role(self, role, obj):
        """Whether the person has a role in the ACL list of a given object"""
        principals = [
            x for x in obj.acl_entries
            if x.has_management_role(role, explicit=True)
        ]
        return any(
            x for x in principals
            if ((self.user_id is not None and self.user_id == x.user_id) or (
                self.email is not None and self.email == x.email)))
 def email(cls):
     if not cls.allow_emails:
         return
     return db.Column(db.String, nullable=True, index=True)
示例#29
0
class Event(SearchableTitleMixin, DescriptionMixin, LocationMixin, ProtectionManagersMixin, AttachedItemsMixin,
            AttachedNotesMixin, PersonLinkDataMixin, db.Model):
    """An fossir event

    This model contains the most basic information related to an event.

    Note that the ACL is currently only used for managers but not for
    view access!
    """
    __tablename__ = 'events'
    disallowed_protection_modes = frozenset()
    inheriting_have_acl = True
    allow_access_key = True
    allow_no_access_contact = True
    location_backref_name = 'events'
    allow_location_inheritance = False
    possible_render_modes = {RenderMode.html}
    default_render_mode = RenderMode.html
    __logging_disabled = False

    ATTACHMENT_FOLDER_ID_COLUMN = 'event_id'

    @strict_classproperty
    @classmethod
    def __auto_table_args(cls):
        return (db.Index('ix_events_start_dt_desc', cls.start_dt.desc()),
                db.Index('ix_events_end_dt_desc', cls.end_dt.desc()),
                db.Index('ix_events_not_deleted_category', cls.is_deleted, cls.category_id),
                db.Index('ix_events_not_deleted_category_dates',
                         cls.is_deleted, cls.category_id, cls.start_dt, cls.end_dt),
                db.Index('ix_uq_events_url_shortcut', db.func.lower(cls.url_shortcut), unique=True,
                         postgresql_where=db.text('NOT is_deleted')),
                db.CheckConstraint("category_id IS NOT NULL OR is_deleted", 'category_data_set'),
                db.CheckConstraint("(logo IS NULL) = (logo_metadata::text = 'null')", 'valid_logo'),
                db.CheckConstraint("(stylesheet IS NULL) = (stylesheet_metadata::text = 'null')",
                                   'valid_stylesheet'),
                db.CheckConstraint("end_dt >= start_dt", 'valid_dates'),
                db.CheckConstraint("url_shortcut != ''", 'url_shortcut_not_empty'),
                db.CheckConstraint("cloned_from_id != id", 'not_cloned_from_self'),
                db.CheckConstraint('visibility IS NULL OR visibility >= 0', 'valid_visibility'),
                {'schema': 'events'})

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

    #: The ID of the event
    id = db.Column(
        db.Integer,
        primary_key=True
    )
    #: If the event has been deleted
    is_deleted = db.Column(
        db.Boolean,
        nullable=False,
        default=False
    )
    #: If the event is locked (read-only mode)
    is_locked = db.Column(
        db.Boolean,
        nullable=False,
        default=False
    )
    #: The ID of the user who created the event
    creator_id = db.Column(
        db.Integer,
        db.ForeignKey('users.users.id'),
        nullable=False,
        index=True
    )
    #: The ID of immediate parent category of the event
    category_id = db.Column(
        db.Integer,
        db.ForeignKey('categories.categories.id'),
        nullable=True,
        index=True
    )
    #: The ID of the series this events belongs to
    series_id = db.Column(
        db.Integer,
        db.ForeignKey('events.series.id'),
        nullable=True,
        index=True
    )
    #: If this event was cloned, the id of the parent event
    cloned_from_id = db.Column(
        db.Integer,
        db.ForeignKey('events.events.id'),
        nullable=True,
        index=True,
    )
    #: The creation date of the event
    created_dt = db.Column(
        UTCDateTime,
        nullable=False,
        index=True,
        default=now_utc
    )
    #: The start date of the event
    start_dt = db.Column(
        UTCDateTime,
        nullable=False,
        index=True
    )
    #: The end date of the event
    end_dt = db.Column(
        UTCDateTime,
        nullable=False,
        index=True
    )
    #: The timezone of the event
    timezone = db.Column(
        db.String,
        nullable=False
    )
    #: The type of the event
    _type = db.Column(
        'type',
        PyIntEnum(EventType),
        nullable=False
    )
    #: The visibility depth in category overviews
    visibility = db.Column(
        db.Integer,
        nullable=True,
        default=None
    )
    #: A list of tags/keywords for the event
    keywords = db.Column(
        ARRAY(db.String),
        nullable=False,
        default=[],
    )
    #: The URL shortcut for the event
    url_shortcut = db.Column(
        db.String,
        nullable=True
    )
    #: The metadata of the logo (hash, size, filename, content_type)
    logo_metadata = db.Column(
        JSON,
        nullable=False,
        default=lambda: None
    )
    #: The logo's raw image data
    logo = db.deferred(db.Column(
        db.LargeBinary,
        nullable=True
    ))
    #: The metadata of the stylesheet (hash, size, filename)
    stylesheet_metadata = db.Column(
        JSON,
        nullable=False,
        default=lambda: None
    )
    #: The stylesheet's raw image data
    stylesheet = db.deferred(db.Column(
        db.Text,
        nullable=True
    ))
    #: The ID of the event's default page (conferences only)
    default_page_id = db.Column(
        db.Integer,
        db.ForeignKey('events.pages.id'),
        index=True,
        nullable=True
    )
    #: The last user-friendly registration ID
    _last_friendly_registration_id = db.deferred(db.Column(
        'last_friendly_registration_id',
        db.Integer,
        nullable=False,
        default=0
    ))
    #: The last user-friendly contribution ID
    _last_friendly_contribution_id = db.deferred(db.Column(
        'last_friendly_contribution_id',
        db.Integer,
        nullable=False,
        default=0
    ))
    #: The last user-friendly session ID
    _last_friendly_session_id = db.deferred(db.Column(
        'last_friendly_session_id',
        db.Integer,
        nullable=False,
        default=0
    ))

    #: The category containing the event
    category = db.relationship(
        'Category',
        lazy=True,
        backref=db.backref(
            'events',
            primaryjoin='(Category.id == Event.category_id) & ~Event.is_deleted',
            order_by=(start_dt, id),
            lazy=True
        )
    )
    #: The user who created the event
    creator = db.relationship(
        'User',
        lazy=True,
        backref=db.backref(
            'created_events',
            lazy='dynamic'
        )
    )
    #: The event this one was cloned from
    cloned_from = db.relationship(
        'Event',
        lazy=True,
        remote_side='Event.id',
        backref=db.backref(
            'clones',
            lazy=True,
            order_by=start_dt
        )
    )
    #: The event's default page (conferences only)
    default_page = db.relationship(
        'EventPage',
        lazy=True,
        foreign_keys=[default_page_id],
        post_update=True,
        # don't use this backref. we just need it so SA properly NULLs
        # this column when deleting the default page
        backref=db.backref('_default_page_of_event', lazy=True)
    )
    #: The ACL entries for the event
    acl_entries = db.relationship(
        'EventPrincipal',
        backref='event',
        cascade='all, delete-orphan',
        collection_class=set
    )
    #: External references associated with this event
    references = db.relationship(
        'EventReference',
        lazy=True,
        cascade='all, delete-orphan',
        backref=db.backref(
            'event',
            lazy=True
        )
    )
    #: Persons associated with this event
    person_links = db.relationship(
        'EventPersonLink',
        lazy=True,
        cascade='all, delete-orphan',
        backref=db.backref(
            'event',
            lazy=True
        )
    )
    #: The series this event is part of
    series = db.relationship(
        'EventSeries',
        lazy=True,
        backref=db.backref(
            'events',
            lazy=True,
            order_by=(start_dt, id),
            primaryjoin='(Event.series_id == EventSeries.id) & ~Event.is_deleted',
        )
    )

    #: Users who can review on all tracks
    global_abstract_reviewers = db.relationship(
        'User',
        secondary='events.track_abstract_reviewers',
        collection_class=set,
        lazy=True,
        backref=db.backref(
            'global_abstract_reviewer_for_events',
            collection_class=set,
            lazy=True
        )
    )

    #: Users who are conveners on all tracks
    global_conveners = db.relationship(
        'User',
        secondary='events.track_conveners',
        collection_class=set,
        lazy=True,
        backref=db.backref(
            'global_convener_for_events',
            collection_class=set,
            lazy=True
        )
    )

    # relationship backrefs:
    # - abstract_email_templates (AbstractEmailTemplate.event)
    # - abstract_review_questions (AbstractReviewQuestion.event)
    # - abstracts (Abstract.event)
    # - agreements (Agreement.event)
    # - all_attachment_folders (AttachmentFolder.event)
    # - all_legacy_attachment_folder_mappings (LegacyAttachmentFolderMapping.event)
    # - all_legacy_attachment_mappings (LegacyAttachmentMapping.event)
    # - all_notes (EventNote.event)
    # - all_vc_room_associations (VCRoomEventAssociation.event)
    # - attachment_folders (AttachmentFolder.linked_event)
    # - clones (Event.cloned_from)
    # - contribution_fields (ContributionField.event)
    # - contribution_types (ContributionType.event)
    # - contributions (Contribution.event)
    # - custom_pages (EventPage.event)
    # - designer_templates (DesignerTemplate.event)
    # - layout_images (ImageFile.event)
    # - legacy_contribution_mappings (LegacyContributionMapping.event)
    # - legacy_mapping (LegacyEventMapping.event)
    # - legacy_session_block_mappings (LegacySessionBlockMapping.event)
    # - legacy_session_mappings (LegacySessionMapping.event)
    # - legacy_subcontribution_mappings (LegacySubContributionMapping.event)
    # - log_entries (EventLogEntry.event)
    # - menu_entries (MenuEntry.event)
    # - note (EventNote.linked_event)
    # - paper_competences (PaperCompetence.event)
    # - paper_review_questions (PaperReviewQuestion.event)
    # - paper_templates (PaperTemplate.event)
    # - persons (EventPerson.event)
    # - registration_forms (RegistrationForm.event)
    # - registrations (Registration.event)
    # - reminders (EventReminder.event)
    # - requests (Request.event)
    # - reservations (Reservation.event)
    # - sessions (Session.event)
    # - settings (EventSetting.event)
    # - settings_principals (EventSettingPrincipal.event)
    # - static_list_links (StaticListLink.event)
    # - static_sites (StaticSite.event)
    # - surveys (Survey.event)
    # - timetable_entries (TimetableEntry.event)
    # - tracks (Track.event)
    # - vc_room_associations (VCRoomEventAssociation.linked_event)

    start_dt_override = _EventSettingProperty(event_core_settings, 'start_dt_override')
    end_dt_override = _EventSettingProperty(event_core_settings, 'end_dt_override')
    organizer_info = _EventSettingProperty(event_core_settings, 'organizer_info')
    additional_info = _EventSettingProperty(event_core_settings, 'additional_info')
    contact_title = _EventSettingProperty(event_contact_settings, 'title')
    contact_emails = _EventSettingProperty(event_contact_settings, 'emails')
    contact_phones = _EventSettingProperty(event_contact_settings, 'phones')

    @classmethod
    def category_chain_overlaps(cls, category_ids):
        """
        Create a filter that checks whether the event has any of the
        provided category ids in its parent chain.

        :param category_ids: A list of category ids or a single
                             category id
        """
        from fossir.modules.categories import Category
        if not isinstance(category_ids, (list, tuple, set)):
            category_ids = [category_ids]
        cte = Category.get_tree_cte()
        return (cte.c.id == Event.category_id) & cte.c.path.overlap(category_ids)

    @classmethod
    def is_visible_in(cls, category):
        """
        Create a filter that checks whether the event is visible in
        the specified category.
        """
        cte = category.visible_categories_cte
        return (db.exists(db.select([1]))
                .where(db.and_(cte.c.id == Event.category_id,
                               db.or_(Event.visibility.is_(None), Event.visibility > cte.c.level))))

    @property
    def event(self):
        """Convenience property so all event entities have it"""
        return self

    @property
    def has_logo(self):
        return self.logo_metadata is not None

    @property
    def has_stylesheet(self):
        return self.stylesheet_metadata is not None

    @property
    def theme(self):
        from fossir.modules.events.layout import layout_settings, theme_settings
        theme = layout_settings.get(self, 'timetable_theme')
        if theme and theme in theme_settings.get_themes_for(self.type):
            return theme
        else:
            return theme_settings.defaults[self.type]

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

    @property
    def logo_url(self):
        return url_for('event_images.logo_display', self, slug=self.logo_metadata['hash'])

    @property
    def participation_regform(self):
        return next((form for form in self.registration_forms if form.is_participation), None)

    @property
    @memoize_request
    def published_registrations(self):
        from fossir.modules.events.registration.util import get_published_registrations
        return get_published_registrations(self)

    @property
    def protection_parent(self):
        return self.category

    @property
    def start_dt_local(self):
        return self.start_dt.astimezone(self.tzinfo)

    @property
    def end_dt_local(self):
        return self.end_dt.astimezone(self.tzinfo)

    @property
    def start_dt_display(self):
        """
        The 'displayed start dt', which is usually the actual start dt,
        but may be overridden for a conference.
        """
        if self.type_ == EventType.conference and self.start_dt_override:
            return self.start_dt_override
        else:
            return self.start_dt

    @property
    def end_dt_display(self):
        """
        The 'displayed end dt', which is usually the actual end dt,
        but may be overridden for a conference.
        """
        if self.type_ == EventType.conference and self.end_dt_override:
            return self.end_dt_override
        else:
            return self.end_dt

    @property
    def type(self):
        # XXX: this should eventually be replaced with the type_
        # property returning the enum - but there are too many places
        # right now that rely on the type string
        return self.type_.name

    @hybrid_property
    def type_(self):
        return self._type

    @type_.setter
    def type_(self, value):
        old_type = self._type
        self._type = value
        if old_type is not None and old_type != value:
            signals.event.type_changed.send(self, old_type=old_type)

    @property
    def url(self):
        return url_for('events.display', self)

    @property
    def external_url(self):
        return url_for('events.display', self, _external=True)

    @property
    def short_url(self):
        id_ = self.url_shortcut or self.id
        return url_for('events.shorturl', confId=id_)

    @property
    def short_external_url(self):
        id_ = self.url_shortcut or self.id
        return url_for('events.shorturl', confId=id_, _external=True)

    @property
    def tzinfo(self):
        return pytz.timezone(self.timezone)

    @property
    def display_tzinfo(self):
        """The tzinfo of the event as preferred by the current user"""
        return get_display_tz(self, as_timezone=True)

    @property
    @contextmanager
    def logging_disabled(self):
        """Temporarily disables event logging

        This is useful when performing actions e.g. during event
        creation or at other times where adding entries to the event
        log doesn't make sense.
        """
        self.__logging_disabled = True
        try:
            yield
        finally:
            self.__logging_disabled = False

    @hybrid_method
    def happens_between(self, from_dt=None, to_dt=None):
        """Check whether the event takes place within two dates"""
        if from_dt is not None and to_dt is not None:
            # any event that takes place during the specified range
            return overlaps((self.start_dt, self.end_dt), (from_dt, to_dt), inclusive=True)
        elif from_dt is not None:
            # any event that starts on/after the specified date
            return self.start_dt >= from_dt
        elif to_dt is not None:
            # any event that ends on/before the specifed date
            return self.end_dt <= to_dt
        else:
            return True

    @happens_between.expression
    def happens_between(cls, from_dt=None, to_dt=None):
        if from_dt is not None and to_dt is not None:
            # any event that takes place during the specified range
            return db_dates_overlap(cls, 'start_dt', from_dt, 'end_dt', to_dt, inclusive=True)
        elif from_dt is not None:
            # any event that starts on/after the specified date
            return cls.start_dt >= from_dt
        elif to_dt is not None:
            # any event that ends on/before the specifed date
            return cls.end_dt <= to_dt
        else:
            return True

    @hybrid_method
    def starts_between(self, from_dt=None, to_dt=None):
        """Check whether the event starts within two dates"""
        if from_dt is not None and to_dt is not None:
            return from_dt <= self.start_dt <= to_dt
        elif from_dt is not None:
            return self.start_dt >= from_dt
        elif to_dt is not None:
            return self.start_dt <= to_dt
        else:
            return True

    @starts_between.expression
    def starts_between(cls, from_dt=None, to_dt=None):
        if from_dt is not None and to_dt is not None:
            return cls.start_dt.between(from_dt, to_dt)
        elif from_dt is not None:
            return cls.start_dt >= from_dt
        elif to_dt is not None:
            return cls.start_dt <= to_dt
        else:
            return True

    @hybrid_method
    def ends_after(self, dt):
        """Check whether the event ends on/after the specified date"""
        return self.end_dt >= dt if dt is not None else True

    @ends_after.expression
    def ends_after(cls, dt):
        return cls.end_dt >= dt if dt is not None else True

    @hybrid_property
    def duration(self):
        return self.end_dt - self.start_dt

    def can_lock(self, user):
        """Check whether the user can lock/unlock the event"""
        return user and (user.is_admin or user == self.creator or self.category.can_manage(user))

    def get_relative_event_ids(self):
        """Get the first, last, previous and next event IDs.

        Any of those values may be ``None`` if there is no matching
        event or if it would be the current event.

        :return: A dict containing ``first``, ``last``, ``prev`` and ``next``.
        """
        subquery = (select([Event.id,
                            db.func.first_value(Event.id).over(order_by=(Event.start_dt, Event.id)).label('first'),
                            db.func.last_value(Event.id).over(order_by=(Event.start_dt, Event.id),
                                                              range_=(None, None)).label('last'),
                            db.func.lag(Event.id).over(order_by=(Event.start_dt, Event.id)).label('prev'),
                            db.func.lead(Event.id).over(order_by=(Event.start_dt, Event.id)).label('next')])
                    .where((Event.category_id == self.category_id) & ~Event.is_deleted)
                    .alias())
        rv = (db.session.query(subquery.c.first, subquery.c.last, subquery.c.prev, subquery.c.next)
              .filter(subquery.c.id == self.id)
              .one()
              ._asdict())
        if rv['first'] == self.id:
            rv['first'] = None
        if rv['last'] == self.id:
            rv['last'] = None
        return rv

    def get_verbose_title(self, show_speakers=False, show_series_pos=False):
        """Get the event title with some additional information

        :param show_speakers: Whether to prefix the title with the
                              speakers of the event.
        :param show_series_pos: Whether to suffix the title with the
                                position and total count in the event's
                                series.
        """
        title = self.title
        if show_speakers and self.person_links:
            speakers = ', '.join(sorted([pl.full_name for pl in self.person_links], key=unicode.lower))
            title = '{}, "{}"'.format(speakers, title)
        if show_series_pos and self.series and self.series.show_sequence_in_title:
            title = '{} ({}/{})'.format(title, self.series_pos, self.series_count)
        return title

    def get_non_inheriting_objects(self):
        """Get a set of child objects that do not inherit protection"""
        return get_non_inheriting_objects(self)

    def get_contribution(self, id_):
        """Get a contribution of the event"""
        return get_related_object(self, 'contributions', {'id': id_})

    def get_session(self, id_=None, friendly_id=None):
        """Get a session of the event"""
        if friendly_id is None and id_ is not None:
            criteria = {'id': id_}
        elif id_ is None and friendly_id is not None:
            criteria = {'friendly_id': friendly_id}
        else:
            raise ValueError('Exactly one kind of id must be specified')
        return get_related_object(self, 'sessions', criteria)

    def get_session_block(self, id_, scheduled_only=False):
        """Get a session block of the event"""
        from fossir.modules.events.sessions.models.blocks import SessionBlock
        query = SessionBlock.query.filter(SessionBlock.id == id_,
                                          SessionBlock.session.has(event=self, is_deleted=False))
        if scheduled_only:
            query.filter(SessionBlock.timetable_entry != None)  # noqa
        return query.first()

    def get_allowed_sender_emails(self, include_current_user=True, include_creator=True, include_managers=True,
                                  include_contact=True, include_chairs=True, extra=None):
        """
        Return the emails of people who can be used as senders (or
        rather Reply-to contacts) in emails sent from within an event.

        :param include_current_user: Whether to include the email of
                                     the currently logged-in user
        :param include_creator: Whether to include the email of the
                                event creator
        :param include_managers: Whether to include the email of all
                                 event managers
        :param include_contact: Whether to include the "event contact"
                                emails
        :param include_chairs: Whether to include the emails of event
                               chairpersons (or lecture speakers)
        :param extra: An email address that is always included, even
                      if it is not in any of the included lists.
        :return: An OrderedDict mapping emails to pretty names
        """
        emails = {}
        # Contact/Support
        if include_contact:
            for email in self.contact_emails:
                emails[email] = self.contact_title
        # Current user
        if include_current_user and has_request_context() and session.user:
            emails[session.user.email] = session.user.full_name
        # Creator
        if include_creator:
            emails[self.creator.email] = self.creator.full_name
        # Managers
        if include_managers:
            emails.update((p.principal.email, p.principal.full_name)
                          for p in self.acl_entries
                          if p.type == PrincipalType.user and p.full_access)
        # Chairs
        if include_chairs:
            emails.update((pl.email, pl.full_name) for pl in self.person_links if pl.email)
        # Extra email (e.g. the current value in an object from the DB)
        if extra:
            emails.setdefault(extra, extra)
        # Sanitize and format emails
        emails = {to_unicode(email.strip().lower()): '{} <{}>'.format(to_unicode(name), to_unicode(email))
                  for email, name in emails.iteritems()
                  if email and email.strip()}
        own_email = session.user.email if has_request_context() and session.user else None
        return OrderedDict(sorted(emails.items(), key=lambda x: (x[0] != own_email, x[1].lower())))

    @memoize_request
    def has_feature(self, feature):
        """Checks if a feature is enabled for the event"""
        from fossir.modules.events.features.util import is_feature_enabled
        return is_feature_enabled(self, feature)

    @property
    @memoize_request
    def scheduled_notes(self):
        from fossir.modules.events.notes.util import get_scheduled_notes
        return get_scheduled_notes(self)

    def log(self, realm, kind, module, summary, user=None, type_='simple', data=None):
        """Creates a new log entry for the event

        :param realm: A value from :class:`.EventLogRealm` indicating
                      the realm of the action.
        :param kind: A value from :class:`.EventLogKind` indicating
                     the kind of the action that was performed.
        :param module: A human-friendly string describing the module
                       related to the action.
        :param summary: A one-line summary describing the logged action.
        :param user: The user who performed the action.
        :param type_: The type of the log entry. This is used for custom
                      rendering of the log message/data
        :param data: JSON-serializable data specific to the log type.
        :return: The newly created `EventLogEntry`

        In most cases the ``simple`` log type is fine. For this type,
        any items from data will be shown in the detailed view of the
        log entry.  You may either use a dict (which will be sorted)
        alphabetically or a list of ``key, value`` pairs which will
        be displayed in the given order.
        """
        if self.__logging_disabled:
            return
        entry = EventLogEntry(user=user, realm=realm, kind=kind, module=module, type=type_, summary=summary,
                              data=data or {})
        self.log_entries.append(entry)
        return entry

    def get_contribution_field(self, field_id):
        return next((v for v in self.contribution_fields if v.id == field_id), '')

    def move_start_dt(self, start_dt):
        """Set event start_dt and adjust its timetable entries"""
        diff = start_dt - self.start_dt
        for entry in self.timetable_entries.filter(TimetableEntry.parent_id.is_(None)):
            new_dt = entry.start_dt + diff
            entry.move(new_dt)
        self.start_dt = start_dt

    def iter_days(self, tzinfo=None):
        start_dt = self.start_dt
        end_dt = self.end_dt
        if tzinfo:
            start_dt = start_dt.astimezone(tzinfo)
            end_dt = end_dt.astimezone(tzinfo)
        duration = (end_dt - start_dt).days
        for offset in xrange(duration + 1):
            yield (start_dt + timedelta(days=offset)).date()

    def preload_all_acl_entries(self):
        db.m.Contribution.preload_acl_entries(self)
        db.m.Session.preload_acl_entries(self)

    def move(self, category):
        old_category = self.category
        self.category = category
        db.session.flush()
        signals.event.moved.send(self, old_parent=old_category)

    def delete(self, reason, user=None):
        from fossir.modules.events import logger, EventLogRealm, EventLogKind
        self.is_deleted = True
        signals.event.deleted.send(self, user=user)
        db.session.flush()
        logger.info('Event %r deleted [%s]', self, reason)
        self.log(EventLogRealm.event, EventLogKind.negative, 'Event', 'Event deleted', user, data={'Reason': reason})

    @property
    @memoize_request
    def cfa(self):
        from fossir.modules.events.abstracts.models.call_for_abstracts import CallForAbstracts
        return CallForAbstracts(self)

    @property
    @memoize_request
    def cfp(self):
        from fossir.modules.events.papers.models.call_for_papers import CallForPapers
        return CallForPapers(self)

    @return_ascii
    def __repr__(self):
        return format_repr(self, 'id', 'start_dt', 'end_dt', is_deleted=False, is_locked=False,
                           _text=text_to_repr(self.title, max_length=75))
示例#30
0
class AbstractEmailLogEntry(db.Model):
    __tablename__ = 'email_logs'
    __table_args__ = {'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)
    email_template_id = db.Column(
        db.Integer,
        db.ForeignKey('event_abstracts.email_templates.id'),
        index=True,
        nullable=True)
    user_id = db.Column(db.Integer,
                        db.ForeignKey('users.users.id'),
                        index=True,
                        nullable=True)
    sent_dt = db.Column(UTCDateTime, nullable=False, default=now_utc)
    recipients = db.Column(ARRAY(db.String), nullable=False)
    subject = db.Column(db.String, nullable=False)
    body = db.Column(db.Text, nullable=False)
    data = db.Column(JSON, nullable=False)

    abstract = db.relationship('Abstract',
                               lazy=True,
                               backref=db.backref(
                                   'email_logs',
                                   order_by=sent_dt,
                                   lazy=True,
                                   cascade='all, delete-orphan'))
    email_template = db.relationship('AbstractEmailTemplate',
                                     lazy=True,
                                     backref=db.backref('logs',
                                                        lazy='dynamic'))
    user = db.relationship('User',
                           lazy=True,
                           backref=db.backref('abstract_email_log_entries',
                                              lazy='dynamic'))

    @return_ascii
    def __repr__(self):
        return format_repr(self, 'id', 'abstract_id', _text=self.subject)

    @classmethod
    def create_from_email(cls, email_data, email_tpl, user=None):
        """Create a new log entry from the data used to send an email

        :param email_data: email data as returned from `make_email`
        :param email_tpl: the abstract email template that created the
                          email
        :param user: the user who performed the action causing the
                     notification
        """
        recipients = sorted(email_data['to'] | email_data['cc']
                            | email_data['bcc'])
        data = {'template_name': email_tpl.title}
        return cls(email_template=email_tpl,
                   user=user,
                   recipients=recipients,
                   subject=email_data['subject'],
                   body=email_data['body'],
                   data=data)