Ejemplo n.º 1
0
class Room(MagModel, NightsMixin):
    notes = Column(UnicodeText)
    message = Column(UnicodeText)
    locked_in = Column(Boolean, default=False)
    nights = Column(MultiChoice(c.NIGHT_OPTS))
    created = Column(UTCDateTime, server_default=utcnow())
    assignments = relationship('RoomAssignment', backref='room')

    @property
    def email(self):
        return [ra.attendee.email for ra in self.assignments]

    @property
    def first_names(self):
        return [ra.attendee.first_name for ra in self.assignments]

    @property
    def check_in_date(self):
        return c.NIGHT_DATES[self.nights_labels[0]]

    @property
    def check_out_date(self):
        # TODO: Undo this kludgy workaround by fully implementing:
        #       https://github.com/magfest/hotel/issues/39
        if self.nights_labels[-1] == 'Monday':
            return c.ESCHATON.date() + timedelta(days=1)
        else:
            return c.NIGHT_DATES[self.nights_labels[-1]] + timedelta(days=1)
Ejemplo n.º 2
0
class PanelApplication(MagModel):
    event_id = Column(UUID,
                      ForeignKey('event.id', ondelete='SET NULL'),
                      nullable=True)
    poc_id = Column(UUID,
                    ForeignKey('attendee.id', ondelete='SET NULL'),
                    nullable=True)
    name = Column(UnicodeText)
    length = Column(Choice(c.PANEL_LENGTH_OPTS), default=c.SIXTY_MIN)
    length_text = Column(UnicodeText)
    length_reason = Column(UnicodeText)
    description = Column(UnicodeText)
    unavailable = Column(UnicodeText)
    available = Column(UnicodeText)
    affiliations = Column(UnicodeText)
    past_attendance = Column(UnicodeText)
    presentation = Column(Choice(c.PRESENTATION_OPTS))
    other_presentation = Column(UnicodeText)
    tech_needs = Column(MultiChoice(c.TECH_NEED_OPTS))
    other_tech_needs = Column(UnicodeText)
    need_tables = Column(Boolean, default=False)
    tables_desc = Column(UnicodeText)
    has_cost = Column(Boolean, default=False)
    cost_desc = Column(UnicodeText)
    livestream = Column(Choice(c.LIVESTREAM_OPTS), default=c.DONT_CARE)
    panelist_bringing = Column(UnicodeText)
    extra_info = Column(UnicodeText)
    applied = Column(UTCDateTime, server_default=utcnow())
    status = Column(Choice(c.PANEL_APP_STATUS_OPTS),
                    default=c.PENDING,
                    admin_only=True)
    comments = Column(UnicodeText, admin_only=True)

    applicants = relationship('PanelApplicant', backref='application')

    email_model_name = 'app'

    @property
    def email(self):
        return self.submitter and self.submitter.email

    @property
    def submitter(self):
        for a in self.applicants:
            if a.submitter:
                return a
        return None

    @property
    def other_panelists(self):
        return [a for a in self.applicants if not a.submitter]

    @property
    def matched_attendees(self):
        return [a.attendee for a in self.applicants if a.attendee_id]

    @property
    def unmatched_applicants(self):
        return [a for a in self.applicants if not a.attendee_id]
Ejemplo n.º 3
0
class IndieStudio(MagModel):
    group_id = Column(UUID, ForeignKey('group.id'), nullable=True)
    name = Column(UnicodeText, unique=True)
    address = Column(UnicodeText)
    website = Column(UnicodeText)
    twitter = Column(UnicodeText)
    facebook = Column(UnicodeText)
    status = Column(Choice(c.MIVS_STUDIO_STATUS_OPTS),
                    default=c.NEW,
                    admin_only=True)
    staff_notes = Column(UnicodeText, admin_only=True)
    registered = Column(UTCDateTime, server_default=utcnow())

    games = relationship('IndieGame',
                         backref='studio',
                         order_by='IndieGame.title')
    developers = relationship('IndieDeveloper',
                              backref='studio',
                              order_by='IndieDeveloper.last_name')

    email_model_name = 'studio'

    @property
    def confirm_deadline(self):
        sorted_games = sorted([g for g in self.games if g.accepted],
                              key=lambda g: g.accepted)
        confirm_deadline = timedelta(days=c.MIVS_CONFIRM_DEADLINE)
        return sorted_games[0].accepted + confirm_deadline

    @property
    def after_confirm_deadline(self):
        return self.confirm_deadline < localized_now()

    @property
    def website_href(self):
        return make_url(self.website)

    @property
    def email(self):
        return [dev.email for dev in self.developers if dev.primary_contact]

    @property
    def primary_contact(self):
        return [dev for dev in self.developers if dev.primary_contact][0]

    @property
    def submitted_games(self):
        return [g for g in self.games if g.submitted]

    @property
    def comped_badges(self):
        game_count = len([g for g in self.games if g.status == c.ACCEPTED])
        return c.MIVS_INDIE_BADGE_COMPS * game_count

    @property
    def unclaimed_badges(self):
        claimed_count = len(
            [d for d in self.developers if not d.matching_attendee])
        return max(0, self.comped_badges - claimed_count)
Ejemplo n.º 4
0
class PasswordReset(MagModel):
    account_id = Column(UUID, ForeignKey('admin_account.id'), unique=True)
    generated = Column(UTCDateTime, server_default=utcnow())
    hashed = Column(UnicodeText, private=True)

    @property
    def is_expired(self):
        return self.generated < datetime.now(UTC) - timedelta(days=7)
Ejemplo n.º 5
0
class Group(MagModel, TakesPaymentMixin):
    public_id = Column(UUID, default=lambda: str(uuid4()))
    name = Column(UnicodeText)
    tables = Column(Numeric, default=0)
    zip_code = Column(UnicodeText)
    address1 = Column(UnicodeText)
    address2 = Column(UnicodeText)
    city = Column(UnicodeText)
    region = Column(UnicodeText)
    country = Column(UnicodeText)
    website = Column(UnicodeText)
    wares = Column(UnicodeText)
    categories = Column(MultiChoice(c.DEALER_WARES_OPTS))
    categories_text = Column(UnicodeText)
    description = Column(UnicodeText)
    special_needs = Column(UnicodeText)
    amount_paid = Column(Integer, default=0, index=True, admin_only=True)
    amount_refunded = Column(Integer, default=0, admin_only=True)
    cost = Column(Integer, default=0, admin_only=True)
    auto_recalc = Column(Boolean, default=True, admin_only=True)
    can_add = Column(Boolean, default=False, admin_only=True)
    admin_notes = Column(UnicodeText, admin_only=True)
    status = Column(Choice(c.DEALER_STATUS_OPTS),
                    default=c.UNAPPROVED,
                    admin_only=True)
    registered = Column(UTCDateTime, server_default=utcnow())
    approved = Column(UTCDateTime, nullable=True)
    leader_id = Column(UUID,
                       ForeignKey('attendee.id',
                                  use_alter=True,
                                  name='fk_leader'),
                       nullable=True)

    leader = relationship('Attendee',
                          foreign_keys=leader_id,
                          post_update=True,
                          cascade='all')
    studio = relationship('IndieStudio', uselist=False, backref='group')
    guest = relationship('GuestGroup', backref='group', uselist=False)

    _repr_attr_names = ['name']

    @presave_adjustment
    def _cost_and_leader(self):
        assigned = [a for a in self.attendees if not a.is_unassigned]
        if len(assigned) == 1:
            [self.leader] = assigned
        if self.auto_recalc:
            self.cost = self.default_cost
        elif not self.cost:
            self.cost = 0
        if not self.amount_paid:
            self.amount_paid = 0
        if not self.amount_refunded:
            self.amount_refunded = 0
        if self.status == c.APPROVED and not self.approved:
            self.approved = datetime.now(UTC)
        if self.leader and self.is_dealer:
            self.leader.ribbon = add_opt(self.leader.ribbon_ints,
                                         c.DEALER_RIBBON)
        if not self.is_unpaid:
            for a in self.attendees:
                a.presave_adjustments()

    @property
    def sorted_attendees(self):
        return list(
            sorted(self.attendees,
                   key=lambda a:
                   (a.is_unassigned, a.id != self.leader_id, a.full_name)))

    @property
    def unassigned(self):
        """
        Returns a list of the unassigned badges for this group, sorted so that
        the paid-by-group badges come last, because when claiming unassigned
        badges we want to claim the "weird" ones first.
        """
        unassigned = [a for a in self.attendees if a.is_unassigned]
        return sorted(unassigned, key=lambda a: a.paid == c.PAID_BY_GROUP)

    @property
    def floating(self):
        """
        Returns the list of paid-by-group unassigned badges for this group.
        This is a separate property from the "Group.unassigned" property
        because when automatically adding or removing unassigned badges, we
        care specifically about paid-by-group badges rather than all unassigned
        badges.
        """
        return [
            a for a in self.attendees
            if a.is_unassigned and a.paid == c.PAID_BY_GROUP
        ]

    @property
    def new_ribbon(self):
        return c.DEALER_RIBBON if self.is_dealer else ''

    @property
    def ribbon_and_or_badge(self):
        badge = self.unassigned[0]
        if badge.ribbon and badge.badge_type != c.ATTENDEE_BADGE:
            return ' / '.join([badge.badge_type_label] + self.ribbon_labels)
        elif badge.ribbon:
            return ' / '.join(badge.ribbon_labels)
        else:
            return badge.badge_type_label

    @property
    def is_dealer(self):
        return bool(self.tables and self.tables != '0' and self.tables != '0.0'
                    and (not self.registered or self.amount_paid or self.cost))

    @property
    def is_unpaid(self):
        return self.cost > 0 and self.amount_paid == 0

    @property
    def email(self):
        if self.leader and self.leader.email:
            return self.leader.email
        elif self.leader_id:  # unattached groups
            [leader] = [a for a in self.attendees if a.id == self.leader_id]
            return leader.email
        else:
            emails = [a.email for a in self.attendees if a.email]
            if len(emails) == 1:
                return emails[0]

    @property
    def badges_purchased(self):
        return len([a for a in self.attendees if a.paid == c.PAID_BY_GROUP])

    @property
    def badges(self):
        return len(self.attendees)

    @property
    def unregistered_badges(self):
        return len([a for a in self.attendees if a.is_unassigned])

    @cost_property
    def table_cost(self):
        table_count = int(float(self.tables))
        return sum(c.TABLE_PRICES[i] for i in range(1, 1 + table_count))

    @property
    def new_badge_cost(self):
        return c.DEALER_BADGE_PRICE if self.is_dealer else c.get_group_price()

    @cost_property
    def badge_cost(self):
        total = 0
        for attendee in self.attendees:
            if attendee.paid == c.PAID_BY_GROUP:
                total += attendee.badge_cost
        return total

    @property
    def amount_extra(self):
        if self.is_new:
            return sum(a.total_cost - a.badge_cost for a in self.attendees
                       if a.paid == c.PAID_BY_GROUP)
        else:
            return 0

    @property
    def total_cost(self):
        return self.default_cost + self.amount_extra

    @property
    def amount_unpaid(self):
        if self.registered:
            return max(0, self.cost - self.amount_paid)
        else:
            return self.total_cost

    @property
    def dealer_max_badges(self):
        return math.ceil(self.tables) + 1

    @property
    def dealer_badges_remaining(self):
        return self.dealer_max_badges - self.badges

    @property
    def hours_since_registered(self):
        if not self.registered:
            return 0
        delta = datetime.now(UTC) - self.registered
        return max(0, delta.total_seconds()) / 60.0 / 60.0

    @property
    def hours_remaining_in_grace_period(self):
        return max(0,
                   c.GROUP_UPDATE_GRACE_PERIOD - self.hours_since_registered)

    @property
    def is_in_grace_period(self):
        return self.hours_remaining_in_grace_period > 0

    @property
    def min_badges_addable(self):
        if self.can_add:
            return 1
        elif self.is_dealer:
            return 0
        else:
            return c.MIN_GROUP_ADDITION

    @property
    def requested_hotel_info(self):
        if self.leader:
            return self.leader.requested_hotel_info
        elif self.leader_id:  # unattached groups
            for attendee in self.attendees:
                if attendee.id == self.leader_id:
                    return attendee.requested_hotel_info
        else:
            return any(a.requested_hotel_info for a in self.attendees)

    @property
    def physical_address(self):
        address1 = self.address1.strip()
        address2 = self.address2.strip()
        city = self.city.strip()
        region = self.region.strip()
        zip_code = self.zip_code.strip()
        country = self.country.strip()

        country = '' if country == 'United States' else country.strip()

        if city and region:
            city_region = '{}, {}'.format(city, region)
        else:
            city_region = city or region
        city_region_zip = '{} {}'.format(city_region, zip_code).strip()

        physical_address = [address1, address2, city_region_zip, country]
        return '\n'.join([s for s in physical_address if s])
Ejemplo n.º 6
0
class PromoCodeGroup(MagModel):
    name = Column(UnicodeText)
    code = Column(UnicodeText, admin_only=True)
    registered = Column(UTCDateTime, server_default=utcnow())
    buyer_id = Column(UUID,
                      ForeignKey('attendee.id', ondelete='SET NULL'),
                      nullable=True)
    buyer = relationship('Attendee',
                         backref='promo_code_groups',
                         foreign_keys=buyer_id,
                         cascade='save-update,merge,refresh-expire,expunge')

    email_model_name = 'group'

    @presave_adjustment
    def group_code(self):
        """
        Promo Code Groups can be used one of two ways: Each promo
        code's unique code can be used to claim a specific badge,
        or the groups' code can be used by multiple people to
        claim random badges in the group.

        We don't want this to clash with any promo codes' existing
        codes, so we use that class' generator method.
        """
        if not self.code:
            self.code = PromoCode.generate_random_code()

    @hybrid_property
    def normalized_code(self):
        return self.normalize_code(self.code)

    @normalized_code.expression
    def normalized_code(cls):
        return func.replace(func.replace(func.lower(cls.code), '-', ''), ' ',
                            '')

    @property
    def email(self):
        return self.buyer.email if self.buyer else None

    @property
    def total_cost(self):
        return sum(code.cost for code in self.promo_codes if code.cost)

    @property
    def valid_codes(self):
        return [code for code in self.promo_codes if code.is_valid]

    @property
    def sorted_promo_codes(self):
        return list(
            sorted(self.promo_codes,
                   key=lambda pc: (not pc.used_by, pc.used_by[0].full_name
                                   if pc.used_by else pc.code)))

    @property
    def hours_since_registered(self):
        if not self.registered:
            return 0
        delta = datetime.now(UTC) - self.registered
        return max(0, delta.total_seconds()) / 60.0 / 60.0

    @property
    def hours_remaining_in_grace_period(self):
        return max(0,
                   c.GROUP_UPDATE_GRACE_PERIOD - self.hours_since_registered)

    @property
    def is_in_grace_period(self):
        return self.hours_remaining_in_grace_period > 0

    @property
    def min_badges_addable(self):
        return 1 if self.hours_remaining_in_grace_period > 0 else c.MIN_GROUP_ADDITION
Ejemplo n.º 7
0
class MarketplaceApplication(MagModel):
    MATCHING_DEALER_FIELDS = [
        'categories', 'categories_text', 'description', 'special_needs'
    ]

    attendee_id = Column(UUID,
                         ForeignKey('attendee.id', ondelete='SET NULL'),
                         nullable=True)
    attendee = relationship('Attendee',
                            foreign_keys=attendee_id,
                            cascade='save-update, merge',
                            backref=backref('marketplace_applications',
                                            cascade='save-update, merge'))
    business_name = Column(UnicodeText)
    status = Column(Choice(c.MARKETPLACE_STATUS_OPTS),
                    default=c.UNAPPROVED,
                    admin_only=True)
    registered = Column(UTCDateTime, server_default=utcnow())
    approved = Column(UTCDateTime, nullable=True)

    categories = Column(MultiChoice(c.DEALER_WARES_OPTS))
    categories_text = Column(UnicodeText)
    description = Column(UnicodeText)
    special_needs = Column(UnicodeText)

    admin_notes = Column(UnicodeText, admin_only=True)
    base_price = Column(Integer, default=0, admin_only=True)
    overridden_price = Column(Integer, nullable=True, admin_only=True)
    amount_paid = Column(Integer, default=0, index=True, admin_only=True)

    email_model_name = 'app'

    @presave_adjustment
    def _cost_adjustments(self):
        self.base_price = self.default_cost

        if self.overridden_price == '':
            self.overridden_price = None

    @property
    def incomplete_reason(self):
        if self.status not in [c.APPROVED, c.PAID]:
            return self.status_label
        if self.attendee.placeholder:
            return "Missing registration info"

    @cost_property
    def app_fee(self):
        return c.MARKETPLACE_FEE or 0

    @property
    def total_cost(self):
        if self.status != c.APPROVED:
            return 0
        else:
            return self.potential_cost

    @property
    def potential_cost(self):
        if self.overridden_price is not None:
            return self.overridden_price
        else:
            return self.base_price or self.default_cost or 0

    @property
    def amount_unpaid(self):
        return max(0, self.total_cost - self.amount_paid)

    @property
    def email(self):
        return self.attendee.email

    @property
    def is_unpaid(self):
        return self.status == c.APPROVED
Ejemplo n.º 8
0
class IndieStudio(MagModel):
    group_id = Column(UUID, ForeignKey('group.id'), nullable=True)
    name = Column(UnicodeText, unique=True)
    address = Column(UnicodeText)
    website = Column(UnicodeText)
    twitter = Column(UnicodeText)
    facebook = Column(UnicodeText)
    status = Column(Choice(c.MIVS_STUDIO_STATUS_OPTS),
                    default=c.NEW,
                    admin_only=True)
    staff_notes = Column(UnicodeText, admin_only=True)
    registered = Column(UTCDateTime, server_default=utcnow())

    accepted_core_hours = Column(Boolean, default=False)
    discussion_emails = Column(UnicodeText)
    completed_discussion = Column(Boolean, default=False)
    read_handbook = Column(Boolean, default=False)
    training_password = Column(UnicodeText)
    selling_at_event = Column(
        Boolean, nullable=True,
        admin_only=True)  # "Admin only" preserves null default
    needs_hotel_space = Column(
        Boolean, nullable=True,
        admin_only=True)  # "Admin only" preserves null default
    name_for_hotel = Column(UnicodeText)
    email_for_hotel = Column(UnicodeText)

    games = relationship('IndieGame',
                         backref='studio',
                         order_by='IndieGame.title')
    developers = relationship('IndieDeveloper',
                              backref='studio',
                              order_by='IndieDeveloper.last_name')

    email_model_name = 'studio'

    @property
    def primary_contact_first_names(self):
        if len(self.primary_contacts) == 1:
            return self.primary_contacts[0].first_name

        string = self.primary_contacts[0].first_name
        for dev in self.primary_contacts[1:-1]:
            string += ", " + dev.first_name
        if len(self.primary_contacts) > 2:
            string += ","
        string += " and " + self.primary_contacts[-1].first_name
        return string

    @property
    def confirm_deadline(self):
        sorted_games = sorted([g for g in self.games if g.accepted],
                              key=lambda g: g.accepted)
        confirm_deadline = timedelta(days=c.MIVS_CONFIRM_DEADLINE)
        return sorted_games[0].accepted + confirm_deadline

    @property
    def after_confirm_deadline(self):
        return self.confirm_deadline < localized_now()

    @property
    def discussion_emails_list(self):
        return list(filter(None, self.discussion_emails.split(',')))

    @property
    def core_hours_status(self):
        return "Accepted" if self.accepted_core_hours else None

    @property
    def discussion_status(self):
        return "Completed" if self.completed_discussion else None

    @property
    def handbook_status(self):
        return "Read" if self.read_handbook else None

    @property
    def training_status(self):
        if self.training_password:
            return "Completed" if self.training_password.lower() == c.MIVS_TRAINING_PASSWORD.lower()\
                else "Entered the wrong phrase!"

    @property
    def selling_at_event_status(self):
        if self.selling_at_event is not None:
            return "Expressed interest in selling" if self.selling_at_event else "Opted out"

    @property
    def hotel_space_status(self):
        if self.needs_hotel_space is not None:
            return "Requested hotel space for {} with email {}".format(self.name_for_hotel, self.email_for_hotel)\
                if self.needs_hotel_space else "Opted out"

    def checklist_deadline(self, slug):
        default_deadline = c.MIVS_CHECKLIST[slug]['deadline']
        if self.group and self.group.registered >= default_deadline and slug != 'hotel_space':
            return self.group.registered + timedelta(days=3)
        return default_deadline

    def past_checklist_deadline(self, slug):
        """

        Args:
            slug: A standardized name, which should match the checklist's section name in config.
            E.g., the properties above ending in _status

        Returns: A timedelta object representing how far from the deadline this team is for a particular checklist item

        """
        return localized_now() - self.checklist_deadline(slug)

    @property
    def checklist_items_due_soon_grouped(self):
        two_days = []
        one_day = []
        overdue = []
        for key, val in c.MIVS_CHECKLIST.items():
            if localized_now() >= self.checklist_deadline(key):
                overdue.append([val['name'], "mivs_" + key])
            elif (localized_now() -
                  timedelta(days=1)) >= self.checklist_deadline(key):
                one_day.append([val['name'], "mivs_" + key])
            elif (localized_now() -
                  timedelta(days=2)) >= self.checklist_deadline(key):
                two_days.append([val['name'], "mivs_" + key])

        return two_days, one_day, overdue

    @property
    def website_href(self):
        return make_url(self.website)

    @property
    def email(self):
        return [
            dev.email_to_address for dev in self.developers
            if dev.primary_contact
        ]

    @property
    def primary_contacts(self):
        return [dev for dev in self.developers if dev.primary_contact]

    @property
    def submitted_games(self):
        return [g for g in self.games if g.submitted]

    @property
    def comped_badges(self):
        game_count = len([g for g in self.games if g.status == c.ACCEPTED])
        return c.MIVS_INDIE_BADGE_COMPS * game_count

    @property
    def unclaimed_badges(self):
        claimed_count = len(
            [d for d in self.developers if not d.matching_attendee])
        return max(0, self.comped_badges - claimed_count)
Ejemplo n.º 9
0
class IndieGame(MagModel, ReviewMixin):
    studio_id = Column(UUID, ForeignKey('indie_studio.id'))
    title = Column(UnicodeText)
    brief_description = Column(UnicodeText)  # 140 max
    genres = Column(MultiChoice(c.MIVS_INDIE_GENRE_OPTS))
    platforms = Column(MultiChoice(c.MIVS_INDIE_PLATFORM_OPTS))
    platforms_text = Column(UnicodeText)
    description = Column(UnicodeText)  # 500 max
    how_to_play = Column(UnicodeText)  # 1000 max
    link_to_video = Column(UnicodeText)
    link_to_game = Column(UnicodeText)
    password_to_game = Column(UnicodeText)
    code_type = Column(Choice(c.MIVS_CODE_TYPE_OPTS), default=c.NO_CODE)
    code_instructions = Column(UnicodeText)
    build_status = Column(Choice(c.MIVS_BUILD_STATUS_OPTS),
                          default=c.PRE_ALPHA)
    build_notes = Column(UnicodeText)  # 500 max
    shown_events = Column(UnicodeText)
    video_submitted = Column(Boolean, default=False)
    submitted = Column(Boolean, default=False)
    agreed_liability = Column(Boolean, default=False)
    agreed_showtimes = Column(Boolean, default=False)
    agreed_reminder1 = Column(Boolean, default=False)
    agreed_reminder2 = Column(Boolean, default=False)
    alumni_years = Column(MultiChoice(c.PREV_MIVS_YEAR_OPTS))
    alumni_update = Column(UnicodeText)

    link_to_promo_video = Column(UnicodeText)
    link_to_webpage = Column(UnicodeText)
    twitter = Column(UnicodeText)
    facebook = Column(UnicodeText)
    other_social_media = Column(UnicodeText)

    tournament_at_event = Column(Boolean, default=False)
    tournament_prizes = Column(UnicodeText)
    has_multiplayer = Column(Boolean, default=False)
    player_count = Column(UnicodeText)

    # Length in minutes
    multiplayer_game_length = Column(Integer, nullable=True)
    leaderboard_challenge = Column(Boolean, default=False)

    status = Column(Choice(c.MIVS_GAME_STATUS_OPTS),
                    default=c.NEW,
                    admin_only=True)
    judge_notes = Column(UnicodeText, admin_only=True)
    registered = Column(UTCDateTime, server_default=utcnow())
    waitlisted = Column(UTCDateTime, nullable=True)
    accepted = Column(UTCDateTime, nullable=True)

    codes = relationship('IndieGameCode', backref='game')
    reviews = relationship('IndieGameReview', backref='game')
    images = relationship('IndieGameImage',
                          backref='game',
                          order_by='IndieGameImage.id')

    email_model_name = 'game'

    @presave_adjustment
    def accepted_time(self):
        if self.status == c.ACCEPTED and not self.accepted:
            self.accepted = datetime.now(UTC)

    @presave_adjustment
    def waitlisted_time(self):
        if self.status == c.WAITLISTED and not self.waitlisted:
            self.waitlisted = datetime.now(UTC)

    @property
    def email(self):
        return self.studio.email

    @property
    def reviews_to_email(self):
        return [review for review in self.reviews if review.send_to_studio]

    @property
    def video_href(self):
        return make_url(self.link_to_video)

    @property
    def href(self):
        return make_url(self.link_to_game)

    @property
    def screenshots(self):
        return [img for img in self.images if img.is_screenshot]

    @property
    def best_screenshots(self):
        return [
            img for img in self.images
            if img.is_screenshot and img.use_in_promo
        ]

    def best_screenshot_downloads(self, count=2):
        all_images = reversed(
            sorted(self.images,
                   key=lambda img: (img.is_screenshot and img.use_in_promo, img
                                    .is_screenshot, img.use_in_promo)))

        screenshots = []
        for i, screenshot in enumerate(all_images):
            if os.path.exists(screenshot.filepath):
                screenshots.append(screenshot)
                if len(screenshots) >= count:
                    break
        return screenshots

    def best_screenshot_download_filenames(self, count=2):
        nonchars = re.compile(r'[\W]+')
        best_screenshots = self.best_screenshot_downloads(count)
        screenshots = []
        for i, screenshot in enumerate(best_screenshots):
            if os.path.exists(screenshot.filepath):
                name = '_'.join([s for s in self.title.lower().split() if s])
                name = nonchars.sub('', name)
                filename = '{}_{}.{}'.format(name,
                                             len(screenshots) + 1,
                                             screenshot.extension.lower())
                screenshots.append(filename)
                if len(screenshots) >= count:
                    break
        return screenshots + ([''] * (count - len(screenshots)))

    @property
    def promo_image(self):
        return next(
            iter([img for img in self.images if not img.is_screenshot]), None)

    @property
    def missing_steps(self):
        steps = []
        if not self.link_to_game:
            steps.append(
                'You have not yet included a link to where the judges can '
                'access your game')
        if self.code_type != c.NO_CODE and self.link_to_game:
            if not self.codes:
                steps.append(
                    'You have not yet attached any codes to this game for '
                    'our judges to use')
            elif not self.unlimited_code \
                    and len(self.codes) < c.MIVS_CODES_REQUIRED:
                steps.append(
                    'You have not attached the {} codes you must provide '
                    'for our judges'.format(c.MIVS_CODES_REQUIRED))
        if not self.agreed_showtimes:
            steps.append(
                'You must agree to the showtimes detailed on the game form')
        if not self.agreed_liability:
            steps.append(
                'You must check the box that agrees to our liability waiver')

        return steps

    @property
    def video_broken(self):
        for r in self.reviews:
            if r.video_status == c.BAD_LINK:
                return True

    @property
    def unlimited_code(self):
        for code in self.codes:
            if code.unlimited_use:
                return code

    @property
    def video_submittable(self):
        return bool(self.link_to_video)

    @property
    def submittable(self):
        return not self.missing_steps

    @property
    def scores(self):
        return [r.game_score for r in self.reviews if r.game_score]

    @property
    def score_sum(self):
        return sum(self.scores, 0)

    @property
    def average_score(self):
        return (self.score_sum / len(self.scores)) if self.scores else 0

    @property
    def has_issues(self):
        return any(r.has_issues for r in self.reviews)

    @property
    def confirmed(self):
        return self.status == c.ACCEPTED \
            and self.studio \
            and self.studio.group_id

    @hybrid_property
    def has_been_accepted(self):
        return self.status == c.ACCEPTED

    @property
    def guidebook_name(self):
        return self.studio.name

    @property
    def guidebook_subtitle(self):
        return self.title

    @property
    def guidebook_desc(self):
        return self.description

    @property
    def guidebook_location(self):
        return ''

    @property
    def guidebook_image(self):
        return self.best_screenshot_download_filenames()[0]

    @property
    def guidebook_thumbnail(self):
        return self.best_screenshot_download_filenames()[1] \
            if len(self.best_screenshot_download_filenames()) > 1 else self.best_screenshot_download_filenames()[0]

    @property
    def guidebook_images(self):
        image_filenames = [self.best_screenshot_download_filenames()[0]]
        images = [self.best_screenshot_downloads()[0]]
        if self.guidebook_image != self.guidebook_thumbnail:
            image_filenames.append(self.guidebook_thumbnail)
            images.append(self.best_screenshot_downloads()[1])

        return image_filenames, images
Ejemplo n.º 10
0
class MITSTeam(MagModel):
    name = Column(UnicodeText)
    panel_interest = Column(Boolean, nullable=True, admin_only=True)
    showcase_interest = Column(Boolean, nullable=True, admin_only=True)
    want_to_sell = Column(Boolean, default=False)
    address = Column(UnicodeText)
    submitted = Column(UTCDateTime, nullable=True)
    waiver_signature = Column(UnicodeText)
    waiver_signed = Column(UTCDateTime, nullable=True)

    applied = Column(UTCDateTime, server_default=utcnow())
    status = Column(Choice(c.MITS_APP_STATUS),
                    default=c.PENDING,
                    admin_only=True)

    applicants = relationship('MITSApplicant', backref='team')
    games = relationship('MITSGame', backref='team')
    pictures = relationship('MITSPicture', backref='team')
    documents = relationship('MITSDocument', backref='team')
    schedule = relationship('MITSTimes', uselist=False, backref='team')
    panel_app = relationship('MITSPanelApplication',
                             uselist=False,
                             backref='team')

    duplicate_of = Column(UUID, nullable=True)
    deleted = Column(Boolean, default=False)
    # We've found that a lot of people start filling out an application and
    # then instead of continuing their application just start over fresh and
    # fill out a new one.  In these cases we mark the application as
    # soft-deleted and then set the duplicate_of field so that when an
    # applicant tries to log into the original application, we can redirect
    # them to the correct application.

    email_model_name = 'team'

    @property
    def accepted(self):
        return self.status == c.ACCEPTED

    @property
    def email(self):
        return [applicant.email for applicant in self.primary_contacts]

    @property
    def primary_contacts(self):
        return [a for a in self.applicants if a.primary_contact]

    @property
    def salutation(self):
        return ' and '.join(applicant.first_name
                            for applicant in self.primary_contacts)

    @property
    def comped_badge_count(self):
        return len([
            a for a in self.applicants if a.attendee_id
            and a.attendee.paid in [c.NEED_NOT_PAY, c.REFUNDED]
        ])

    @property
    def total_badge_count(self):
        return len([a for a in self.applicants if a.attendee_id])

    @property
    def can_add_badges(self):
        uncomped_badge_count = len([
            a for a in self.applicants if a.attendee_id
            and a.attendee.paid not in [c.NEED_NOT_PAY, c.REFUNDED]
        ])
        claimed_badges = len(self.applicants) - uncomped_badge_count
        return claimed_badges < c.MITS_BADGES_PER_TEAM

    @property
    def can_save(self):
        return c.HAS_MITS_ADMIN_ACCESS or self.status in [
            c.ACCEPTED, c.WAITLISTED
        ] or (self.is_new and c.BEFORE_MITS_SUBMISSION_DEADLINE
              or c.BEFORE_MITS_EDITING_DEADLINE)

    @property
    def completed_panel_request(self):
        return self.panel_interest is not None

    @property
    def completed_showcase_request(self):
        return self.showcase_interest is not None

    @property
    def completed_hotel_form(self):
        """
        This is "any" rather than "all" because teams are allowed to
        add and remove members even after their application has been
        submitted. Rather than suddenly downgrade their completion
        percentage, it makes more sense to send such teams an
        automated email indicating that they need to provide their
        remaining hotel info.
        """
        return any(a.declined_hotel_space or a.requested_room_nights
                   for a in self.applicants)

    @property
    def no_hotel_space(self):
        return all(a.declined_hotel_space for a in self.applicants)

    @property
    def steps_completed(self):
        if not self.games:
            return 1
        elif not self.pictures:
            return 2
        elif not self.completed_panel_request:
            return 3
        elif not self.completed_showcase_request:
            return 4
        elif not self.completed_hotel_form:
            return 5
        elif not self.submitted:
            return 6
        else:
            return 7

    @property
    def completion_percentage(self):
        return 100 * self.steps_completed // c.MITS_APPLICATION_STEPS
Ejemplo n.º 11
0
class Attendee(MagModel, TakesPaymentMixin):
    watchlist_id = Column(UUID,
                          ForeignKey('watch_list.id', ondelete='set null'),
                          nullable=True,
                          default=None)

    group_id = Column(UUID,
                      ForeignKey('group.id', ondelete='SET NULL'),
                      nullable=True)
    group = relationship(Group,
                         backref='attendees',
                         foreign_keys=group_id,
                         cascade='save-update,merge,refresh-expire,expunge')

    # NOTE: The cascade relationships for promo_code do NOT include
    # "save-update". During the preregistration workflow, before an Attendee
    # has paid, we create ephemeral Attendee objects that are saved in the
    # cherrypy session, but are NOT saved in the database. If the cascade
    # relationships specified "save-update" then the Attendee would
    # automatically be inserted in the database when the promo_code is set on
    # the Attendee object (which we do not want until the attendee pays).
    #
    # The practical result of this is that we must manually set promo_code_id
    # in order for the relationship to be persisted.
    promo_code_id = Column(UUID,
                           ForeignKey('promo_code.id'),
                           nullable=True,
                           index=True)
    promo_code = relationship('PromoCode',
                              backref=backref(
                                  'used_by',
                                  cascade='merge,refresh-expire,expunge'),
                              foreign_keys=promo_code_id,
                              cascade='merge,refresh-expire,expunge')

    placeholder = Column(Boolean, default=False, admin_only=True)
    first_name = Column(UnicodeText)
    last_name = Column(UnicodeText)
    legal_name = Column(UnicodeText)
    email = Column(UnicodeText)
    birthdate = Column(Date, nullable=True, default=None)
    age_group = Column(Choice(c.AGE_GROUPS),
                       default=c.AGE_UNKNOWN,
                       nullable=True)

    international = Column(Boolean, default=False)
    zip_code = Column(UnicodeText)
    address1 = Column(UnicodeText)
    address2 = Column(UnicodeText)
    city = Column(UnicodeText)
    region = Column(UnicodeText)
    country = Column(UnicodeText)
    no_cellphone = Column(Boolean, default=False)
    ec_name = Column(UnicodeText)
    ec_phone = Column(UnicodeText)
    cellphone = Column(UnicodeText)

    # Represents a request for hotel booking info during preregistration
    requested_hotel_info = Column(Boolean, default=False)

    interests = Column(MultiChoice(c.INTEREST_OPTS))
    found_how = Column(UnicodeText)
    comments = Column(UnicodeText)
    for_review = Column(UnicodeText, admin_only=True)
    admin_notes = Column(UnicodeText, admin_only=True)

    public_id = Column(UUID, default=lambda: str(uuid4()))
    badge_num = Column(Integer, default=None, nullable=True, admin_only=True)
    badge_type = Column(Choice(c.BADGE_OPTS), default=c.ATTENDEE_BADGE)
    badge_status = Column(Choice(c.BADGE_STATUS_OPTS),
                          default=c.NEW_STATUS,
                          index=True,
                          admin_only=True)
    ribbon = Column(MultiChoice(c.RIBBON_OPTS), admin_only=True)

    affiliate = Column(UnicodeText)

    # attendee shirt size for both swag and staff shirts
    shirt = Column(Choice(c.SHIRT_OPTS), default=c.NO_SHIRT)
    can_spam = Column(Boolean, default=False)
    regdesk_info = Column(UnicodeText, admin_only=True)
    extra_merch = Column(UnicodeText, admin_only=True)
    got_merch = Column(Boolean, default=False, admin_only=True)

    reg_station = Column(Integer, nullable=True, admin_only=True)
    registered = Column(UTCDateTime, server_default=utcnow())
    confirmed = Column(UTCDateTime, nullable=True, default=None)
    checked_in = Column(UTCDateTime, nullable=True)

    paid = Column(Choice(c.PAYMENT_OPTS),
                  default=c.NOT_PAID,
                  index=True,
                  admin_only=True)
    overridden_price = Column(Integer, nullable=True, admin_only=True)
    base_badge_price = Column(Integer, default=0, admin_only=True)
    amount_paid = Column(Integer, default=0, admin_only=True)
    amount_extra = Column(Choice(c.DONATION_TIER_OPTS, allow_unspecified=True),
                          default=0)
    extra_donation = Column(Integer, default=0)
    payment_method = Column(Choice(c.PAYMENT_METHOD_OPTS), nullable=True)
    amount_refunded = Column(Integer, default=0, admin_only=True)

    badge_printed_name = Column(UnicodeText)

    requested_any_dept = Column(Boolean, default=False)

    dept_memberships = relationship('DeptMembership', backref='attendee')
    dept_roles = relationship(
        'DeptRole',
        backref='attendees',
        cascade='save-update,merge,refresh-expire,expunge',
        secondaryjoin='and_('
        'dept_membership_dept_role.c.dept_role_id '
        '== DeptRole.id, '
        'dept_membership_dept_role.c.dept_membership_id '
        '== DeptMembership.id)',
        secondary='join(DeptMembership, dept_membership_dept_role)',
        order_by='DeptRole.name',
        viewonly=True)
    shifts = relationship('Shift', backref='attendee')
    jobs_in_assigned_depts = relationship(
        'Job',
        backref='attendees_in_dept',
        cascade='save-update,merge,refresh-expire,expunge',
        secondaryjoin='DeptMembership.department_id == Job.department_id',
        secondary='dept_membership',
        order_by='Job.name',
        viewonly=True)
    depts_where_working = relationship(
        'Department',
        backref='attendees_working_shifts',
        cascade='save-update,merge,refresh-expire,expunge',
        secondary='join(Shift, Job)',
        order_by='Department.name',
        viewonly=True)
    dept_memberships_with_role = relationship(
        'DeptMembership',
        primaryjoin='and_('
        'Attendee.id == DeptMembership.attendee_id, '
        'DeptMembership.has_role == True)',
        viewonly=True)
    dept_memberships_as_dept_head = relationship(
        'DeptMembership',
        primaryjoin='and_('
        'Attendee.id == DeptMembership.attendee_id, '
        'DeptMembership.is_dept_head == True)',
        viewonly=True)
    dept_memberships_as_poc = relationship(
        'DeptMembership',
        primaryjoin='and_('
        'Attendee.id == DeptMembership.attendee_id, '
        'DeptMembership.is_poc == True)',
        viewonly=True)
    dept_memberships_where_can_admin_checklist = relationship(
        'DeptMembership',
        primaryjoin='and_('
        'Attendee.id == DeptMembership.attendee_id, '
        'or_('
        'DeptMembership.is_dept_head == True,'
        'DeptMembership.is_checklist_admin == True))',
        viewonly=True)
    dept_memberships_as_checklist_admin = relationship(
        'DeptMembership',
        primaryjoin='and_('
        'Attendee.id == DeptMembership.attendee_id, '
        'DeptMembership.is_checklist_admin == True)',
        viewonly=True)
    pocs_for_depts_where_working = relationship(
        'Attendee',
        cascade='save-update,merge,refresh-expire,expunge',
        primaryjoin='Attendee.id == Shift.attendee_id',
        secondaryjoin='and_('
        'DeptMembership.attendee_id == Attendee.id, '
        'DeptMembership.is_poc == True)',
        secondary='join(Shift, Job).join(DeptMembership, '
        'DeptMembership.department_id == Job.department_id)',
        order_by='Attendee.full_name',
        viewonly=True)
    dept_heads_for_depts_where_working = relationship(
        'Attendee',
        cascade='save-update,merge,refresh-expire,expunge',
        primaryjoin='Attendee.id == Shift.attendee_id',
        secondaryjoin='and_('
        'DeptMembership.attendee_id == Attendee.id, '
        'DeptMembership.is_dept_head == True)',
        secondary='join(Shift, Job).join(DeptMembership, '
        'DeptMembership.department_id == Job.department_id)',
        order_by='Attendee.full_name',
        viewonly=True)

    staffing = Column(Boolean, default=False)
    nonshift_hours = Column(Integer, default=0, admin_only=True)
    past_years = Column(UnicodeText, admin_only=True)
    can_work_setup = Column(Boolean, default=False, admin_only=True)
    can_work_teardown = Column(Boolean, default=False, admin_only=True)

    # TODO: a record of when an attendee is unable to pickup a shirt
    # (which type? swag or staff? prob swag)
    no_shirt = relationship('NoShirt',
                            backref=backref('attendee', load_on_pending=True),
                            uselist=False)

    admin_account = relationship('AdminAccount',
                                 backref=backref('attendee',
                                                 load_on_pending=True),
                                 uselist=False)
    food_restrictions = relationship('FoodRestrictions',
                                     backref=backref('attendee',
                                                     load_on_pending=True),
                                     uselist=False)

    sales = relationship('Sale',
                         backref='attendee',
                         cascade='save-update,merge,refresh-expire,expunge')
    mpoints_for_cash = relationship('MPointsForCash', backref='attendee')
    old_mpoint_exchanges = relationship('OldMPointExchange',
                                        backref='attendee')
    dept_checklist_items = relationship('DeptChecklistItem',
                                        backref=backref('attendee',
                                                        lazy='subquery'))

    _attendee_table_args = [Index('ix_attendee_paid_group_id', paid, group_id)]
    if not c.SQLALCHEMY_URL.startswith('sqlite'):
        _attendee_table_args.append(
            UniqueConstraint('badge_num',
                             deferrable=True,
                             initially='DEFERRED'))

    __table_args__ = tuple(_attendee_table_args)
    _repr_attr_names = ['full_name']

    @predelete_adjustment
    def _shift_badges(self):
        if self.badge_num:
            self.session.shift_badges(self.badge_type,
                                      self.badge_num + 1,
                                      down=True)

    @presave_adjustment
    def _misc_adjustments(self):
        if not self.amount_extra:
            self.affiliate = ''

        if self.birthdate == '':
            self.birthdate = None

        if not self.extra_donation:
            self.extra_donation = 0

        if not self.gets_any_kind_of_shirt:
            self.shirt = c.NO_SHIRT

        if self.paid != c.REFUNDED:
            self.amount_refunded = 0

        if self.badge_cost == 0 and self.paid in [c.NOT_PAID, c.PAID_BY_GROUP]:
            self.paid = c.NEED_NOT_PAY

        if not self.base_badge_price:
            self.base_badge_price = self.new_badge_cost

        if c.AT_THE_CON and self.badge_num and not self.checked_in and \
                self.is_new and \
                self.badge_type not in c.PREASSIGNED_BADGE_TYPES:
            self.checked_in = datetime.now(UTC)

        if self.birthdate:
            self.age_group = self.age_group_conf['val']

        for attr in ['first_name', 'last_name']:
            value = getattr(self, attr)
            if value.isupper() or value.islower():
                setattr(self, attr, value.title())

        if self.legal_name and self.full_name == self.legal_name:
            self.legal_name = ''

    @presave_adjustment
    def _status_adjustments(self):
        if self.badge_status == c.NEW_STATUS and self.banned:
            self.badge_status = c.WATCHED_STATUS
            try:
                send_email(c.SECURITY_EMAIL,
                           [c.REGDESK_EMAIL, c.SECURITY_EMAIL],
                           c.EVENT_NAME + ' WatchList Notification',
                           render('emails/reg_workflow/attendee_watchlist.txt',
                                  {'attendee': self}),
                           model='n/a')
            except:
                log.error('unable to send banned email about {}', self)

        elif self.badge_status == c.NEW_STATUS and not self.placeholder and \
                self.first_name and (
                    self.paid in [c.HAS_PAID, c.NEED_NOT_PAY] or
                    self.paid == c.PAID_BY_GROUP and
                    self.group_id and
                    not self.group.is_unpaid):
            self.badge_status = c.COMPLETED_STATUS

    @presave_adjustment
    def _staffing_adjustments(self):
        if self.is_dept_head:
            self.staffing = True
            if c.SHIFT_CUSTOM_BADGES or \
                    c.STAFF_BADGE not in c.PREASSIGNED_BADGE_TYPES:
                self.badge_type = c.STAFF_BADGE
            if self.paid == c.NOT_PAID:
                self.paid = c.NEED_NOT_PAY
        elif c.VOLUNTEER_RIBBON in self.ribbon_ints and self.is_new:
            self.staffing = True

        if not self.is_new:
            old_ribbon = map(int, self.orig_value_of('ribbon').split(',')) \
                if self.orig_value_of('ribbon') else []
            old_staffing = self.orig_value_of('staffing')

            if self.staffing and not old_staffing or \
                    c.VOLUNTEER_RIBBON in self.ribbon_ints and \
                    c.VOLUNTEER_RIBBON not in old_ribbon:
                self.staffing = True

            elif old_staffing and not self.staffing \
                    or c.VOLUNTEER_RIBBON not in self.ribbon_ints \
                    and c.VOLUNTEER_RIBBON in old_ribbon \
                    and not self.is_dept_head:
                self.unset_volunteering()

        if self.badge_type == c.STAFF_BADGE:
            self.ribbon = remove_opt(self.ribbon_ints, c.VOLUNTEER_RIBBON)

        elif self.staffing and self.badge_type != c.STAFF_BADGE and \
                c.VOLUNTEER_RIBBON not in self.ribbon_ints:
            self.ribbon = add_opt(self.ribbon_ints, c.VOLUNTEER_RIBBON)

        if self.badge_type == c.STAFF_BADGE:
            self.staffing = True
            if not self.overridden_price \
                    and self.paid in [c.NOT_PAID, c.PAID_BY_GROUP]:
                self.paid = c.NEED_NOT_PAY

    @presave_adjustment
    def _badge_adjustments(self):
        from uber.badge_funcs import needs_badge_num
        if self.badge_type == c.PSEUDO_DEALER_BADGE:
            self.ribbon = add_opt(self.ribbon_ints, c.DEALER_RIBBON)

        self.badge_type = self.badge_type_real

        old_type = self.orig_value_of('badge_type')
        old_num = self.orig_value_of('badge_num')

        if not needs_badge_num(self):
            self.badge_num = None

        if old_type != self.badge_type or old_num != self.badge_num:
            self.session.update_badge(self, old_type, old_num)
        elif needs_badge_num(self) and not self.badge_num:
            self.badge_num = self.session.get_next_badge_num(self.badge_type)

    @presave_adjustment
    def _use_promo_code(self):
        if c.BADGE_PROMO_CODES_ENABLED and self.promo_code and \
                not self.overridden_price and self.is_unpaid:
            if self.badge_cost > 0:
                self.overridden_price = self.badge_cost
            else:
                self.paid = c.NEED_NOT_PAY

    def unset_volunteering(self):
        self.staffing = False
        self.requested_any_dept = False
        self.requested_depts = []
        self.assigned_depts = []
        self.ribbon = remove_opt(self.ribbon_ints, c.VOLUNTEER_RIBBON)
        if self.badge_type == c.STAFF_BADGE:
            self.badge_type = c.ATTENDEE_BADGE
            self.badge_num = None
        del self.shifts[:]

    @property
    def ribbon_labels(self):
        labels = super(Attendee, self)._labels('ribbon', self.ribbon)
        if c.DEPT_HEAD_RIBBON in self.ribbon_ints or not self.is_dept_head:
            return labels
        labels.append(c.RIBBONS[c.DEPT_HEAD_RIBBON])
        return sorted(labels)

    @property
    def ribbon_and_or_badge(self):
        if self.ribbon and self.badge_type != c.ATTENDEE_BADGE:
            return ' / '.join([self.badge_type_label] + self.ribbon_labels)
        elif self.ribbon:
            return ' / '.join(self.ribbon_labels)
        else:
            return self.badge_type_label

    @property
    def badge_type_real(self):
        return get_real_badge_type(self.badge_type)

    @cost_property
    def badge_cost(self):
        return self.calculate_badge_cost()

    @property
    def badge_cost_without_promo_code(self):
        return self.calculate_badge_cost(use_promo_code=False)

    def calculate_badge_cost(self, use_promo_code=True):
        if self.paid == c.NEED_NOT_PAY:
            return 0
        elif self.overridden_price is not None:
            return self.overridden_price
        elif self.base_badge_price:
            cost = self.base_badge_price
        else:
            cost = self.new_badge_cost

        if c.BADGE_PROMO_CODES_ENABLED and self.promo_code and use_promo_code:
            return self.promo_code.calculate_discounted_price(cost)
        else:
            return cost

    @property
    def new_badge_cost(self):
        # What this badge would cost if it were new, i.e., not taking into
        # account special overrides
        registered = self.registered_local if self.registered else None
        if self.is_dealer:
            return c.DEALER_BADGE_PRICE
        elif self.badge_type == c.ONE_DAY_BADGE:
            return c.get_oneday_price(registered)
        elif self.is_presold_oneday:
            return c.get_presold_oneday_price(self.badge_type)
        elif self.badge_type in c.BADGE_TYPE_PRICES:
            return int(c.BADGE_TYPE_PRICES[self.badge_type])
        elif self.age_discount != 0:
            return max(0, c.get_attendee_price(registered) + self.age_discount)
        elif self.group and self.paid == c.PAID_BY_GROUP:
            return c.get_attendee_price(registered) - c.GROUP_DISCOUNT
        else:
            return c.get_attendee_price(registered)

    @property
    def promo_code_code(self):
        """
        Convenience property for accessing `promo_code.code` if available.

        Returns:
            str: `promo_code.code` if `promo_code` is not `None`, empty string
                otherwise.
        """
        return self.promo_code.code if self.promo_code else ''

    @property
    def age_discount(self):
        return -self.age_group_conf['discount']

    @property
    def age_group_conf(self):
        if self.birthdate:
            day = c.EPOCH.date() \
                if date.today() <= c.EPOCH.date() \
                else localized_now().date()

            attendee_age = get_age_from_birthday(self.birthdate, day)
            for val, age_group in c.AGE_GROUP_CONFIGS.items():
                if val != c.AGE_UNKNOWN and \
                        age_group['min_age'] <= attendee_age and \
                        attendee_age <= age_group['max_age']:
                    return age_group

        return c.AGE_GROUP_CONFIGS[int(self.age_group or c.AGE_UNKNOWN)]

    @property
    def total_cost(self):
        return self.default_cost + self.amount_extra

    @property
    def total_donation(self):
        return self.total_cost - self.badge_cost

    @cost_property
    def donation_cost(self):
        return self.extra_donation or 0

    @property
    def amount_unpaid(self):
        if self.paid == c.PAID_BY_GROUP:
            personal_cost = max(0, self.total_cost - self.badge_cost)
        else:
            personal_cost = self.total_cost
        return max(0, personal_cost - self.amount_paid)

    @property
    def is_unpaid(self):
        return self.paid == c.NOT_PAID

    @property
    def is_unassigned(self):
        return not self.first_name

    @property
    def is_dealer(self):
        return c.DEALER_RIBBON in self.ribbon_ints or \
            self.badge_type == c.PSEUDO_DEALER_BADGE or (
                self.group and
                self.group.is_dealer and
                self.paid == c.PAID_BY_GROUP)

    @property
    def is_checklist_admin(self):
        return any(m.is_checklist_admin for m in self.dept_memberships)

    @property
    def is_dept_head(self):
        return any(m.is_dept_head for m in self.dept_memberships)

    @property
    def is_presold_oneday(self):
        """
        Returns a boolean indicating whether this is a c.FRIDAY/c.SATURDAY/etc
        badge; see the presell_one_days config option for a full explanation.
        """
        return self.badge_type_label in c.DAYS_OF_WEEK

    @property
    def is_not_ready_to_checkin(self):
        """
        Returns None if we are ready for checkin, otherwise a short error
        message why we can't check them in.
        """
        if self.paid == c.NOT_PAID:
            return "Not paid"

        # When someone claims an unassigned group badge on-site, they first
        # fill out a new registration which is paid-by-group but isn't assigned
        # to a group yet (the admin does that when they check in).
        if self.badge_status != c.COMPLETED_STATUS and not (
                self.badge_status == c.NEW_STATUS
                and self.paid == c.PAID_BY_GROUP and not self.group_id):
            return "Badge status"

        if self.is_unassigned:
            return "Badge not assigned"

        if self.is_presold_oneday:
            if self.badge_type_label != localized_now().strftime('%A'):
                return "Wrong day"

        return None

    @property
    # should be OK
    def shirt_size_marked(self):
        return self.shirt not in [c.NO_SHIRT, c.SIZE_UNKNOWN]

    @property
    def is_group_leader(self):
        return self.group and self.id == self.group.leader_id

    @property
    def unassigned_name(self):
        if self.group_id and self.is_unassigned:
            return '[Unassigned {self.badge}]'.format(self=self)

    @hybrid_property
    def full_name(self):
        return self.unassigned_name or \
            '{self.first_name} {self.last_name}'.format(self=self)

    @full_name.expression
    def full_name(cls):
        return case(
            [(
                or_(cls.first_name == None, cls.first_name
                    == ''),  # noqa: E711
                'zzz')],
            else_=func.lower(cls.first_name + ' ' + cls.last_name))

    @hybrid_property
    def last_first(self):
        return self.unassigned_name or \
            '{self.last_name}, {self.first_name}'.format(self=self)

    @last_first.expression
    def last_first(cls):
        return case(
            [(
                or_(cls.first_name == None, cls.first_name
                    == ''),  # noqa: E711
                'zzz')],
            else_=func.lower(cls.last_name + ', ' + cls.first_name))

    @hybrid_property
    def normalized_email(self):
        return self.normalize_email(self.email)

    @normalized_email.expression
    def normalized_email(cls):
        return func.replace(func.lower(func.trim(cls.email)), '.', '')

    @classmethod
    def normalize_email(cls, email):
        return email.strip().lower().replace('.', '')

    @property
    def watchlist_guess(self):
        try:
            from uber.models import Session
            with Session() as session:
                watchentries = session.guess_attendee_watchentry(self)
                return [w.to_dict() for w in watchentries]
        except Exception as ex:
            log.warning('Error guessing watchlist entry: {}', ex)
            return None

    @property
    def banned(self):
        return listify(self.watch_list or self.watchlist_guess)

    @property
    def badge(self):
        if self.paid == c.NOT_PAID:
            badge = 'Unpaid ' + self.badge_type_label
        elif self.badge_num:
            badge = '{} #{}'.format(self.badge_type_label, self.badge_num)
        else:
            badge = self.badge_type_label

        if self.ribbon:
            badge += ' ({})'.format(", ".join(self.ribbon_labels))

        return badge

    @property
    def is_transferable(self):
        return not self.is_new and \
            not self.checked_in and \
            self.paid in [c.HAS_PAID, c.PAID_BY_GROUP] and \
            self.badge_type in c.TRANSFERABLE_BADGE_TYPES and \
            not self.admin_account and \
            not self.has_role_somewhere

    @property
    def paid_for_a_swag_shirt(self):
        return self.amount_extra >= c.SHIRT_LEVEL

    @property
    def volunteer_swag_shirt_eligible(self):
        """
        Returns: True if this attendee is eligible for a swag shirt *due to
            their status as a volunteer or staff*. They may additionally be
            eligible for a swag shirt for other reasons too.
        """

        # Some events want to exclude staff badges from getting swag shirts
        # (typically because they are getting staff uniform shirts instead).
        if self.badge_type == c.STAFF_BADGE:
            return c.STAFF_ELIGIBLE_FOR_SWAG_SHIRT
        else:
            return c.VOLUNTEER_RIBBON in self.ribbon_ints

    @property
    def volunteer_swag_shirt_earned(self):
        return self.volunteer_swag_shirt_eligible and (not self.takes_shifts or
                                                       self.worked_hours >= 6)

    @property
    def num_swag_shirts_owed(self):
        swag_shirts = int(self.paid_for_a_swag_shirt)
        volunteer_shirts = int(self.volunteer_swag_shirt_eligible)
        return swag_shirts + volunteer_shirts

    @property
    def gets_staff_shirt(self):
        return self.badge_type == c.STAFF_BADGE

    @property
    def gets_any_kind_of_shirt(self):
        return self.gets_staff_shirt or self.num_swag_shirts_owed > 0

    @property
    def has_personalized_badge(self):
        return self.badge_type in c.PREASSIGNED_BADGE_TYPES

    @property
    def donation_swag(self):
        donation_items = [
            desc for amount, desc in sorted(c.DONATION_TIERS.items())
            if amount and self.amount_extra >= amount
        ]

        extra_donations = \
            ['Extra donation of ${}'.format(self.extra_donation)] \
            if self.extra_donation else []

        return donation_items + extra_donations

    @property
    def merch(self):
        """
        Here is the business logic surrounding shirts:

            - People who kick in enough to get a shirt get a shirt.
            - People with staff badges get a configurable number of staff
              shirts.
            - Volunteers who meet the requirements get a complementary swag
              shirt (NOT a staff shirt).

        """
        merch = self.donation_swag

        if self.volunteer_swag_shirt_eligible:
            shirt = c.DONATION_TIERS[c.SHIRT_LEVEL]
            if self.paid_for_a_swag_shirt:
                shirt = 'a 2nd ' + shirt
            if not self.volunteer_swag_shirt_earned:
                shirt += (' (this volunteer must work at least 6 hours or '
                          'they will be reported for picking up their shirt)')
            merch.append(shirt)

        if self.gets_staff_shirt:
            staff_shirts = '{} Staff Shirt{}'.format(
                c.SHIRTS_PER_STAFFER, 's' if c.SHIRTS_PER_STAFFER > 1 else '')
            if self.shirt_size_marked:
                staff_shirts += ' [{}]'.format(c.SHIRTS[self.shirt])
            merch.append(staff_shirts)

        if self.staffing:
            merch.append('Staffer Info Packet')

        if self.extra_merch:
            merch.append(self.extra_merch)

        return comma_and(merch)

    @property
    def accoutrements(self):
        stuff = [] \
            if not self.ribbon \
            else ['a ' + s + ' ribbon' for s in self.ribbon_labels]

        if c.WRISTBANDS_ENABLED:
            stuff.append('a {} wristband'.format(
                c.WRISTBAND_COLORS[self.age_group]))
        if self.regdesk_info:
            stuff.append(self.regdesk_info)
        return (' with ' if stuff else '') + comma_and(stuff)

    @property
    def multiply_assigned(self):
        return len(self.dept_memberships) > 1

    @property
    def takes_shifts(self):
        return bool(self.staffing
                    and any(not d.is_shiftless for d in self.assigned_depts))

    @property
    def hours(self):
        all_hours = set()
        for shift in self.shifts:
            all_hours.update(shift.job.hours)
        return all_hours

    @property
    def hour_map(self):
        all_hours = {}
        for shift in self.shifts:
            for hour in shift.job.hours:
                all_hours[hour] = shift.job
        return all_hours

    @cached_property
    def available_jobs(self):
        if not self.dept_memberships:
            return []

        def _get_available_jobs(session, attendee_id):
            from uber.models.department import DeptMembership, Job, Shift
            return session.query(Job) \
                .outerjoin(Job.shifts) \
                .filter(
                    Job.department_id == DeptMembership.department_id,
                    DeptMembership.attendee_id == attendee_id) \
                .group_by(Job.id) \
                .having(func.count(Shift.id) < Job.slots) \
                .order_by(Job.start_time, Job.department_id).all()

        if self.session:
            jobs = _get_available_jobs(self.session, self.id)
        else:
            from uber.models import Session
            with Session() as session:
                jobs = _get_available_jobs(session, self.id)

        return [job for job in jobs if self.has_required_roles(job)]

    @cached_property
    def possible(self):
        assert self.session, ('{}.possible property may only be accessed for '
                              'objects attached to a session'.format(
                                  self.__class__.__name__))

        if not self.dept_memberships and not c.AT_THE_CON:
            return []
        else:
            from uber.models.department import DeptMembership, Job
            job_query = self.session.query(Job) \
                .filter(
                    Job.department_id == DeptMembership.department_id,
                    DeptMembership.attendee_id == self.id) \
                .options(
                    subqueryload(Job.shifts),
                    subqueryload(Job.required_roles)) \
                .order_by(Job.start_time, Job.department_id)

            return [
                job for job in job_query
                if job.slots > len(job.shifts) and job.no_overlap(self) and (
                    job.type != c.SETUP or self.can_work_setup) and (
                        job.type != c.TEARDOWN or self.can_work_teardown)
                and self.has_required_roles(job)
            ]

    @property
    def possible_opts(self):
        return [(job.id, '({}) [{}] {}'.format(hour_day_format(job.start_time),
                                               job.department_name, job.name))
                for job in self.possible if localized_now() < job.start_time]

    @property
    def possible_and_current(self):
        jobs = [s.job for s in self.shifts]
        for job in jobs:
            job.taken = True
        jobs.extend(self.possible)
        return sorted(jobs, key=lambda j: j.start_time)

    # ========================================================================
    # TODO: Refactor all this stuff regarding assigned_depts and
    #       requested_depts. Maybe a @suffix_property with a setter for the
    #       *_ids fields? The hardcoded *_labels props are also not great.
    #       There's a bigger feature here that I haven't wrapped my head
    #       around yet. A generic way to lazily set relations using ids.
    # ========================================================================

    @classproperty
    def extra_apply_attrs(cls):
        return set(['assigned_depts_ids'
                    ]).union(cls.extra_apply_attrs_restricted)

    @classproperty
    def extra_apply_attrs_restricted(cls):
        return set(['requested_depts_ids'])

    @property
    def assigned_depts_labels(self):
        return [d.name for d in self.assigned_depts]

    @property
    def requested_depts_labels(self):
        return [d.name for d in self.requested_depts]

    @property
    def assigned_depts_ids(self):
        _, ids = self._get_relation_ids('assigned_depts')
        return [str(d.id) for d in self.assigned_depts] if ids is None else ids

    @assigned_depts_ids.setter
    def assigned_depts_ids(self, value):
        values = set(s for s in listify(value) if s)
        for membership in list(self.dept_memberships):
            if membership.department_id not in values:
                # Manually remove dept_memberships to ensure the associated
                # rows in the dept_membership_dept_role table are deleted.
                self.dept_memberships.remove(membership)
        from uber.models.department import Department
        self._set_relation_ids('assigned_depts', Department, list(values))

    @property
    def requested_depts_ids(self):
        any_dept = ['All'] if self.requested_any_dept else []
        _, ids = self._get_relation_ids('requested_depts')
        return any_dept + ([str(d.id) for d in self.requested_depts]
                           if ids is None else ids)

    @requested_depts_ids.setter
    def requested_depts_ids(self, value):
        values = set(s for s in listify(value) if s)
        self.requested_any_dept = 'All' in values
        if self.requested_any_dept:
            values.remove('All')
        from uber.models.department import Department
        self._set_relation_ids('requested_depts', Department, list(values))

    @property
    def worked_shifts(self):
        return [s for s in self.shifts if s.worked == c.SHIFT_WORKED]

    @property
    def weighted_hours(self):
        weighted_hours = sum(s.job.weighted_hours for s in self.shifts)
        return weighted_hours + self.nonshift_hours

    @department_id_adapter
    def weighted_hours_in(self, department_id):
        if not department_id:
            return self.weighted_hours
        return sum(shift.job.weighted_hours for shift in self.shifts
                   if shift.job.department_id == department_id)

    @property
    def worked_hours(self):
        weighted_hours = sum(s.job.real_duration * s.job.weight
                             for s in self.worked_shifts)
        return weighted_hours + self.nonshift_hours

    @department_id_adapter
    def dept_membership_for(self, department_id):
        if not department_id:
            return None
        for m in self.dept_memberships:
            if m.department_id == department_id:
                return m
        return None

    @department_id_adapter
    def requested(self, department_id):
        if self.requested_any_dept:
            return True
        if not department_id:
            return False
        return any(d.id == department_id for d in self.requested_depts)

    @department_id_adapter
    def assigned_to(self, department_id):
        if not department_id:
            return False
        return any(m.department_id == department_id
                   for m in self.dept_memberships)

    def trusted_in(self, department):
        return self.has_role_in(department)

    def can_admin_dept_for(self, department):
        return (self.admin_account
                and c.ACCOUNTS in self.admin_account.access_ints) \
                    or self.is_dept_head_of(department)

    @department_id_adapter
    def can_admin_checklist_for(self, department_id):
        if not department_id:
            return False
        return (self.admin_account
                and c.ACCOUNTS in self.admin_account.access_ints) \
            or any(
                m.department_id == department_id
                for m in self.dept_memberships_where_can_admin_checklist)

    def is_checklist_admin_of(self, department_id):
        if not department_id:
            return False
        return any(m.department_id == department_id and m.is_checklist_admin
                   for m in self.dept_memberships)

    def is_dept_head_of(self, department_id):
        if not department_id:
            return False
        return any(m.department_id == department_id and m.is_dept_head
                   for m in self.dept_memberships)

    def is_poc_of(self, department_id):
        if not department_id:
            return False
        return any(m.department_id == department_id and m.is_poc
                   for m in self.dept_memberships)

    def completed_every_checklist_for(self, slug):
        return all(
            d.checklist_item_for_slug(slug) for d in self.checklist_depts)

    @property
    def gets_any_checklist(self):
        return bool(self.dept_memberships_as_checklist_admin)

    def has_role(self, role):
        return any(r.id == role.id for r in self.dept_roles)

    @department_id_adapter
    def has_role_in(self, department_id):
        if not department_id:
            return False
        return any(m.department_id == department_id
                   for m in self.dept_memberships_with_role)

    def has_required_roles(self, job):
        if not job.required_roles:
            return True
        required_role_ids = set(r.id for r in job.required_roles)
        role_ids = set(r.id for r in self.dept_roles)
        return required_role_ids.issubset(role_ids)

    @property
    def has_role_somewhere(self):
        """
        Returns True if at least one of the following is true for at least
        one department:
            - is a department head
            - is a point of contact
            - is a checklist admin
            - has a dept role
        """
        return bool(self.dept_memberships_with_role)

    def has_shifts_in(self, department):
        return department in self.depts_where_working

    @property
    def food_restrictions_filled_out(self):
        return self.food_restrictions if c.STAFF_GET_FOOD else True

    @property
    def shift_prereqs_complete(self):
        return not self.placeholder and \
            self.food_restrictions_filled_out and self.shirt_size_marked

    @property
    def past_years_json(self):
        return json.loads(self.past_years or '[]')

    @property
    def must_contact(self):
        dept_chairs = []
        for dept in self.depts_where_working:
            poc_names = ' / '.join(sorted(poc.full_name for poc in dept.pocs))
            dept_chairs.append('({}) {}'.format(dept.name, poc_names))
        return safe_string('<br/>'.join(sorted(dept_chairs)))
Ejemplo n.º 12
0
class Group(MagModel, TakesPaymentMixin):
    public_id = Column(UUID, default=lambda: str(uuid4()))
    name = Column(UnicodeText)
    tables = Column(Numeric, default=0)
    zip_code = Column(UnicodeText)
    address1 = Column(UnicodeText)
    address2 = Column(UnicodeText)
    city = Column(UnicodeText)
    region = Column(UnicodeText)
    country = Column(UnicodeText)
    website = Column(UnicodeText)
    wares = Column(UnicodeText)
    categories = Column(MultiChoice(c.DEALER_WARES_OPTS))
    categories_text = Column(UnicodeText)
    description = Column(UnicodeText)
    special_needs = Column(UnicodeText)

    amount_paid_override = Column(Integer,
                                  default=0,
                                  index=True,
                                  admin_only=True)
    amount_refunded_override = Column(Integer, default=0, admin_only=True)
    cost = Column(Integer, default=0, admin_only=True)
    purchased_items = Column(MutableDict.as_mutable(JSONB),
                             default={},
                             server_default='{}')
    refunded_items = Column(MutableDict.as_mutable(JSONB),
                            default={},
                            server_default='{}')
    auto_recalc = Column(Boolean, default=True, admin_only=True)
    stripe_txn_share_logs = relationship('StripeTransactionGroup',
                                         backref='group')

    can_add = Column(Boolean, default=False, admin_only=True)
    admin_notes = Column(UnicodeText, admin_only=True)
    status = Column(Choice(c.DEALER_STATUS_OPTS),
                    default=c.UNAPPROVED,
                    admin_only=True)
    registered = Column(UTCDateTime, server_default=utcnow())
    approved = Column(UTCDateTime, nullable=True)
    leader_id = Column(UUID,
                       ForeignKey('attendee.id',
                                  use_alter=True,
                                  name='fk_leader'),
                       nullable=True)
    creator_id = Column(UUID, ForeignKey('attendee.id'), nullable=True)

    creator = relationship('Attendee',
                           foreign_keys=creator_id,
                           backref=backref('created_groups',
                                           order_by='Group.name',
                                           cascade='all,delete-orphan'),
                           cascade='save-update,merge,refresh-expire,expunge',
                           remote_side='Attendee.id',
                           single_parent=True)
    leader = relationship('Attendee',
                          foreign_keys=leader_id,
                          post_update=True,
                          cascade='all')
    studio = relationship('IndieStudio', uselist=False, backref='group')
    guest = relationship('GuestGroup', backref='group', uselist=False)

    _repr_attr_names = ['name']

    @presave_adjustment
    def _cost_and_leader(self):
        assigned = [a for a in self.attendees if not a.is_unassigned]
        if len(assigned) == 1:
            [self.leader] = assigned
        if self.auto_recalc:
            self.cost = self.default_cost
        elif not self.cost:
            self.cost = 0
        if self.status == c.APPROVED and not self.approved:
            self.approved = datetime.now(UTC)
        if self.leader and self.is_dealer:
            self.leader.ribbon = add_opt(self.leader.ribbon_ints,
                                         c.DEALER_RIBBON)
        if not self.is_unpaid:
            for a in self.attendees:
                a.presave_adjustments()

    @presave_adjustment
    def update_purchased_items(self):
        if self.cost == self.orig_value_of(
                'cost') and self.tables == self.orig_value_of('tables'):
            return

        self.purchased_items.clear()
        if not self.auto_recalc:
            # ¯\_(ツ)_/¯
            if self.cost:
                self.purchased_items['group_total'] = self.cost
        else:
            # Groups tables and paid-by-group badges by cost
            table_count = int(float(self.tables))
            default_price = c.TABLE_PRICES['default_price']
            more_tables = {default_price: 0}
            for i in table_count:
                if c.TABLE_PRICES[i] == default_price:
                    more_tables[default_price] += 1
                else:
                    self.purchased_items['table_' + i] = c.TABLE_PRICES[i]
            if more_tables[default_price]:
                self.purchased_items[
                    more_tables[default_price] + ' extra table(s) at $' +
                    default_price +
                    ' each'] = default_price * more_tables[default_price]

            badges_by_cost = {}
            for attendee in self.attendees:
                if attendee.paid == c.PAID_BY_GROUP:
                    badges_by_cost[attendee.badge_cost] = bool(
                        badges_by_cost.get(attendee.badge_cost)) + 1
            for cost in badges_by_cost:
                self.purchased_items[badges_by_cost[cost] + ' badge(s) at $' +
                                     cost +
                                     ' each'] = cost * badges_by_cost[cost]

    @presave_adjustment
    def assign_creator(self):
        if self.is_new and not self.creator_id:
            self.creator_id = self.session.admin_attendee(
            ).id if self.session.admin_attendee() else None

    @property
    def sorted_attendees(self):
        return list(
            sorted(self.attendees,
                   key=lambda a:
                   (a.is_unassigned, a.id != self.leader_id, a.full_name)))

    @property
    def unassigned(self):
        """
        Returns a list of the unassigned badges for this group, sorted so that
        the paid-by-group badges come last, because when claiming unassigned
        badges we want to claim the "weird" ones first.
        """
        unassigned = [a for a in self.attendees if a.is_unassigned]
        return sorted(unassigned, key=lambda a: a.paid == c.PAID_BY_GROUP)

    @property
    def floating(self):
        """
        Returns the list of paid-by-group unassigned badges for this group.
        This is a separate property from the "Group.unassigned" property
        because when automatically adding or removing unassigned badges, we
        care specifically about paid-by-group badges rather than all unassigned
        badges.
        """
        return [
            a for a in self.attendees
            if a.is_unassigned and a.paid == c.PAID_BY_GROUP
        ]

    @property
    def new_ribbon(self):
        return c.DEALER_RIBBON if self.is_dealer else ''

    @property
    def ribbon_and_or_badge(self):
        badge = self.unassigned[0]
        if badge.ribbon and badge.badge_type != c.ATTENDEE_BADGE:
            return ' / '.join([badge.badge_type_label] + self.ribbon_labels)
        elif badge.ribbon:
            return ' / '.join(badge.ribbon_labels)
        else:
            return badge.badge_type_label

    @hybrid_property
    def is_dealer(self):
        return bool(self.tables and self.tables != '0' and self.tables != '0.0'
                    and (not self.registered or self.amount_paid or self.cost
                         or self.status != c.UNAPPROVED))

    @is_dealer.expression
    def is_dealer(cls):
        return and_(
            cls.tables > 0,
            or_(cls.amount_paid > 0, cls.cost > 0, cls.status != c.UNAPPROVED))

    @hybrid_property
    def is_unpaid(self):
        return self.cost > 0 and self.amount_paid == 0

    @is_unpaid.expression
    def is_unpaid(cls):
        return and_(cls.cost > 0, cls.amount_paid == 0)

    @property
    def email(self):
        if self.studio and self.studio.email:
            return self.studio.email
        elif self.leader and self.leader.email:
            return self.leader.email
        elif self.leader_id:  # unattached groups
            [leader] = [a for a in self.attendees if a.id == self.leader_id]
            return leader.email
        else:
            emails = [a.email for a in self.attendees if a.email]
            if len(emails) == 1:
                return emails[0]

    @hybrid_property
    def badges_purchased(self):
        return len([a for a in self.attendees if a.paid == c.PAID_BY_GROUP])

    @badges_purchased.expression
    def badges_purchased(cls):
        from uber.models import Attendee
        return exists().where(
            and_(Attendee.group_id == cls.id,
                 Attendee.paid == c.PAID_BY_GROUP))

    @property
    def badges(self):
        return len(self.attendees)

    @hybrid_property
    def unregistered_badges(self):
        return len([a for a in self.attendees if a.is_unassigned])

    @unregistered_badges.expression
    def unregistered_badges(cls):
        from uber.models import Attendee
        return exists().where(
            and_(Attendee.group_id == cls.id, Attendee.first_name == ''))

    @cost_property
    def table_cost(self):
        table_count = int(float(self.tables))
        return sum(c.TABLE_PRICES[i] for i in range(1, 1 + table_count))

    @property
    def new_badge_cost(self):
        return c.DEALER_BADGE_PRICE if self.is_dealer else c.get_group_price()

    @cost_property
    def badge_cost(self):
        total = 0
        for attendee in self.attendees:
            if attendee.paid == c.PAID_BY_GROUP:
                total += attendee.badge_cost
        return total

    @property
    def amount_extra(self):
        if self.is_new:
            return sum(a.total_cost - a.badge_cost for a in self.attendees
                       if a.paid == c.PAID_BY_GROUP)
        else:
            return 0

    @property
    def total_cost(self):
        return self.default_cost + self.amount_extra

    @property
    def amount_unpaid(self):
        if self.registered:
            return max(0, ((self.cost * 100) - self.amount_paid) / 100)
        else:
            return self.total_cost

    @hybrid_property
    def amount_paid(self):
        return sum([
            item.amount for item in self.receipt_items
            if item.txn_type == c.PAYMENT
        ])

    @amount_paid.expression
    def amount_paid(cls):
        from uber.models import ReceiptItem

        return select([func.sum(ReceiptItem.amount)]).where(
            and_(ReceiptItem.group_id == cls.id,
                 ReceiptItem.txn_type == c.PAYMENT)).label('amount_paid')

    @hybrid_property
    def amount_refunded(self):
        return sum([
            item.amount for item in self.receipt_items
            if item.txn_type == c.REFUND
        ])

    @amount_refunded.expression
    def amount_refunded(cls):
        from uber.models import ReceiptItem

        return select([func.sum(ReceiptItem.amount)]).where(
            and_(ReceiptItem.group_id == cls.id,
                 ReceiptItem.txn_type == c.REFUND)).label('amount_refunded')

    def balance_by_item_type(self, item_type):
        """
        Return a sum of all the receipt item payments, minus the refunds, for this model by item type
        """
        return sum([amt for type, amt in self.itemized_payments if type == item_type]) \
               - sum([amt for type, amt in self.itemized_refunds if type == item_type])

    @property
    def itemized_payments(self):
        return [(item.item_type, item.amount) for item in self.receipt_items
                if item.txn_type == c.PAYMENT]

    @property
    def itemized_refunds(self):
        return [(item.item_type, item.amount) for item in self.receipt_items
                if item.txn_type == c.REFUND]

    @property
    def dealer_max_badges(self):
        return c.MAX_DEALERS or math.ceil(self.tables) + 1

    @property
    def dealer_badges_remaining(self):
        return self.dealer_max_badges - self.badges

    @property
    def hours_since_registered(self):
        if not self.registered:
            return 0
        delta = datetime.now(UTC) - self.registered
        return max(0, delta.total_seconds()) / 60.0 / 60.0

    @property
    def hours_remaining_in_grace_period(self):
        return max(0,
                   c.GROUP_UPDATE_GRACE_PERIOD - self.hours_since_registered)

    @property
    def is_in_grace_period(self):
        return self.hours_remaining_in_grace_period > 0

    @property
    def min_badges_addable(self):
        if self.is_dealer and not self.dealer_badges_remaining or self.amount_unpaid:
            return 0
        if self.can_add:
            return 1
        elif self.is_dealer:
            return 0
        else:
            return c.MIN_GROUP_ADDITION

    @property
    def requested_hotel_info(self):
        if self.leader:
            return self.leader.requested_hotel_info
        elif self.leader_id:  # unattached groups
            for attendee in self.attendees:
                if attendee.id == self.leader_id:
                    return attendee.requested_hotel_info
        else:
            return any(a.requested_hotel_info for a in self.attendees)

    @property
    def physical_address(self):
        address1 = self.address1.strip()
        address2 = self.address2.strip()
        city = self.city.strip()
        region = self.region.strip()
        zip_code = self.zip_code.strip()
        country = self.country.strip()

        country = '' if country == 'United States' else country.strip()

        if city and region:
            city_region = '{}, {}'.format(city, region)
        else:
            city_region = city or region
        city_region_zip = '{} {}'.format(city_region, zip_code).strip()

        physical_address = [address1, address2, city_region_zip, country]
        return '\n'.join([s for s in physical_address if s])

    @property
    def guidebook_name(self):
        return self.name

    @property
    def guidebook_subtitle(self):
        category_labels = [
            cat for cat in self.categories_labels if 'Other' not in cat
        ] + [self.categories_text]
        return ', '.join(category_labels[:5])

    @property
    def guidebook_desc(self):
        return self.description

    @property
    def guidebook_location(self):
        return ''