class ReceiptItem(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id', ondelete='SET NULL'), nullable=True) attendee = relationship(Attendee, backref='receipt_items', foreign_keys=attendee_id, cascade='save-update,merge,refresh-expire,expunge') group_id = Column(UUID, ForeignKey('group.id', ondelete='SET NULL'), nullable=True) group = relationship(Group, backref='receipt_items', foreign_keys=group_id, cascade='save-update,merge,refresh-expire,expunge') txn_id = Column(UUID, ForeignKey('stripe_transaction.id', ondelete='SET NULL'), nullable=True) stripe_transaction = relationship( StripeTransaction, backref='receipt_items', foreign_keys=txn_id, cascade='save-update,merge,refresh-expire,expunge') txn_type = Column(Choice(c.TRANSACTION_TYPE_OPTS), default=c.PAYMENT) payment_method = Column(Choice(c.PAYMENT_METHOD_OPTS), default=c.STRIPE) amount = Column(Integer) when = Column(UTCDateTime, default=lambda: datetime.now(UTC)) who = Column(UnicodeText) desc = Column(UnicodeText) cost_snapshot = Column(JSON, default={}, server_default='{}') refund_snapshot = Column(JSON, default={}, server_default='{}')
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 Shift(MagModel): job_id = Column(UUID, ForeignKey('job.id', ondelete='cascade')) attendee_id = Column(UUID, ForeignKey('attendee.id', ondelete='cascade')) worked = Column(Choice(c.WORKED_STATUS_OPTS), default=c.SHIFT_UNMARKED) rating = Column(Choice(c.RATING_OPTS), default=c.UNRATED) comment = Column(UnicodeText) @property def name(self): return "{}'s {!r} shift".format(self.attendee.full_name, self.job.name)
class IndieGameReview(MagModel): game_id = Column(UUID, ForeignKey('indie_game.id')) judge_id = Column(UUID, ForeignKey('indie_judge.id')) video_status = Column(Choice(c.MIVS_VIDEO_REVIEW_STATUS_OPTS), default=c.PENDING) game_status = Column(Choice(c.MIVS_GAME_REVIEW_STATUS_OPTS), default=c.PENDING) game_content_bad = Column(Boolean, default=False) video_score = Column(Choice(c.MIVS_VIDEO_REVIEW_OPTS), default=c.PENDING) video_review = Column(UnicodeText) # 0 = not reviewed, 1-10 score (10 is best) readiness_score = Column(Integer, default=0) design_score = Column(Integer, default=0) enjoyment_score = Column(Integer, default=0) game_review = Column(UnicodeText) developer_response = Column(UnicodeText) staff_notes = Column(UnicodeText) send_to_studio = Column(Boolean, default=False) __table_args__ = (UniqueConstraint('game_id', 'judge_id', name='review_game_judge_uniq'), ) @presave_adjustment def no_score_if_broken(self): if self.has_video_issues: self.video_score = c.PENDING @property def game_score(self): if self.has_game_issues or not (self.readiness_score and self.design_score and self.enjoyment_score): return 0 return sum([ self.readiness_score, self.design_score, self.enjoyment_score ]) / float(3) @property def has_video_issues(self): return self.video_status in c.MIVS_PROBLEM_STATUSES @property def has_game_issues(self): if self.game_status != c.COULD_NOT_PLAY: return self.game_status in c.MIVS_PROBLEM_STATUSES @property def has_issues(self): return self.has_video_issues or self.has_game_issues
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 Event(MagModel): location = Column(Choice(c.EVENT_LOCATION_OPTS)) start_time = Column(UTCDateTime) duration = Column(Integer) # half-hour increments name = Column(UnicodeText, nullable=False) description = Column(UnicodeText) assigned_panelists = relationship('AssignedPanelist', backref='event') applications = relationship('PanelApplication', backref='event') panel_feedback = relationship('EventFeedback', backref='event') tournaments = relationship('TabletopTournament', backref='event', uselist=False) guest = relationship('GuestGroup', backref='event') @property def half_hours(self): half_hours = set() for i in range(self.duration): half_hours.add(self.start_time + timedelta(minutes=30 * i)) return half_hours @property def minutes(self): return (self.duration or 0) * 30 @property def start_slot(self): if self.start_time: start_delta = self.start_time_local - c.EPOCH return int(start_delta.total_seconds() / (60 * 30)) @property def end_time(self): return self.start_time + timedelta(minutes=self.minutes)
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 EventFeedback(MagModel): event_id = Column(UUID, ForeignKey('event.id')) attendee_id = Column(UUID, ForeignKey('attendee.id', ondelete='cascade')) headcount_starting = Column(Integer, default=0) headcount_during = Column(Integer, default=0) comments = Column(UnicodeText) rating = Column(Choice(c.PANEL_RATING_OPTS), default=c.UNRATED)
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 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 Sale(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id', ondelete='set null'), nullable=True) what = Column(UnicodeText) cash = Column(Integer, default=0) mpoints = Column(Integer, default=0) when = Column(UTCDateTime, default=lambda: datetime.now(UTC)) reg_station = Column(Integer, nullable=True) payment_method = Column(Choice(c.SALE_OPTS), default=c.MERCH)
class StripeTransaction(MagModel): stripe_id = Column(UnicodeText, nullable=True) type = Column(Choice(c.TRANSACTION_TYPE_OPTS), default=c.PENDING) amount = Column(Integer) when = Column(UTCDateTime, default=lambda: datetime.now(UTC)) who = Column(UnicodeText) desc = Column(UnicodeText) attendees = relationship('StripeTransactionAttendee', backref='stripe_transaction') groups = relationship('StripeTransactionGroup', backref='stripe_transaction')
class StripeTransaction(MagModel): stripe_id = Column(UnicodeText, nullable=True) type = Column(Choice(c.TRANSACTION_TYPE_OPTS), default=c.PAYMENT) amount = Column(Integer) when = Column(UTCDateTime, default=lambda: datetime.now(UTC)) who = Column(UnicodeText) desc = Column(UnicodeText) fk_id = Column(UUID) fk_model = Column(UnicodeText)
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 GuestCharity(MagModel): guest_id = Column(UUID, ForeignKey('guest_group.id'), unique=True) donating = Column(Choice(c.GUEST_CHARITY_OPTS), nullable=True) desc = Column(UnicodeText) @property def status(self): return self.donating_label @presave_adjustment def no_desc_if_not_donating(self): if self.donating == c.NOT_DONATING: self.desc = ''
class MITSGame(MagModel): team_id = Column(ForeignKey('mits_team.id')) name = Column(UnicodeText) promo_blurb = Column(UnicodeText) description = Column(UnicodeText) genre = Column(UnicodeText) phase = Column(Choice(c.MITS_PHASE_OPTS)) min_age = Column(Integer) min_players = Column(Integer, default=2) max_players = Column(Integer, default=4) personally_own = Column(Boolean, default=False) unlicensed = Column(Boolean, default=False) professional = Column(Boolean, default=False)
class ArtShowPayment(MagModel): receipt_id = Column(UUID, ForeignKey('art_show_receipt.id', ondelete='SET NULL'), nullable=True) receipt = relationship('ArtShowReceipt', foreign_keys=receipt_id, cascade='save-update, merge', backref=backref('art_show_payments', cascade='save-update, merge')) amount = Column(Integer, default=0) type = Column(Choice(c.ART_SHOW_PAYMENT_OPTS), default=c.STRIPE, admin_only=True) when = Column(UTCDateTime, default=lambda: datetime.now(UTC))
class AttractionNotification(MagModel): attraction_event_id = Column(UUID, ForeignKey('attraction_event.id')) attraction_id = Column(UUID, ForeignKey('attraction.id')) attendee_id = Column(UUID, ForeignKey('attendee.id')) notification_type = Column(Choice(Attendee._NOTIFICATION_PREF_OPTS)) ident = Column(UnicodeText, index=True) sid = Column(UnicodeText) sent_time = Column(UTCDateTime, default=lambda: datetime.now(pytz.UTC)) subject = Column(UnicodeText) body = Column(UnicodeText) @presave_adjustment def _fix_attraction_id(self): if not self.attraction_id and self.event: self.attraction_id = self.event.attraction_id
class AttractionNotificationReply(MagModel): attraction_event_id = Column(UUID, ForeignKey('attraction_event.id'), nullable=True) attraction_id = Column(UUID, ForeignKey('attraction.id'), nullable=True) attendee_id = Column(UUID, ForeignKey('attendee.id'), nullable=True) notification_type = Column(Choice(Attendee._NOTIFICATION_PREF_OPTS)) from_phonenumber = Column(UnicodeText) to_phonenumber = Column(UnicodeText) sid = Column(UnicodeText, index=True) received_time = Column(UTCDateTime, default=lambda: datetime.now(pytz.UTC)) sent_time = Column(UTCDateTime, default=lambda: datetime.now(pytz.UTC)) body = Column(UnicodeText) @presave_adjustment def _fix_attraction_id(self): if not self.attraction_id and self.event: self.attraction_id = self.event.attraction_id
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 PromoCodeWord(MagModel): """ Words used to generate promo codes. Attributes: word (str): The text of this promo code word. normalized_word (str): A normalized version of `word`, suitable for database queries. part_of_speech (int): The part of speech that `word` is. Valid values are: * 0 `_ADJECTIVE`: `word` is an adjective * 1 `_NOUN`: `word` is a noun * 2 `_VERB`: `word` is a verb * 3 `_ADVERB`: `word` is an adverb part_of_speech_str (str): A human readable description of `part_of_speech`. """ _ADJECTIVE = 0 _NOUN = 1 _VERB = 2 _ADVERB = 3 _PART_OF_SPEECH_OPTS = [(_ADJECTIVE, 'adjective'), (_NOUN, 'noun'), (_VERB, 'verb'), (_ADVERB, 'adverb')] _PARTS_OF_SPEECH = dict(_PART_OF_SPEECH_OPTS) word = Column(UnicodeText) part_of_speech = Column(Choice(_PART_OF_SPEECH_OPTS), default=_ADJECTIVE) __table_args__ = (Index( 'uq_promo_code_word_normalized_word_part_of_speech', func.lower(func.trim(word)), part_of_speech, unique=True), CheckConstraint( func.trim(word) != '', name='ck_promo_code_word_non_empty_word')) _repr_attr_names = ('word', ) @hybrid_property def normalized_word(self): return self.normalize_word(self.word) @normalized_word.expression def normalized_word(cls): return func.lower(func.trim(cls.word)) @property def part_of_speech_str(self): return self._PARTS_OF_SPEECH[self.part_of_speech].title() @presave_adjustment def _attribute_adjustments(self): # Replace multiple whitespace characters with a single space self.word = re.sub(r'\s+', ' ', self.word.strip()) @classmethod def group_by_parts_of_speech(cls, words): """ Groups a list of words by their part_of_speech. Arguments: words (list): List of `PromoCodeWord`. Returns: OrderedDict: A dictionary of words mapped to their part of speech, like this:: OrderedDict([ (0, ['adjective1', 'adjective2']), (1, ['noun1', 'noun2']), (2, ['verb1', 'verb2']), (3, ['adverb1', 'adverb2']) ]) """ parts_of_speech = OrderedDict([ (i, []) for (i, _) in PromoCodeWord._PART_OF_SPEECH_OPTS ]) for word in words: parts_of_speech[word.part_of_speech].append(word.word) return parts_of_speech @classmethod def normalize_word(cls, word): """ Normalizes a word. Arguments: word (str): A word as typed by an admin. Returns: str: A copy of `word` converted to all lowercase, and multiple whitespace characters replaced by a single space. """ return re.sub(r'\s+', ' ', word.strip().lower())
class PromoCode(MagModel): """ Promo codes used by attendees to purchase badges at discounted prices. Attributes: code (str): The actual textual representation of the promo code. This is what the attendee would have to type in during registration to receive a discount. `code` may not be an empty string or a string consisting entirely of whitespace. discount (int): The discount amount that should be applied to the purchase price of a badge. The interpretation of this value depends on the value of `discount_type`. In any case, a value of 0 equates to a full discount, i.e. a free badge. discount_str (str): A human readable description of the discount. discount_type (int): The type of discount this promo code will apply. Valid values are: * 0 `_FIXED_DISCOUNT`: `discount` is interpreted as a fixed dollar amount by which the badge price should be reduced. If `discount` is 49 and the badge price is normally $100, then the discounted badge price would be $51. * 1 `_FIXED_PRICE`: `discount` is interpreted as the actual badge price. If `discount` is 49, then the discounted badge price would be $49. * 2 `_PERCENT_DISCOUNT`: `discount` is interpreted as a percentage by which the badge price should be reduced. If `discount` is 20 and the badge price is normally $50, then the discounted badge price would $40 ($50 reduced by 20%). If `discount` is 100, then the price would be 100% off, i.e. a free badge. group (relationship): An optional relationship to a PromoCodeGroup object, which groups sets of promo codes to make attendee-facing "groups" cost (int): The cost of this promo code if and when it was bought as part of a PromoCodeGroup. expiration_date (datetime): The date & time upon which this promo code expires. An expired promo code may no longer be used to receive discounted badges. is_free (bool): True if this promo code will always cause a badge to be free. False if this promo code may not cause a badge to be free. Note: It's possible for this value to be False for a promo code that still reduces a badge's price to zero. If there are some other discounts that also reduce a badge price (like an age discount) then the price may be pushed down to zero. is_expired (bool): True if this promo code is expired, False otherwise. is_unlimited (bool): True if this promo code may be used an unlimited number of times, False otherwise. is_valid (bool): True if this promo code is still valid and may be used again, False otherwise. normalized_code (str): A normalized version of `code` suitable for database queries. Normalization converts `code` to all lowercase and removes dashes ("-"). used_by (list): List of attendees that have used this promo code. Note: This property is declared as a backref in the Attendee class. uses_allowed (int): The total number of times this promo code may be used. A value of None means this promo code may be used an unlimited number of times. uses_allowed_str (str): A human readable description of uses_allowed. uses_count (int): The number of times this promo code has already been used. uses_count_str (str): A human readable description of uses_count. uses_remaining (int): Remaining number of times this promo code may be used. uses_remaining_str (str): A human readable description of uses_remaining. """ _FIXED_DISCOUNT = 0 _FIXED_PRICE = 1 _PERCENT_DISCOUNT = 2 _DISCOUNT_TYPE_OPTS = [(_FIXED_DISCOUNT, 'Fixed Discount'), (_FIXED_PRICE, 'Fixed Price'), (_PERCENT_DISCOUNT, 'Percent Discount')] _AMBIGUOUS_CHARS = { '0': 'OQD', '1': 'IL', '2': 'Z', '5': 'S', '6': 'G', '8': 'B' } _UNAMBIGUOUS_CHARS = string.digits + string.ascii_uppercase for _, s in _AMBIGUOUS_CHARS.items(): _UNAMBIGUOUS_CHARS = re.sub('[{}]'.format(s), '', _UNAMBIGUOUS_CHARS) code = Column(UnicodeText) discount = Column(Integer, nullable=True, default=None) discount_type = Column(Choice(_DISCOUNT_TYPE_OPTS), default=_FIXED_DISCOUNT) expiration_date = Column(UTCDateTime, default=c.ESCHATON) uses_allowed = Column(Integer, nullable=True, default=None) cost = Column(Integer, nullable=True, default=None) group_id = Column(UUID, ForeignKey('promo_code_group.id', ondelete='SET NULL'), nullable=True) group = relationship(PromoCodeGroup, backref='promo_codes', foreign_keys=group_id, cascade='save-update,merge,refresh-expire,expunge') __table_args__ = (Index('uq_promo_code_normalized_code', func.replace( func.replace(func.lower(code), '-', ''), ' ', ''), unique=True), CheckConstraint(func.trim(code) != '', name='ck_promo_code_non_empty_code')) _repr_attr_names = ('code', ) @classmethod def normalize_expiration_date(cls, dt): """ Converts the given datetime to 11:59pm local in the event timezone. """ if isinstance(dt, six.string_types): if dt.strip(): dt = dateparser.parse(dt) else: dt = c.ESCHATON if dt.tzinfo: dt = dt.astimezone(c.EVENT_TIMEZONE) return c.EVENT_TIMEZONE.localize( dt.replace(hour=23, minute=59, second=59, tzinfo=None)) @property def discount_str(self): if self.discount_type == self._FIXED_DISCOUNT and self.discount == 0: # This is done to account for Art Show Agent codes, which use the PromoCode class return 'No discount' elif not self.discount: return 'Free badge' if self.discount_type == self._FIXED_DISCOUNT: return '${} discount'.format(self.discount) elif self.discount_type == self._FIXED_PRICE: return '${} badge'.format(self.discount) else: return '%{} discount'.format(self.discount) @hybrid_property def is_expired(self): return self.expiration_date < localized_now() @is_expired.expression def is_expired(cls): return cls.expiration_date < localized_now() @property def is_free(self): return not self.discount or ( self.discount_type == self._PERCENT_DISCOUNT and self.discount >= 100) or (self.discount_type == self._FIXED_DISCOUNT and self.discount >= c.BADGE_PRICE) @hybrid_property def is_unlimited(self): return not self.uses_allowed @is_unlimited.expression def is_unlimited(cls): return cls.uses_allowed == None # noqa: E711 @hybrid_property def is_valid(self): return not self.is_expired and (self.is_unlimited or self.uses_remaining > 0) @is_valid.expression def is_valid(cls): return (cls.expiration_date >= localized_now()) \ & ((cls.uses_allowed == None) | (cls.uses_remaining > 0)) # noqa: E711 @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 uses_allowed_str(self): uses = self.uses_allowed return 'Unlimited uses' if uses is None else '{} use{} allowed'.format( uses, '' if uses == 1 else 's') @hybrid_property def uses_count(self): return len(self.used_by) @uses_count.expression def uses_count(cls): from uber.models.attendee import Attendee return select([ func.count(Attendee.id) ]).where(Attendee.promo_code_id == cls.id).label('uses_count') @property def uses_count_str(self): uses = self.uses_count return 'Used by {} attendee{}'.format(uses, '' if uses == 1 else 's') @hybrid_property def uses_remaining(self): return None if self.is_unlimited else self.uses_allowed - self.uses_count @uses_remaining.expression def uses_remaining(cls): return cls.uses_allowed - cls.uses_count @property def uses_remaining_str(self): uses = self.uses_remaining return 'Unlimited uses' if uses is None else '{} use{} remaining'.format( uses, '' if uses == 1 else 's') @presave_adjustment def _attribute_adjustments(self): # If 'uses_allowed' is empty, then this is an unlimited use code if not self.uses_allowed: self.uses_allowed = None # If 'discount' is empty, then this is a full discount, free badge if self.discount == '': self.discount = None self.code = self.code.strip() if self.code else '' if not self.code: # If 'code' is empty, then generate a random code self.code = self.generate_random_code() else: # Replace multiple whitespace characters with a single space self.code = re.sub(r'\s+', ' ', self.code) # Always make expiration_date 11:59pm of the given date self.expiration_date = self.normalize_expiration_date( self.expiration_date) def calculate_discounted_price(self, price): """ Returns the discounted price based on the promo code's `discount_type`. Args: price (int): The badge price in whole dollars. Returns: int: The discounted price. The returned number will never be less than zero or greater than `price`. If `price` is None or a negative number, then the return value will always be 0. """ if not self.discount or not price or price < 0: return 0 discounted_price = price if self.discount_type == self._FIXED_DISCOUNT: discounted_price = price - self.discount elif self.discount_type == self._FIXED_PRICE: discounted_price = self.discount elif self.discount_type == self._PERCENT_DISCOUNT: discounted_price = int(price * ((100.0 - self.discount) / 100.0)) return min(max(discounted_price, 0), price) @classmethod def _generate_code(cls, generator, count=None): """ Helper method to limit collisions for the other generate() methods. Arguments: generator (callable): Function that returns a newly generated code. count (int): The number of codes to generate. If `count` is `None`, then a single code will be generated. Defaults to `None`. Returns: If an `int` value was passed for `count`, then a `list` of newly generated codes is returned. If `count` is `None`, then a single `str` is returned. """ from uber.models import Session with Session() as session: # Kind of inefficient, but doing one big query for all the existing # codes will be faster than a separate query for each new code. old_codes = set(s for (s, ) in session.query(cls.code).all()) # Set an upper limit on the number of collisions we'll allow, # otherwise this loop could potentially run forever. max_collisions = 100 collisions = 0 codes = set() while len(codes) < (1 if count is None else count): code = generator().strip() if not code: break if code in codes or code in old_codes: collisions += 1 if collisions >= max_collisions: break else: codes.add(code) return (codes.pop() if codes else None) if count is None else codes @classmethod def generate_random_code(cls, count=None, length=9, segment_length=3): """ Generates a random promo code. With `length` = 12 and `segment_length` = 3:: XXX-XXX-XXX-XXX With `length` = 6 and `segment_length` = 2:: XX-XX-XX Arguments: count (int): The number of codes to generate. If `count` is `None`, then a single code will be generated. Defaults to `None`. length (int): The number of characters to use for the code. segment_length (int): The length of each segment within the code. Returns: If an `int` value was passed for `count`, then a `list` of newly generated codes is returned. If `count` is `None`, then a single `str` is returned. """ # The actual generator function, called repeatedly by `_generate_code` def _generate_random_code(): letters = ''.join( random.choice(cls._UNAMBIGUOUS_CHARS) for _ in range(length)) return '-'.join(textwrap.wrap(letters, segment_length)) return cls._generate_code(_generate_random_code, count=count) @classmethod def generate_word_code(cls, count=None): """ Generates a promo code consisting of words from `PromoCodeWord`. Arguments: count (int): The number of codes to generate. If `count` is `None`, then a single code will be generated. Defaults to `None`. Returns: If an `int` value was passed for `count`, then a `list` of newly generated codes is returned. If `count` is `None`, then a single `str` is returned. """ from uber.models import Session with Session() as session: words = PromoCodeWord.group_by_parts_of_speech( session.query(PromoCodeWord).order_by( PromoCodeWord.normalized_word).all()) # The actual generator function, called repeatedly by `_generate_code` def _generate_word_code(): code_words = [] for part_of_speech, _ in PromoCodeWord._PART_OF_SPEECH_OPTS: if words[part_of_speech]: code_words.append(random.choice(words[part_of_speech])) return ' '.join(code_words) return cls._generate_code(_generate_word_code, count=count) @classmethod def disambiguate_code(cls, code): """ Removes ambiguous characters in a promo code supplied by an attendee. Arguments: code (str): A promo code as typed by an attendee. Returns: str: A copy of `code` with all ambiguous characters replaced by their unambiguous equivalent. """ code = cls.normalize_code(code) if not code: return '' for unambiguous, ambiguous in cls._AMBIGUOUS_CHARS.items(): ambiguous_pattern = '[{}]'.format(ambiguous.lower()) code = re.sub(ambiguous_pattern, unambiguous.lower(), code) return code @classmethod def normalize_code(cls, code): """ Normalizes a promo code supplied by an attendee. Arguments: code (str): A promo code as typed by an attendee. Returns: str: A copy of `code` converted to all lowercase, with dashes ("-") and whitespace characters removed. """ if not code: return '' return re.sub(r'[\s\-]+', '', code.lower())
class Attendee: comped_reason = Column(UnicodeText, default='', admin_only=True) fursuiting = Column(Choice(c.FURSUITING_OPTS), nullable=True) @presave_adjustment def save_group_cost(self): if self.group and self.group.auto_recalc: self.group.cost = self.group.default_cost @presave_adjustment def never_spam(self): self.can_spam = False @presave_adjustment def not_attending_need_not_pay(self): if self.badge_status == c.NOT_ATTENDING: self.paid = c.NEED_NOT_PAY self.comped_reason = "Automated: Not Attending badge status." @presave_adjustment def staffing_badge_and_ribbon_adjustments(self): if self.badge_type == c.STAFF_BADGE or c.STAFF_RIBBON in self.ribbon_ints: self.ribbon = remove_opt(self.ribbon_ints, c.VOLUNTEER_RIBBON) elif self.staffing and self.badge_type != c.STAFF_BADGE \ and c.STAFF_RIBBON not in self.ribbon_ints 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 or c.STAFF_RIBBON in self.ribbon_ints: 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 @cost_property def badge_cost(self): registered = self.registered_local if self.registered else None if self.paid == c.NEED_NOT_PAY \ and self.badge_type not in [c.SPONSOR_BADGE, c.SHINY_BADGE]: return 0 elif self.paid == c.NEED_NOT_PAY: return c.BADGE_TYPE_PRICES[self.badge_type] \ - c.get_attendee_price(registered) elif self.overridden_price is not None: return self.overridden_price elif self.badge_type == c.ONE_DAY_BADGE: return c.get_oneday_price(registered) elif self.is_presold_oneday: return max(0, c.get_presold_oneday_price(self.badge_type) + self.age_discount) if 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) else: return c.get_attendee_price(registered) @property def age_discount(self): if 'val' in self.age_group_conf \ and self.age_group_conf['val'] == c.UNDER_13 \ and c.AT_THE_CON: if self.badge_type == c.ATTENDEE_BADGE: discount = 33 elif self.badge_type in [c.FRIDAY, c.SUNDAY]: discount = 13 elif self.badge_type == c.SATURDAY: discount = 20 if not self.age_group_conf['discount'] \ or self.age_group_conf['discount'] < discount: return -discount return -self.age_group_conf['discount'] @property def paid_for_a_shirt(self): return self.badge_type in [c.SPONSOR_BADGE, c.SHINY_BADGE] @property def staffing_or_will_be(self): return self.staffing or self.badge_type == c.STAFF_BADGE \ or c.VOLUNTEER_RIBBON in self.ribbon_ints or c.STAFF_RIBBON in self.ribbon_ints @property def merch_items(self): """ Here is the business logic surrounding shirts: - People who kick in enough to get a shirt get an event shirt. - People with staff badges get a configurable number of staff shirts. - Volunteers who meet the requirements get a complementary event shirt (NOT a staff shirt). If the c.SEPARATE_STAFF_SWAG setting is true, then this excludes staff merch; see the staff_merch property. This property returns a list containing strings and sub-lists of each donation tier with multiple sub-items, e.g. [ 'tshirt', 'Supporter Pack', [ 'Swag Bag', 'Badge Holder' ], 'Season Pass Certificate' ] """ merch = [] for amount, desc in sorted(c.DONATION_TIERS.items()): if amount and self.amount_extra >= amount: merch.append(desc) items = c.DONATION_TIER_ITEMS.get(amount, []) if len(items) == 1: merch[-1] = items[0] elif len(items) > 1: merch.append(items) if self.num_event_shirts_owed == 1 and not self.paid_for_a_shirt: merch.append('A T-shirt') elif self.num_event_shirts_owed > 1: merch.append('A 2nd T-Shirt') if merch and self.volunteer_event_shirt_eligible and not self.volunteer_event_shirt_earned: merch[-1] += ( ' (this volunteer must work at least {} hours or they will be reported for picking up their shirt)' .format(c.HOURS_FOR_SHIRT)) if self.badge_type == c.SPONSOR_BADGE: merch.append('Sponsor merch') if self.badge_type == c.SHINY_BADGE: merch.append('Shiny Sponsor merch') if not c.SEPARATE_STAFF_MERCH: merch.extend(self.staff_merch_items) if self.extra_merch: merch.append(self.extra_merch) return merch
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 AttractionEvent(MagModel): attraction_feature_id = Column(UUID, ForeignKey('attraction_feature.id')) attraction_id = Column(UUID, ForeignKey('attraction.id'), index=True) location = Column(Choice(c.EVENT_LOCATION_OPTS)) start_time = Column(UTCDateTime, default=c.EPOCH) duration = Column(Integer, default=900) # In seconds slots = Column(Integer, default=1) signups_open = Column(Boolean, default=True) signups = relationship('AttractionSignup', backref='event', order_by='AttractionSignup.checkin_time') attendee_signups = association_proxy('signups', 'attendee') notifications = relationship('AttractionNotification', backref='event', order_by='AttractionNotification.sent_time') notification_replies = relationship( 'AttractionNotificationReply', backref='event', order_by='AttractionNotificationReply.sid') attendees = relationship( 'Attendee', backref='attraction_events', cascade='save-update,merge,refresh-expire,expunge', secondary='attraction_signup', order_by='attraction_signup.c.signup_time') @presave_adjustment def _fix_attraction_id(self): if not self.attraction_id and self.feature: self.attraction_id = self.feature.attraction_id @classmethod def get_ident(cls, id, advance_notice): if advance_notice == -1: return str(id) return '{}_{}'.format(id, advance_notice) @hybrid_property def end_time(self): return self.start_time + timedelta(seconds=self.duration) @end_time.expression def end_time(cls): return cls.start_time + (cls.duration * text("interval '1 second'")) @property def start_day_local(self): return self.start_time_local.strftime('%A') @property def start_time_label(self): if self.start_time: return self.start_time_local.strftime('%-I:%M %p %A') return 'unknown start time' @property def checkin_start_time(self): advance_checkin = self.attraction.advance_checkin if advance_checkin < 0: return self.start_time else: return self.start_time - timedelta(seconds=advance_checkin) @property def checkin_end_time(self): advance_checkin = self.attraction.advance_checkin if advance_checkin < 0: return self.end_time else: return self.start_time @property def checkin_start_time_label(self): checkin = self.checkin_start_time_local today = datetime.now(c.EVENT_TIMEZONE).date() if checkin.date() == today: return checkin.strftime('%-I:%M %p') return checkin.strftime('%-I:%M %p %a') @property def checkin_end_time_label(self): checkin = self.checkin_end_time_local today = datetime.now(c.EVENT_TIMEZONE).date() if checkin.date() == today: return checkin.strftime('%-I:%M %p') return checkin.strftime('%-I:%M %p %a') @property def time_remaining_to_checkin(self): return self.checkin_start_time - datetime.now(pytz.UTC) @property def time_remaining_to_checkin_label(self): return humanize_timedelta(self.time_remaining_to_checkin, granularity='minutes', separator=' ') @property def is_checkin_over(self): return self.checkin_end_time < datetime.now(pytz.UTC) @property def is_sold_out(self): return self.slots <= len(self.attendees) @property def is_started(self): return self.start_time < datetime.now(pytz.UTC) @property def remaining_slots(self): return max(self.slots - len(self.attendees), 0) @property def time_span_label(self): if self.start_time: end_time = self.end_time.astimezone(c.EVENT_TIMEZONE) start_time = self.start_time.astimezone(c.EVENT_TIMEZONE) if start_time.date() == end_time.date(): return '{} – {}'.format(start_time.strftime('%-I:%M %p'), end_time.strftime('%-I:%M %p %A')) return '{} – {}'.format(start_time.strftime('%-I:%M %p %A'), end_time.strftime('%-I:%M %p %A')) return 'unknown time span' @property def duration_label(self): if self.duration: return humanize_timedelta(seconds=self.duration, separator=' ') return 'unknown duration' @property def location_event_name(self): return location_event_name(self.location) @property def location_room_name(self): return location_room_name(self.location) @property def name(self): return self.feature.name @property def label(self): return '{} at {}'.format(self.name, self.start_time_label) def overlap(self, event): if not event: return 0 latest_start = max(self.start_time, event.start_time) earliest_end = min(self.end_time, event.end_time) if earliest_end < latest_start: return -int((latest_start - earliest_end).total_seconds()) elif self.start_time < event.start_time and self.end_time > event.end_time: return int((self.end_time - event.start_time).total_seconds()) elif self.start_time > event.start_time and self.end_time < event.end_time: return int((event.end_time - self.start_time).total_seconds()) else: return int((earliest_end - latest_start).total_seconds())
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 GuestMerch(MagModel): _inventory_file_regex = re.compile(r'^(audio|image)(|\-\d+)$') _inventory_filename_regex = re.compile(r'^(audio|image)(|\-\d+)_filename$') guest_id = Column(UUID, ForeignKey('guest_group.id'), unique=True) selling_merch = Column(Choice(c.GUEST_MERCH_OPTS), nullable=True) inventory = Column(JSON, default={}, server_default='{}') bringing_boxes = Column(UnicodeText) extra_info = Column(UnicodeText) tax_phone = Column(UnicodeText) poc_is_group_leader = Column(Boolean, default=False) poc_first_name = Column(UnicodeText) poc_last_name = Column(UnicodeText) poc_phone = Column(UnicodeText) poc_email = Column(UnicodeText) poc_zip_code = Column(UnicodeText) poc_address1 = Column(UnicodeText) poc_address2 = Column(UnicodeText) poc_city = Column(UnicodeText) poc_region = Column(UnicodeText) poc_country = Column(UnicodeText) handlers = Column(JSON, default=[], server_default='[]') @property def full_name(self): if self.poc_is_group_leader: return self.guest.group.leader.full_name elif self.poc_first_name or self.poc_last_name: return ' '.join([self.poc_first_name, self.poc_last_name]) else: return '' @property def first_name(self): if self.poc_is_group_leader: return self.guest.group.leader.first_name return self.poc_first_name @property def last_name(self): if self.poc_is_group_leader: return self.guest.group.leader.last_name return self.poc_last_name @property def phone(self): if self.poc_is_group_leader: return self.guest.group.leader.cellphone or self.tax_phone or self.guest.info.poc_phone return self.poc_phone @property def email(self): if self.poc_is_group_leader: return self.guest.group.leader.email return self.poc_email @property def rock_island_url(self): return '{}/guest_admin/rock_island?id={}'.format(c.PATH, self.guest_id) @property def rock_island_csv_url(self): return '{}/guest_admin/rock_island_csv?id={}'.format(c.PATH, self.guest_id) @property def status(self): if self.selling_merch == c.ROCK_ISLAND: return self.selling_merch_label + ('' if self.inventory else ' (No Merch)') return self.selling_merch_label @presave_adjustment def tax_phone_from_poc_phone(self): if self.selling_merch == c.OWN_TABLE and not self.tax_phone and self.guest and self.guest.info: self.tax_phone = self.guest.info.poc_phone @classmethod def extract_json_params(cls, params, field): multi_param_regex = re.compile(''.join(['^', field, r'_([\w_\-]+?)_(\d+)$'])) single_param_regex = re.compile(''.join(['^', field, r'_([\w_\-]+?)$'])) items = defaultdict(dict) single_item = dict() for param_name, value in filter(lambda i: i[1], params.items()): match = multi_param_regex.match(param_name) if match: name = match.group(1) item_number = int(match.group(2)) items[item_number][name] = value else: match = single_param_regex.match(param_name) if match: name = match.group(1) single_item[name] = value if single_item: items[len(items)] = single_item return [item for item_number, item in sorted(items.items())] @classmethod def extract_inventory(cls, params): inventory = {} for item in cls.extract_json_params(params, 'inventory'): if not item.get('id'): item['id'] = str(uuid.uuid4()) inventory[item['id']] = item return inventory @classmethod def extract_handlers(cls, params): return cls.extract_json_params(params, 'handlers') @classmethod def validate_inventory(cls, inventory): if not inventory: return 'You must add some merch to your inventory!' messages = [] for item_id, item in inventory.items(): quantity = int(item.get('quantity') or 0) if quantity <= 0 and cls.total_quantity(item) <= 0: messages.append('You must specify some quantity') for name, file in [(n, f) for (n, f) in item.items() if f]: match = cls._inventory_file_regex.match(name) if match and getattr(file, 'filename', None): file_type = match.group(1).upper() config_name = 'ALLOWED_INVENTORY_{}_EXTENSIONS'.format(file_type) extensions = getattr(c, config_name, []) ext = filename_extension(file.filename) if extensions and ext not in extensions: messages.append('{} files must be one of {}'.format(file_type.title(), ', '.join(extensions))) return '. '.join(uniquify([s.strip() for s in messages if s.strip()])) def _prune_inventory_file(self, item, new_inventory, *, prune_missing=False): for name, filename in list(item.items()): match = self._inventory_filename_regex.match(name) if match and filename: new_item = new_inventory.get(item['id']) if (prune_missing and not new_item) or (new_item and new_item.get(name) != filename): filepath = self.inventory_path(filename) if os.path.exists(filepath): os.remove(filepath) def _prune_inventory_files(self, new_inventory, *, prune_missing=False): for item_id, item in self.inventory.items(): self._prune_inventory_file(item, new_inventory, prune_missing=prune_missing) def _save_inventory_files(self, inventory): for item_id, item in inventory.items(): for name, file in [(n, f) for (n, f) in item.items() if f]: match = self._inventory_file_regex.match(name) if match: download_file_attr = '{}_download_filename'.format(name) file_attr = '{}_filename'.format(name) content_type_attr = '{}_content_type'.format(name) del item[name] if getattr(file, 'filename', None): item[download_file_attr] = file.filename item[file_attr] = str(uuid.uuid4()) item[content_type_attr] = file.content_type.value item_path = self.inventory_path(item[file_attr]) with open(item_path, 'wb') as f: shutil.copyfileobj(file.file, f) attrs = [download_file_attr, file_attr, content_type_attr] for attr in attrs: if attr in item and not item[attr]: del item[attr] @classmethod def total_quantity(cls, item): total_quantity = 0 for attr in filter(lambda s: s.startswith('quantity'), item.keys()): total_quantity += int(item[attr] if item[attr] else 0) return total_quantity @classmethod def item_subcategories(cls, item_type): s = {getattr(c, s): s for s in c.MERCH_TYPES_VARS}[int(item_type)] return ( getattr(c, '{}_VARIETIES'.format(s), defaultdict(lambda: {})), getattr(c, '{}_CUTS'.format(s), defaultdict(lambda: {})), getattr(c, '{}_SIZES'.format(s), defaultdict(lambda: {}))) @classmethod def item_subcategories_opts(cls, item_type): s = {getattr(c, s): s for s in c.MERCH_TYPES_VARS}[int(item_type)] return ( getattr(c, '{}_VARIETIES_OPTS'.format(s), defaultdict(lambda: [])), getattr(c, '{}_CUTS_OPTS'.format(s), defaultdict(lambda: [])), getattr(c, '{}_SIZES_OPTS'.format(s), defaultdict(lambda: []))) @classmethod def line_items(cls, item): line_items = [] for attr in filter(lambda s: s.startswith('quantity-'), item.keys()): if int(item[attr] if item[attr] else 0) > 0: line_items.append(attr) varieties, cuts, sizes = [ [v for (v, _) in x] for x in cls.item_subcategories_opts(item['type'])] def _line_item_sort_key(line_item): variety, cut, size = cls.line_item_to_types(line_item) return ( varieties.index(variety) if variety else 0, cuts.index(cut) if cut else 0, sizes.index(size) if size else 0) return sorted(line_items, key=_line_item_sort_key) @classmethod def line_item_to_types(cls, line_item): return [int(s) for s in line_item.split('-')[1:]] @classmethod def line_item_to_string(cls, item, line_item): variety_val, cut_val, size_val = cls.line_item_to_types(line_item) varieties, cuts, sizes = cls.item_subcategories(item['type']) variety_label = varieties.get(variety_val, '').strip() if not size_val and not cut_val: return variety_label + ' - One size only' size_label = sizes.get(size_val, '').strip() cut_label = cuts.get(cut_val, '').strip() parts = [variety_label] if cut_label: parts.append(cut_label) if size_label: parts.extend(['-', size_label]) return ' '.join(parts) @classmethod def inventory_path(cls, file): return os.path.join(c.GUESTS_INVENTORY_DIR, file) def inventory_url(self, item_id, name): return '{}/guests/view_inventory_file?id={}&item_id={}&name={}'.format(c.PATH, self.id, item_id, name) def remove_inventory_item(self, item_id, *, persist_files=True): item = None if item_id in self.inventory: inventory = dict(self.inventory) item = inventory[item_id] del inventory[item_id] if persist_files: self._prune_inventory_file(item, inventory, prune_missing=True) self.inventory = inventory return item def set_inventory(self, inventory, *, persist_files=True): if persist_files: self._save_inventory_files(inventory) self._prune_inventory_files(inventory, prune_missing=True) self.inventory = inventory def update_inventory(self, inventory, *, persist_files=True): if persist_files: self._save_inventory_files(inventory) self._prune_inventory_files(inventory, prune_missing=False) self.inventory = dict(self.inventory, **inventory)
class Event(MagModel): location = Column(Choice(c.EVENT_LOCATION_OPTS)) start_time = Column(UTCDateTime) duration = Column(Integer) # half-hour increments name = Column(UnicodeText, nullable=False) description = Column(UnicodeText) assigned_panelists = relationship('AssignedPanelist', backref='event') applications = relationship('PanelApplication', backref='event') panel_feedback = relationship('EventFeedback', backref='event') tournaments = relationship('TabletopTournament', backref='event', uselist=False) guest = relationship('GuestGroup', backref=backref('event', cascade="save-update,merge"), cascade='save-update,merge') @property def half_hours(self): half_hours = set() for i in range(self.duration): half_hours.add(self.start_time + timedelta(minutes=30 * i)) return half_hours @property def minutes(self): return (self.duration or 0) * 30 @property def start_slot(self): if self.start_time: start_delta = self.start_time_local - c.EPOCH return int(start_delta.total_seconds() / (60 * 30)) @property def end_time(self): return self.start_time + timedelta(minutes=self.minutes) @property def guidebook_name(self): return self.name @property def guidebook_subtitle(self): # Note: not everything on this list is actually exported if self.location in c.PANEL_ROOMS: return 'Panel' if self.location in c.MUSIC_ROOMS: return 'Music' if self.location in c.TABLETOP_LOCATIONS: return 'Tabletop Event' if "Autograph" in self.location_label: return 'Autograph Session' @property def guidebook_desc(self): panelists_creds = '<br/><br/>' + '<br/><br/>'.join( a.other_credentials for a in self.applications[0].applicants if a.other_credentials ) if self.applications else '' return self.description + panelists_creds @property def guidebook_location(self): return self.event.location_label
class GuestGroup(MagModel): group_id = Column(UUID, ForeignKey('group.id')) event_id = Column(UUID, ForeignKey('event.id', ondelete='SET NULL'), nullable=True) group_type = Column(Choice(c.GROUP_TYPE_OPTS), default=c.BAND) num_hotel_rooms = Column(Integer, default=1, admin_only=True) payment = Column(Integer, default=0, admin_only=True) vehicles = Column(Integer, default=1, admin_only=True) estimated_loadin_minutes = Column(Integer, default=c.DEFAULT_LOADIN_MINUTES, admin_only=True) estimated_performance_minutes = Column(Integer, default=c.DEFAULT_PERFORMANCE_MINUTES, admin_only=True) wants_mc = Column(Boolean, nullable=True) info = relationship('GuestInfo', backref=backref('guest', load_on_pending=True), uselist=False) bio = relationship('GuestBio', backref=backref('guest', load_on_pending=True), uselist=False) taxes = relationship('GuestTaxes', backref=backref('guest', load_on_pending=True), uselist=False) stage_plot = relationship('GuestStagePlot', backref=backref('guest', load_on_pending=True), uselist=False) panel = relationship('GuestPanel', backref=backref('guest', load_on_pending=True), uselist=False) merch = relationship('GuestMerch', backref=backref('guest', load_on_pending=True), uselist=False) charity = relationship('GuestCharity', backref=backref('guest', load_on_pending=True), uselist=False) autograph = relationship('GuestAutograph', backref=backref('guest', load_on_pending=True), uselist=False) interview = relationship('GuestInterview', backref=backref('guest', load_on_pending=True), uselist=False) travel_plans = relationship('GuestTravelPlans', backref=backref('guest', load_on_pending=True), uselist=False) email_model_name = 'guest' def __getattr__(self, name): """ If someone tries to access a property called, e.g., info_status, and the named property doesn't exist, we instead call self.status. This allows us to refer to status config options indirectly, which in turn allows us to override certain status options on a case-by-case basis. This is helpful for a couple of properties here, but it's vital to allow events to control group checklists with granularity. """ if name.endswith('_status'): return self.status(name.rsplit('_', 1)[0]) else: return super(GuestGroup, self).__getattr__(name) def deadline_from_model(self, model): name = str(self.group_type_label).upper() + "_" + str(model).upper() + "_DEADLINE" return getattr(c, name, None) @property def all_badges_claimed(self): return not any(a.is_unassigned for a in self.group.attendees) @property def estimated_performer_count(self): return len([a for a in self.group.attendees if a.badge_type == c.GUEST_BADGE]) @property def performance_minutes(self): return self.estimated_performance_minutes @property def email(self): return self.group.email @property def normalized_group_name(self): # Lowercase name = self.group.name.strip().lower() # Remove all special characters name = ''.join(s for s in name if s.isalnum() or s == ' ') # Remove extra whitespace & replace spaces with underscores return ' '.join(name.split()).replace(' ', '_') @property def badges_status(self): if self.group.unregistered_badges: return str(self.group.unregistered_badges) + " Unclaimed" return "Yes" @property def taxes_status(self): return "Not Needed" if not self.payment else self.status('taxes') @property def panel_status(self): application_count = len(self.group.leader.panel_applications) return '{} Panel Application(s)'.format(application_count) \ if self.group.leader.panel_applications else self.status('panel') @property def mc_status(self): return None if self.wants_mc is None else yesno(self.wants_mc, 'Yes,No') @property def checklist_completed(self): for list_item in c.GUEST_CHECKLIST_ITEMS: item_status = getattr(self, list_item['name'] + '_status', None) if self.deadline_from_model(list_item['name']) and not item_status: return False elif item_status and 'Unclaimed' in item_status: return False return True def status(self, model): """ This is a safe way to check if a step has been completed and what its status is for a particular group. It checks for a custom 'status' property for the step; if that doesn't exist, it will attempt to return True if an ID of the step exists or an empty string if not. If there's no corresponding deadline for the model we're checking, we return "N/A". Args: model: This should match one of the relationship columns in the GuestGroup class, e.g., 'bio' or 'taxes'. Returns: Returns either the 'status' property of the model, "N/A," True, or an empty string. """ if not self.deadline_from_model(model): return "N/A" subclass = getattr(self, model, None) if subclass: return getattr(subclass, 'status', getattr(subclass, 'id')) return '' @property def guidebook_name(self): return self.group.name if self.group else '' @property def guidebook_subtitle(self): return self.group_type_label @property def guidebook_desc(self): return self.bio.desc if self.bio else '' @property def guidebook_image(self): return self.bio.pic_filename if self.bio else '' @property def guidebook_thumbnail(self): return self.bio.pic_filename if self.bio else '' @property def guidebook_images(self): if not self.bio: return ['', ''] return [self.bio.pic_filename], [self.bio]