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 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 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)
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)
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 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
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 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)
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 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
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_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 ''