Exemplo n.º 1
0
class RegistrationForm(db.Model):
    """A registration form for an event."""

    __tablename__ = 'forms'
    principal_type = PrincipalType.registration_form
    principal_order = 2

    __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 Indico 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(11, 2),  # max. 999999999.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='')
    #: If the completed registration email should include the event's iCalendar file.
    attach_ical = db.Column(db.Boolean, nullable=False, default=False)
    #: 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))

    # relationship backrefs:
    # - in_attachment_acls (AttachmentPrincipal.registration_form)
    # - in_attachment_folder_acls (AttachmentFolderPrincipal.registration_form)
    # - in_contribution_acls (ContributionPrincipal.registration_form)
    # - in_event_acls (EventPrincipal.registration_form)
    # - in_session_acls (SessionPrincipal.registration_form)

    def __contains__(self, user):
        if user is None:
            return False
        return (Registration.query.with_parent(self).join(
            Registration.registration_form).filter(
                Registration.user == user,
                Registration.state.in_(
                    [RegistrationState.unpaid,
                     RegistrationState.complete]), ~Registration.is_deleted,
                ~RegistrationForm.is_deleted).has_rows())

    @property
    def name(self):
        # needed when sorting acl entries by name
        return self.title

    @property
    def identifier(self):
        return f'RegistrationForm:{self.id}'

    @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

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

    def is_modification_allowed(self, registration):
        """Check 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_approved:
            return registration.state == RegistrationState.pending
        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):
        """Retrieve 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_deleted).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_deleted).first()
        if email:
            return Registration.query.with_parent(self).filter_by(
                email=email).filter(~Registration.is_deleted).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):
        """Return 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
Exemplo n.º 2
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),
                      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_new = 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 indico.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_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 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_new.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_new.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)
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(11, 2),  # max. 999999999.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(
        JSONB,
        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 indico.modules.events.payment.util import get_payment_plugins
        return get_payment_plugins().get(self.provider)

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

    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):
        """Render 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 f'[plugin not loaded: {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)
        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")
        registration.transaction = new_transaction
        new_transaction.status = next_status
        return new_transaction
Exemplo n.º 4
0
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(11, 2),  # max. 999999999.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(
        JSONB,
        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 indico.modules.events.payment.util import get_payment_plugins
        return get_payment_plugins().get(self.provider)

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

    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):
        """Render the transaction details."""
        if self.is_manual:
            return render_template('events/payment/transaction_details_manual.html', transaction=self, plugin=None)
        plugin = self.plugin
        if plugin is None:
            return f'[plugin not loaded: {self.provider}]'
        with plugin.plugin_context():
            return plugin.render_transaction_details(self)

    def is_pending_expired(self):
        if self.is_manual:
            return False
        if (plugin := self.plugin) is None:
            return False
        with plugin.plugin_context():
            return plugin.is_pending_transaction_expired(self)
Exemplo n.º 5
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)
    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 Indico 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 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 Event containing this registration form
    event_new = db.relationship('Event',
                                lazy=True,
                                backref=db.backref('registration_forms',
                                                   lazy='dynamic'))
    # 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 != None) & (cls.end_dt <= now_utc())  # noqa

    @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 != None) & (cls.start_dt <= now_utc())  # noqa

    @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 != None)  # noqa

    @property
    def event(self):
        from MaKaC.conference import ConferenceHolder
        return ConferenceHolder().getById(str(self.event_id), True)

    @property
    def locator(self):
        return dict(self.event.getLocator(), 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 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
    def active_registrations(self):
        return [r for r in self.registrations if r.is_active]

    @property
    def sender_address(self):
        return self.notification_sender_address or self.event.getSupportInfo(
        ).getEmail()

    @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:
            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
Exemplo n.º 6
0
class PaymentTransaction(db.Model):
    """Payment transactions"""
    __tablename__ = 'payment_transactions'
    __table_args__ = (db.CheckConstraint('amount > 0', 'positive_amount'),
                      db.UniqueConstraint('event_id', 'registrant_id', 'timestamp'),
                      {'schema': 'events'})

    #: Entry ID
    id = db.Column(
        db.Integer,
        primary_key=True
    )
    #: ID of the event
    event_id = db.Column(
        db.Integer,
        index=True,
        nullable=False
    )
    #: ID of the registrant
    registrant_id = db.Column(
        db.Integer,
        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
    )
    #: the date and time the transaction was recorded
    timestamp = db.Column(
        UTCDateTime,
        default=now_utc,
        index=True,
        nullable=False
    )
    #: plugin-specific data of the payment
    data = db.Column(
        JSON,
        nullable=False
    )

    @property
    def event(self):
        from MaKaC.conference import ConferenceHolder
        return ConferenceHolder().getById(str(self.event_id))

    @property
    def registrant(self):
        return self.event.getRegistrantById(str(self.registrant_id))

    @registrant.setter
    def registrant(self, registrant):
        self.registrant_id = int(registrant.getId())
        self.event_id = int(registrant.getConference().getId())

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

    @property
    def 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 '<PaymentTransaction({}, {}, {}, {}, {}, {} {}, {})>'.format(self.id, self.event_id, self.registrant_id,
                                                                            status, self.provider, self.amount,
                                                                            self.currency, self.timestamp)

    def render_details(self):
        """Renders the transaction details for the registrant details in event management"""
        if self.manual:
            return render_template('payment/transaction_details_manual.html', transaction=self,
                                   registrant=self.registrant)
        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, registrant, amount, currency, action, provider='_manual', data=None):
        event = registrant.getConference()
        new_transaction = PaymentTransaction(event_id=event.getId(), registrant_id=registrant.getId(), amount=amount,
                                             currency=currency, provider=provider, data=data)
        double_payment = False
        previous_transaction = cls.find_latest_for_registrant(registrant)
        try:
            next_status = TransactionStatusTransition.next(previous_transaction, action, provider)
        except InvalidTransactionStatus as e:
            Logger.get('payment').exception("{}\nData received: {}".format(e, data))
            return None, None
        except InvalidManualTransactionAction as e:
            Logger.get('payment').exception("Invalid manual action code '{}' on initial status\n"
                                            "Data received: {}".format(e, data))
            return None, None
        except InvalidTransactionAction as e:
            Logger.get('payment').exception("Invalid action code '{}' on initial status\n"
                                            "Data received: {}".format(e, data))
            return None, None
        except IgnoredTransactionAction as e:
            Logger.get('payment').warning("{}\nData received: {}".format(e, data))
            return None, None
        except DoublePaymentTransaction:
            next_status = TransactionStatus.successful
            double_payment = True
            Logger.get('payment').warning("Received successful payment for an already paid registrant")
        new_transaction.status = next_status
        return new_transaction, double_payment

    @staticmethod
    def find_latest_for_registrant(registrant):
        """Returns the newest transaction for a given registrant.

        :param registrant: the registrant to find the transaction for
        :return: a :class:`PaymentTransaction` or `None`
        """
        return (PaymentTransaction.find(event_id=registrant.getConference().getId(), registrant_id=registrant.getId())
                                  .order_by(PaymentTransaction.timestamp.desc())
                                  .first())
Exemplo n.º 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: str(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(11, 2),  # max. 999999999.99
        nullable=False,
        default=0)
    #: The price modifier applied to the final calculated price
    price_adjustment = db.Column(
        db.Numeric(11, 2),  # max. 999999999.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: str(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)
    #: If given a reason for rejection
    rejection_reason = db.Column(
        db.String,
        nullable=False,
        default='',
    )
    #: Type of consent given to publish this registration
    consent_to_publish = db.Column(PyIntEnum(RegistrationVisibility),
                                   nullable=False,
                                   default=RegistrationVisibility.nobody)
    #: Management-set override for visibility
    participant_hidden = db.Column(db.Boolean, nullable=False, default=False)
    #: 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))
    #: The registration tags assigned to this registration
    tags = db.relationship('RegistrationTag',
                           secondary=registrations_tags_table,
                           passive_deletes=True,
                           collection_class=set,
                           backref=db.backref('registrations', lazy=False))

    # 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 indico.modules.events.registration.models.forms import RegistrationForm
        return (Registration.query.filter(
            Registration.is_active, ~RegistrationForm.is_deleted,
            RegistrationForm.event_id == event.id).join(
                Registration.registration_form).all())

    @classmethod
    def merge_users(cls, target, source):
        target_regforms_used = {
            r.registration_form
            for r in target.registrations if not r.is_deleted
        }
        for r in source.registrations.all():
            if r.registration_form not in target_regforms_used:
                r.user = target

    @hybrid_method
    def is_publishable(self, is_participant):
        if self.visibility == RegistrationVisibility.nobody or not self.is_state_publishable:
            return False
        if (self.registration_form.publish_registrations_duration is not None
                and self.event.end_dt +
                self.registration_form.publish_registrations_duration <=
                now_utc()):
            return False
        if self.visibility == RegistrationVisibility.participants:
            return is_participant
        if self.visibility == RegistrationVisibility.all:
            return True
        return False

    @is_publishable.expression
    def is_publishable(cls, is_participant):
        from indico.modules.events import Event
        from indico.modules.events.registration.models.forms import RegistrationForm

        def _has_regform_publish_mode(mode):
            if is_participant:
                return cls.registration_form.has(
                    publish_registrations_participants=mode)
            else:
                return cls.registration_form.has(
                    publish_registrations_public=mode)

        consent_criterion = (cls.consent_to_publish.in_([
            RegistrationVisibility.all, RegistrationVisibility.participants
        ]) if is_participant else cls.consent_to_publish
                             == RegistrationVisibility.all)
        return db.and_(
            ~cls.participant_hidden, cls.is_state_publishable,
            cls.registration_form.has(
                db.or_(
                    RegistrationForm.publish_registrations_duration.is_(None),
                    cls.event.has(
                        (Event.end_dt +
                         RegistrationForm.publish_registrations_duration
                         ) > now_utc()))),
            ~_has_regform_publish_mode(PublishRegistrationsMode.hide_all),
            _has_regform_publish_mode(PublishRegistrationsMode.show_all)
            | consent_criterion)

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

    @hybrid_property
    def is_state_publishable(self):
        return self.is_active and self.state in (RegistrationState.complete,
                                                 RegistrationState.unpaid)

    @is_state_publishable.expression
    def is_state_publishable(cls):
        return cls.is_active & cls.state.in_(
            (RegistrationState.complete, RegistrationState.unpaid))

    @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 can_be_withdrawn(self):
        from indico.modules.events.registration.models.forms import ModificationMode
        if not self.is_active:
            return False
        elif self.is_paid:
            return False
        elif self.event.end_dt < now_utc():
            return False
        elif self.registration_form.modification_mode == ModificationMode.not_allowed:
            return False
        elif self.registration_form.modification_end_dt and self.registration_form.modification_end_dt < now_utc(
        ):
            return False
        else:
            return True

    @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):
        """Return 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 avatar_url(self):
        """Return the url of the user's avatar."""
        return url_for('event_registration.registration_avatar', 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):
        """Return 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] = {}
                for field in section.fields:
                    if not field.is_visible:
                        continue
                    if field in field_summary:
                        summary[section][field] = field_summary[field]

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

        summary = {}
        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)
        ]

    @property
    def visibility_before_override(self):
        if self.registration_form.publish_registrations_participants == PublishRegistrationsMode.hide_all:
            return RegistrationVisibility.nobody
        vis = self.consent_to_publish
        if self.registration_form.publish_registrations_participants == PublishRegistrationsMode.show_all:
            if self.registration_form.publish_registrations_public == PublishRegistrationsMode.hide_all:
                return RegistrationVisibility.participants
            if self.registration_form.publish_registrations_public == PublishRegistrationsMode.show_all:
                return RegistrationVisibility.all
            return max(vis, RegistrationVisibility.participants)
        elif self.registration_form.publish_registrations_public == PublishRegistrationsMode.hide_all:
            return min(vis, RegistrationVisibility.participants)
        return vis

    @property
    def visibility(self):
        if self.participant_hidden:
            return RegistrationVisibility.nobody
        return self.visibility_before_override

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

    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):
        """Return 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,
                     withdrawn=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, withdrawn)) > 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 bool(
                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 withdrawn:
                self.state = RegistrationState.withdrawn
        elif self.state == RegistrationState.unpaid:
            if paid:
                self.state = RegistrationState.complete
            elif approved is False:
                self.state = RegistrationState.pending
            elif withdrawn:
                self.state = RegistrationState.withdrawn
        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
            elif withdrawn:
                self.state = RegistrationState.withdrawn
        elif self.state == RegistrationState.rejected:
            if rejected is False and moderation_required:
                self.state = RegistrationState.pending
            elif rejected is False and payment_required:
                self.state = RegistrationState.unpaid
            elif rejected is False:
                self.state = RegistrationState.complete
        elif self.state == RegistrationState.withdrawn:
            if withdrawn is False and moderation_required:
                self.state = RegistrationState.pending
            elif withdrawn is False and payment_required:
                self.state = RegistrationState.unpaid
            elif withdrawn is False:
                self.state = RegistrationState.complete
        if self.state != initial_state:
            signals.event.registration_state_updated.send(
                self, previous_state=initial_state)

    def has_conflict(self):
        """Check if there are other valid registrations for the same user.

        This is intended for cases where this registration is currenly invalid
        (rejected or withdrawn) to determine whether it would be acceptable to
        restore it.
        """
        conflict_criteria = [Registration.email == self.email]
        if self.user_id is not None:
            conflict_criteria.append(Registration.user_id == self.user_id)
        return (Registration.query.with_parent(self.registration_form).filter(
            Registration.id != self.id, ~Registration.is_deleted,
            db.or_(*conflict_criteria),
            Registration.state.notin_(
                [RegistrationState.rejected,
                 RegistrationState.withdrawn])).has_rows())

    def log(self, *args, **kwargs):
        """Log with prefilled metadata for the registration."""
        self.event.log(*args, meta={'registration_id': self.id}, **kwargs)

    def is_pending_transaction_expired(self):
        """Check if the registration has a pending transaction that expired."""
        if not self.transaction or self.transaction.status != TransactionStatus.pending:
            return False
        return self.transaction.is_pending_expired()