class PanelApplicant(SocialMediaMixin, MagModel): app_id = Column(UUID, ForeignKey('panel_application.id', ondelete='cascade')) attendee_id = Column(UUID, ForeignKey('attendee.id', ondelete='cascade'), nullable=True) submitter = Column(Boolean, default=False) first_name = Column(UnicodeText) last_name = Column(UnicodeText) email = Column(UnicodeText) cellphone = Column(UnicodeText) communication_pref = Column(MultiChoice(c.COMMUNICATION_PREF_OPTS)) other_communication_pref = Column(UnicodeText) pronouns = Column(MultiChoice(c.PRONOUN_OPTS)) other_pronouns = Column(UnicodeText) occupation = Column(UnicodeText) website = Column(UnicodeText) other_credentials = Column(UnicodeText) @property def has_credentials(self): return any([self.occupation, self.website, self.other_credentials]) @property def full_name(self): return self.first_name + ' ' + self.last_name
class IndieJudge(MagModel, ReviewMixin): admin_id = Column(UUID, ForeignKey('admin_account.id')) status = Column(Choice(c.MIVS_JUDGE_STATUS_OPTS), default=c.UNCONFIRMED) no_game_submission = Column(Boolean, nullable=True) genres = Column(MultiChoice(c.MIVS_INDIE_JUDGE_GENRE_OPTS)) platforms = Column(MultiChoice(c.MIVS_INDIE_PLATFORM_OPTS)) platforms_text = Column(UnicodeText) staff_notes = Column(UnicodeText) codes = relationship('IndieGameCode', backref='judge') reviews = relationship('IndieGameReview', backref='judge') email_model_name = 'judge' @property def judging_complete(self): return len(self.reviews) == len(self.game_reviews) @property def mivs_all_genres(self): return c.MIVS_ALL_GENRES in self.genres_ints @property def attendee(self): return self.admin_account.attendee @property def full_name(self): return self.attendee.full_name @property def email(self): return self.attendee.email
class MITSApplicant(MagModel): team_id = Column(ForeignKey('mits_team.id')) attendee_id = Column(ForeignKey('attendee.id'), nullable=True) primary_contact = Column(Boolean, default=False) first_name = Column(UnicodeText) last_name = Column(UnicodeText) email = Column(UnicodeText) cellphone = Column(UnicodeText) contact_method = Column(Choice(c.MITS_CONTACT_OPTS), default=c.TEXTING) declined_hotel_space = Column(Boolean, default=False) requested_room_nights = Column(MultiChoice(c.MITS_ROOM_NIGHT_OPTS), default='') email_model_name = 'applicant' @property def email_to_address(self): if self.attendee: return self.attendee.email return self.email @property def full_name(self): return self.first_name + ' ' + self.last_name def has_requested(self, night): return night in self.requested_room_nights_ints
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)
class AttendeeTournament(MagModel): first_name = Column(UnicodeText) last_name = Column(UnicodeText) email = Column(UnicodeText) cellphone = Column(UnicodeText) game = Column(UnicodeText) availability = Column(MultiChoice(c.TOURNAMENT_AVAILABILITY_OPTS)) format = Column(UnicodeText) experience = Column(UnicodeText) needs = Column(UnicodeText) why = Column(UnicodeText) volunteering = Column(Boolean, default=False) status = Column(Choice(c.TOURNAMENT_STATUS_OPTS), default=c.NEW, admin_only=True) email_model_name = 'app' @property def full_name(self): return self.first_name + ' ' + self.last_name @property def matching_attendee(self): return self.session.query(Attendee).filter( Attendee.first_name == self.first_name.title(), Attendee.last_name == self.last_name.title(), func.lower(Attendee.email) == self.email.lower()).first()
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]
class ApiToken(MagModel): admin_account_id = Column(UUID, ForeignKey('admin_account.id')) token = Column(UUID, default=lambda: str(uuid.uuid4()), private=True) access = Column(MultiChoice(c.API_ACCESS_OPTS)) name = Column(UnicodeText) description = Column(UnicodeText) issued_time = Column(UTCDateTime, default=lambda: datetime.now(UTC)) revoked_time = Column(UTCDateTime, default=None, nullable=True)
class FoodRestrictions(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id'), unique=True) standard = Column(MultiChoice(c.FOOD_RESTRICTION_OPTS)) sandwich_pref = Column(MultiChoice(c.SANDWICH_OPTS)) freeform = Column(UnicodeText) def __getattr__(self, name): try: return super(FoodRestrictions, self).__getattr__(name) except AttributeError: restriction = getattr(c, name.upper()) if restriction not in c.FOOD_RESTRICTIONS: return MagModel.__getattr__(self, name) elif restriction == c.VEGAN and c.VEGAN in self.standard_ints: return False elif restriction == c.PORK and c.VEGAN in self.standard_ints: return True else: return restriction in self.standard_ints
class GuestPanel(MagModel): guest_id = Column(UUID, ForeignKey('guest_group.id'), unique=True) wants_panel = Column(Choice(c.GUEST_PANEL_OPTS), nullable=True) name = Column(UnicodeText) length = Column(UnicodeText) desc = Column(UnicodeText) tech_needs = Column(MultiChoice(c.TECH_NEED_OPTS)) other_tech_needs = Column(UnicodeText) @property def status(self): return self.wants_panel_label
class HotelRequests(MagModel, NightsMixin): attendee_id = Column(UUID, ForeignKey('attendee.id'), unique=True) nights = Column(MultiChoice(c.NIGHT_OPTS)) wanted_roommates = Column(UnicodeText) unwanted_roommates = Column(UnicodeText) special_needs = Column(UnicodeText) approved = Column(Boolean, default=False, admin_only=True) def decline(self): nights = [n for n in self.nights.split(',') if int(n) in c.CORE_NIGHTS] self.nights = ','.join(nights) @presave_adjustment def cascading_save(self): self.attendee.presave_adjustments() def __repr__(self): return '<{self.attendee.full_name} Hotel Requests>'.format(self=self)
class AdminAccount(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id'), unique=True) hashed = Column(UnicodeText) access = Column(MultiChoice(c.ACCESS_OPTS)) password_reset = relationship('PasswordReset', backref='admin_account', uselist=False) def __repr__(self): return '<{}>'.format(self.attendee.full_name) @staticmethod def is_nick(): return AdminAccount.admin_name() in c.JERKS @staticmethod def admin_name(): try: from uber.models import Session with Session() as session: return session.admin_attendee().full_name except: return None @staticmethod def admin_email(): try: from uber.models import Session with Session() as session: return session.admin_attendee().email except: return None @staticmethod def access_set(id=None): try: from uber.models import Session with Session() as session: id = id or cherrypy.session['account_id'] return set(session.admin_account(id).access_ints) except: return set()
class ApiToken(MagModel): admin_account_id = Column(UUID, ForeignKey('admin_account.id')) token = Column(UUID, default=lambda: str(uuid.uuid4()), private=True) access = Column(MultiChoice(c.API_ACCESS_OPTS)) name = Column(UnicodeText) description = Column(UnicodeText) issued_time = Column(UTCDateTime, default=lambda: datetime.now(UTC)) revoked_time = Column(UTCDateTime, default=None, nullable=True) @property def api_read(self): return c.API_READ in self.access_ints @property def api_update(self): return c.API_UPDATE in self.access_ints @property def api_create(self): return c.API_CREATE in self.access_ints @property def api_delete(self): return c.API_DELETE in self.access_ints
class MITSTimes(MagModel): team_id = Column(ForeignKey('mits_team.id')) showcase_availability = Column(MultiChoice(c.MITS_SHOWCASE_SCHEDULE_OPTS)) availability = Column(MultiChoice(c.MITS_SCHEDULE_OPTS))
class AdminAccount(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id'), unique=True) hashed = Column(UnicodeText, private=True) access = Column(MultiChoice(c.ACCESS_OPTS)) password_reset = relationship('PasswordReset', backref='admin_account', uselist=False) api_tokens = relationship('ApiToken', backref='admin_account') active_api_tokens = relationship( 'ApiToken', primaryjoin='and_(' 'AdminAccount.id == ApiToken.admin_account_id, ' 'ApiToken.revoked_time == None)') judge = relationship('IndieJudge', uselist=False, backref='admin_account') def __repr__(self): return '<{}>'.format(self.attendee.full_name) @staticmethod def admin_name(): try: from uber.models import Session with Session() as session: return session.admin_attendee().full_name except Exception: return None @staticmethod def admin_email(): try: from uber.models import Session with Session() as session: return session.admin_attendee().email except Exception: return None @staticmethod def access_set(id=None): try: from uber.models import Session with Session() as session: id = id or cherrypy.session['account_id'] return set(session.admin_account(id).access_ints) except Exception: return set() def _allowed_opts(self, opts, required_access): access_opts = [] admin_access = set(self.access_ints) for access, label in opts: required = set(required_access.get(access, [])) if not required or any(a in required for a in admin_access): access_opts.append((access, label)) return access_opts @property def allowed_access_opts(self): return self._allowed_opts(c.ACCESS_OPTS, c.REQUIRED_ACCESS) @property def allowed_api_access_opts(self): required_access = {a: [a] for a in c.API_ACCESS.keys()} return self._allowed_opts(c.API_ACCESS_OPTS, required_access) @property def is_admin(self): return c.ADMIN in self.access_ints @presave_adjustment def _disable_api_access(self): new_access = set(int(s) for s in self.access.split(',') if s) old_access = set( int(s) for s in self.orig_value_of('access').split(',') if s) removed = old_access.difference(new_access) removed_api = set(a for a in c.API_ACCESS.keys() if a in removed) if removed_api: revoked_time = datetime.utcnow() for api_token in self.active_api_tokens: if removed_api.intersection(api_token.access_ints): api_token.revoked_time = revoked_time
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
class MITSTimes(MagModel): team_id = Column(ForeignKey('mits_team.id')) availability = Column(MultiChoice(c.MITS_SCHEDULE_OPTS)) multiple_tables = Column(MultiChoice(c.MITS_SCHEDULE_OPTS))
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
class GuestTravelPlans(MagModel): guest_id = Column(UUID, ForeignKey('guest_group.id'), unique=True) modes = Column(MultiChoice(c.GUEST_TRAVEL_OPTS)) modes_text = Column(UnicodeText) details = Column(UnicodeText)
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 ''
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)))
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])
class GuestMerch: extra_merch_time = Column(MultiChoice(c.EXTRA_MERCH_TIME_OPTS))