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 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 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 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 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 MerchPickup(MagModel): picked_up_by_id = Column(UUID, ForeignKey('attendee.id')) picked_up_for_id = Column(UUID, ForeignKey('attendee.id'), unique=True) picked_up_by = relationship( Attendee, primaryjoin='MerchPickup.picked_up_by_id == Attendee.id', cascade='save-update,merge,refresh-expire,expunge') picked_up_for = relationship( Attendee, primaryjoin='MerchPickup.picked_up_for_id == Attendee.id', cascade='save-update,merge,refresh-expire,expunge')
class Attendee: art_show_bidder = relationship('ArtShowBidder', backref=backref('attendee', load_on_pending=True), uselist=False) art_show_purchases = relationship( 'ArtShowPiece', backref='buyer', cascade='save-update,merge,refresh-expire,expunge', secondary='art_show_receipt') @presave_adjustment def not_attending_need_not_pay(self): if self.badge_status == c.NOT_ATTENDING: self.paid = c.NEED_NOT_PAY @presave_adjustment def add_as_agent(self): if self.promo_code: art_apps = self.session.lookup_agent_code(self.promo_code.code) for app in art_apps: app.agent_id = self.id @cost_property def art_show_app_cost(self): cost = 0 if self.art_show_applications: for app in self.art_show_applications: cost += app.total_cost return cost @property def art_show_receipt(self): open_receipts = [ receipt for receipt in self.art_show_receipts if not receipt.closed ] if open_receipts: return open_receipts[0] @property def full_address(self): if self.country and self.city and (self.region or self.country not in [ 'United States', 'Canada' ]) and self.address1: return True @property def payment_page(self): if self.art_show_applications: for app in self.art_show_applications: if app.total_cost and app.status != c.PAID: return '../art_show_applications/edit?id={}'.format(app.id) return 'attendee_donation_form?id={}'.format(self.id)
class WatchList(MagModel): first_names = Column(UnicodeText) last_name = Column(UnicodeText) email = Column(UnicodeText, default='') birthdate = Column(Date, nullable=True, default=None) reason = Column(UnicodeText) action = Column(UnicodeText) active = Column(Boolean, default=True) attendees = relationship('Attendee', backref=backref('watch_list', load_on_pending=True)) @property def full_name(self): return '{} {}'.format(self.first_names, self.last_name).strip() or 'Unknown' @property def first_name_list(self): return [name.strip().lower() for name in self.first_names.split(',')] @presave_adjustment def _fix_birthdate(self): if self.birthdate == '': self.birthdate = None
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 DeptRole(MagModel): name = Column(UnicodeText) description = Column(UnicodeText) department_id = Column(UUID, ForeignKey('department.id')) dept_memberships = relationship( 'DeptMembership', backref='dept_roles', cascade='save-update,merge,refresh-expire,expunge', secondary='dept_membership_dept_role') __table_args__ = (UniqueConstraint('name', 'department_id'), ) @hybrid_property def dept_membership_count(self): return len(self.dept_memberships) @dept_membership_count.expression def dept_membership_count(cls): return func.count(cls.dept_memberships) @classproperty def extra_apply_attrs(cls): return set(['dept_memberships_ids' ]).union(cls.extra_apply_attrs_restricted) @property def dept_memberships_ids(self): _, ids = self._get_relation_ids('dept_memberships') return [str(d.id) for d in self.dept_memberships] \ if ids is None else ids @dept_memberships_ids.setter def dept_memberships_ids(self, value): self._set_relation_ids('dept_memberships', DeptMembership, value)
class TabletopTournament(MagModel): event_id = Column(UUID, ForeignKey('event.id'), unique=True) # Separate from the event name for cases where we want a shorter name in our SMS messages. name = Column(UnicodeText) entrants = relationship('TabletopEntrant', backref='tournament')
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 TabletopEntrant(MagModel): tournament_id = Column(UUID, ForeignKey('tabletop_tournament.id')) attendee_id = Column(UUID, ForeignKey('attendee.id')) signed_up = Column(UTCDateTime, default=lambda: datetime.now(UTC)) confirmed = Column(Boolean, default=False) reminder = relationship('TabletopSmsReminder', backref='entrant', uselist=False) replies = relationship('TabletopSmsReply', backref='entrant') @presave_adjustment def _within_cutoff(self): if self.is_new: tournament = self.tournament or self.session.tabletop_tournament( self.tournament_id) cutoff = timedelta(minutes=c.TABLETOP_SMS_CUTOFF_MINUTES) if self.signed_up > tournament.event.start_time - cutoff: self.confirmed = True @property def should_send_reminder(self): stagger = timedelta(minutes=c.TABLETOP_SMS_STAGGER_MINUTES) reminder = timedelta(minutes=c.TABLETOP_SMS_REMINDER_MINUTES) return not self.confirmed \ and not self.reminder \ and localized_now() < self.tournament.event.start_time \ and localized_now() > self.signed_up + stagger \ and localized_now() > self.tournament.event.start_time - reminder def matches(self, message): sent = message.date_sent.replace(tzinfo=UTC) start_time_slack = timedelta(minutes=c.TABLETOP_TOURNAMENT_SLACK) return normalize_phone(self.attendee.cellphone) == message.from_ \ and self.reminder and sent > self.reminder.when \ and sent < self.tournament.event.start_time + start_time_slack __table_args__ = (UniqueConstraint('tournament_id', 'attendee_id', name='_tournament_entrant_uniq'), )
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 WatchList(MagModel): first_names = Column(UnicodeText) last_name = Column(UnicodeText) email = Column(UnicodeText, default='') birthdate = Column(Date, nullable=True, default=None) reason = Column(UnicodeText) action = Column(UnicodeText) active = Column(Boolean, default=True) attendees = relationship( 'Attendee', backref=backref('watch_list', load_on_pending=True)) @presave_adjustment def _fix_birthdate(self): if self.birthdate == '': self.birthdate = None
class TabletopGame(MagModel): code = Column(UnicodeText) name = Column(UnicodeText) attendee_id = Column(UUID, ForeignKey('attendee.id')) returned = Column(Boolean, default=False) checkouts = relationship('TabletopCheckout', backref='game') _repr_attr_names = ['name'] @property def checked_out(self): try: return [c for c in self.checkouts if not c.returned][0] except Exception: pass
class AdminAccount(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id'), unique=True) hashed = Column(UnicodeText) access = Column(MultiChoice(c.ACCESS_OPTS)) password_reset = relationship('PasswordReset', backref='admin_account', uselist=False) def __repr__(self): return '<{}>'.format(self.attendee.full_name) @staticmethod def is_nick(): return AdminAccount.admin_name() in c.JERKS @staticmethod def admin_name(): try: from uber.models import Session with Session() as session: return session.admin_attendee().full_name except: return None @staticmethod def admin_email(): try: from uber.models import Session with Session() as session: return session.admin_attendee().email except: return None @staticmethod def access_set(id=None): try: from uber.models import Session with Session() as session: id = id or cherrypy.session['account_id'] return set(session.admin_account(id).access_ints) except: return set()
class 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 AdminAccount(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id'), unique=True) hashed = Column(UnicodeText, private=True) access = Column(MultiChoice(c.ACCESS_OPTS)) password_reset = relationship('PasswordReset', backref='admin_account', uselist=False) api_tokens = relationship('ApiToken', backref='admin_account') active_api_tokens = relationship( 'ApiToken', primaryjoin='and_(' 'AdminAccount.id == ApiToken.admin_account_id, ' 'ApiToken.revoked_time == None)') judge = relationship('IndieJudge', uselist=False, backref='admin_account') def __repr__(self): return '<{}>'.format(self.attendee.full_name) @staticmethod def admin_name(): try: from uber.models import Session with Session() as session: return session.admin_attendee().full_name except Exception: return None @staticmethod def admin_email(): try: from uber.models import Session with Session() as session: return session.admin_attendee().email except Exception: return None @staticmethod def access_set(id=None): try: from uber.models import Session with Session() as session: id = id or cherrypy.session['account_id'] return set(session.admin_account(id).access_ints) except Exception: return set() def _allowed_opts(self, opts, required_access): access_opts = [] admin_access = set(self.access_ints) for access, label in opts: required = set(required_access.get(access, [])) if not required or any(a in required for a in admin_access): access_opts.append((access, label)) return access_opts @property def allowed_access_opts(self): return self._allowed_opts(c.ACCESS_OPTS, c.REQUIRED_ACCESS) @property def allowed_api_access_opts(self): required_access = {a: [a] for a in c.API_ACCESS.keys()} return self._allowed_opts(c.API_ACCESS_OPTS, required_access) @property def is_admin(self): return c.ADMIN in self.access_ints @presave_adjustment def _disable_api_access(self): new_access = set(int(s) for s in self.access.split(',') if s) old_access = set( int(s) for s in self.orig_value_of('access').split(',') if s) removed = old_access.difference(new_access) removed_api = set(a for a in c.API_ACCESS.keys() if a in removed) if removed_api: revoked_time = datetime.utcnow() for api_token in self.active_api_tokens: if removed_api.intersection(api_token.access_ints): api_token.revoked_time = revoked_time
class 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]
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 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 AdminAccount(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id'), unique=True) access_groups = relationship( 'AccessGroup', backref='admin_accounts', cascade='save-update,merge,refresh-expire,expunge', secondary='admin_access_group') hashed = Column(UnicodeText, private=True) password_reset = relationship('PasswordReset', backref='admin_account', uselist=False) api_tokens = relationship('ApiToken', backref='admin_account') active_api_tokens = relationship( 'ApiToken', primaryjoin='and_(' 'AdminAccount.id == ApiToken.admin_account_id, ' 'ApiToken.revoked_time == None)') judge = relationship('IndieJudge', uselist=False, backref='admin_account') def __repr__(self): return '<{}>'.format(self.attendee.full_name) @staticmethod def admin_name(): try: from uber.models import Session with Session() as session: return session.admin_attendee().full_name except Exception: return None @staticmethod def admin_email(): try: from uber.models import Session with Session() as session: return session.admin_attendee().email except Exception: return None @staticmethod def get_access_set(id=None, include_read_only=False): try: from uber.models import Session with Session() as session: id = id or cherrypy.session.get('account_id') account = session.admin_account(id) if include_read_only: return account.read_or_write_access_set return account.write_access_set except Exception: return set() @classproperty def _extra_apply_attrs(cls): return set(['access_groups_ids']) @property def write_access_set(self): access_list = [list(group.access) for group in self.access_groups] return set([item for sublist in access_list for item in sublist]) @property def read_access_set(self): access_list = [ list(group.read_only_access) for group in self.access_groups ] return set([item for sublist in access_list for item in sublist]) @property def read_or_write_access_set(self): return self.read_access_set.union(self.write_access_set) @property def access_groups_labels(self): return [d.name for d in self.access_groups] @property def access_groups_ids(self): _, ids = self._get_relation_ids('access_groups') return [str(a.id) for a in self.access_groups] if ids is None else ids @access_groups_ids.setter def access_groups_ids(self, value): values = set(s for s in listify(value) if s) for group in list(self.access_groups): if group.id not in values: # Manually remove the group to ensure the associated # rows in the admin_access_group table are deleted. self.access_groups.remove(group) self._set_relation_ids('access_groups', AccessGroup, list(values)) @property def allowed_access_opts(self): return self.session.query(AccessGroup).all() @property def allowed_api_access_opts(self): no_access_set = self.invalid_api_accesses() return [(access, label) for access, label in c.API_ACCESS_OPTS if access not in no_access_set] @property def viewable_guest_group_types(self): return [ opt for opt in c.GROUP_TYPE_VARS if opt.lower() + "_admin" in self.read_or_write_access_set ] @property def is_admin(self): return 'devtools' in self.write_access_set @property def is_mivs_judge_or_admin(self, id=None): try: from uber.models import Session with Session() as session: id = id or cherrypy.session.get('account_id') admin_account = session.admin_account(id) return admin_account.judge or 'mivs_judging' in admin_account.read_or_write_access_set except Exception: return None @property def api_read(self): return any([ group.has_any_access('api', read_only=True) for group in self.access_groups ]) @property def api_update(self): return any([ group.has_access_level('api', AccessGroup.LIMITED) for group in self.access_groups ]) @property def api_create(self): return any([ group.has_access_level('api', AccessGroup.CONTACT) for group in self.access_groups ]) @property def api_delete(self): return any( [group.has_full_access('api') for group in self.access_groups]) @property def full_dept_admin(self): return any([ group.has_full_access('dept_admin') for group in self.access_groups ]) @property def full_shifts_admin(self): return any([ group.has_full_access('shifts_admin') for group in self.access_groups ]) @property def full_dept_checklist_admin(self): return any([ group.has_full_access('dept_checklist') for group in self.access_groups ]) @property def full_attractions_admin(self): return any([ group.has_full_access('attractions_admin') for group in self.access_groups ]) @property def full_email_admin(self): return any([ group.has_full_access('email_admin') for group in self.access_groups ]) @property def full_registration_admin(self): return any([ group.has_full_access('registration') for group in self.access_groups ]) def max_level_access(self, site_section_or_page, read_only=False): write_access_list = [ int(group.access.get(site_section_or_page, 0)) for group in self.access_groups ] read_access_list = [ int(group.read_only_access.get(site_section_or_page, 0)) for group in self.access_groups ] return max(write_access_list + read_access_list) if read_only else max(write_access_list) @presave_adjustment def disable_api_access(self): invalid_api = self.invalid_api_accesses() if invalid_api: self.remove_disabled_api_keys(invalid_api) def remove_disabled_api_keys(self, invalid_api): revoked_time = datetime.utcnow() for api_token in self.active_api_tokens: if invalid_api.intersection(api_token.access_ints): api_token.revoked_time = revoked_time def invalid_api_accesses(self): """ Builds and returns a set of API accesses that this account does not have. Designed to help remove/hide API keys/options that accounts do not have permissions for. """ removed_api = set(c.API_ACCESS.keys()) for access, label in c.API_ACCESS_OPTS: access_name = 'api_' + label.lower() if getattr(self, access_name, None): removed_api.remove(access) return removed_api
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 Attraction(MagModel): _NONE = 0 _PER_FEATURE = 1 _PER_ATTRACTION = 2 _RESTRICTION_OPTS = [( _NONE, 'None – ' 'Attendees can attend as many events as they wish ' '(least restrictive)' ), ( _PER_FEATURE, 'Once Per Feature – ' 'Attendees can only attend each feature once' ), ( _PER_ATTRACTION, 'Once Per Attraction – ' 'Attendees can only attend this attraction once ' '(most restrictive)' )] _RESTRICTIONS = dict(_RESTRICTION_OPTS) _ADVANCE_CHECKIN_OPTS = [ (-1, 'Anytime during event'), (0, 'When the event starts'), (300, '5 minutes before'), (600, '10 minutes before'), (900, '15 minutes before'), (1200, '20 minutes before'), (1800, '30 minutes before'), (2700, '45 minutes before'), (3600, '1 hour before')] _ADVANCE_NOTICES_OPTS = [ ('', 'Never'), (0, 'When checkin starts'), (300, '5 minutes before checkin'), (900, '15 minutes before checkin'), (1800, '30 minutes before checkin'), (3600, '1 hour before checkin'), (7200, '2 hours before checkin'), (86400, '1 day before checkin')] name = Column(UnicodeText, unique=True) slug = Column(UnicodeText, unique=True) description = Column(UnicodeText) is_public = Column(Boolean, default=False) advance_notices = Column(JSON, default=[], server_default='[]') advance_checkin = Column(Integer, default=0) # In seconds restriction = Column(Choice(_RESTRICTION_OPTS), default=_NONE) badge_num_required = Column(Boolean, default=False) department_id = Column(UUID, ForeignKey('department.id'), nullable=True) owner_id = Column(UUID, ForeignKey('admin_account.id')) owner = relationship( 'AdminAccount', cascade='save-update,merge', backref=backref( 'attractions', cascade='all,delete-orphan', uselist=True, order_by='Attraction.name')) owner_attendee = relationship( 'Attendee', cascade='save-update,merge', secondary='admin_account', uselist=False, viewonly=True) department = relationship( 'Department', cascade='save-update,merge', backref=backref( 'attractions', cascade='save-update,merge', uselist=True), order_by='Department.name') features = relationship( 'AttractionFeature', backref='attraction', order_by='[AttractionFeature.name, AttractionFeature.id]') public_features = relationship( 'AttractionFeature', primaryjoin='and_(' 'AttractionFeature.attraction_id == Attraction.id,' 'AttractionFeature.is_public == True)', viewonly=True, order_by='[AttractionFeature.name, AttractionFeature.id]') events = relationship( 'AttractionEvent', backref='attraction', viewonly=True, order_by='[AttractionEvent.start_time, AttractionEvent.id]') signups = relationship( 'AttractionSignup', backref='attraction', viewonly=True, order_by='[AttractionSignup.checkin_time, AttractionSignup.id]') @presave_adjustment def _sluggify_name(self): self.slug = sluggify(self.name) @property def feature_opts(self): return [(f.id, f.name) for f in self.features] @property def feature_names_by_id(self): return OrderedDict(self.feature_opts) @property def used_location_opts(self): locs = set(e.location for e in self.events) sorted_locs = sorted(locs, key=lambda l: c.EVENT_LOCATIONS[l]) return [(l, c.EVENT_LOCATIONS[l]) for l in sorted_locs] @property def unused_location_opts(self): locs = set(e.location for e in self.events) return [(l, s) for l, s in c.EVENT_LOCATION_OPTS if l not in locs] @property def advance_checkin_label(self): if self.advance_checkin < 0: return 'anytime during the event' return humanize_timedelta( seconds=self.advance_checkin, separator=' ', now='by the time the event starts', prefix='at least ', suffix=' before the event starts') @property def location_opts(self): locations = map(lambda e: (e.location, c.EVENT_LOCATIONS[e.location]), self.events) return [(l, s) for l, s in sorted(locations, key=lambda l: l[1])] @property def locations(self): return OrderedDict(self.location_opts) @property def locations_by_feature_id(self): return groupify(self.features, 'id', lambda f: f.locations) def signups_requiring_notification(self, session, from_time, to_time, options=None): """ Returns a dict of AttractionSignups that require notification. The keys of the returned dict are the amount of advanced notice, given in seconds. A key of -1 indicates confirmation notices after a signup. The query generated by this method looks horrific, but is surprisingly efficient. """ advance_checkin = max(0, self.advance_checkin) subqueries = [] for advance_notice in sorted(set([-1] + self.advance_notices)): event_filters = [AttractionEvent.attraction_id == self.id] if advance_notice == -1: notice_ident = cast(AttractionSignup.attraction_event_id, UnicodeText) notice_param = bindparam('confirm_notice', advance_notice).label('advance_notice') else: advance_notice = max(0, advance_notice) + advance_checkin notice_delta = timedelta(seconds=advance_notice) event_filters += [ AttractionEvent.start_time >= from_time + notice_delta, AttractionEvent.start_time < to_time + notice_delta] notice_ident = func.concat(AttractionSignup.attraction_event_id, '_{}'.format(advance_notice)) notice_param = bindparam( 'advance_notice_{}'.format(advance_notice), advance_notice).label('advance_notice') subquery = session.query(AttractionSignup, notice_param).filter( AttractionSignup.is_unchecked_in, AttractionSignup.attraction_event_id.in_( session.query(AttractionEvent.id).filter(*event_filters)), not_(exists().where(and_( AttractionNotification.ident == notice_ident, AttractionNotification.attraction_event_id == AttractionSignup.attraction_event_id, AttractionNotification.attendee_id == AttractionSignup.attendee_id)))).with_labels() subqueries.append(subquery) query = subqueries[0].union(*subqueries[1:]) if options: query = query.options(*listify(options)) query.order_by(AttractionSignup.id) return groupify(query, lambda x: x[0], lambda x: x[1])
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 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 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 AttractionSignup(MagModel): attraction_event_id = Column(UUID, ForeignKey('attraction_event.id')) attraction_id = Column(UUID, ForeignKey('attraction.id')) attendee_id = Column(UUID, ForeignKey('attendee.id')) signup_time = Column(UTCDateTime, default=lambda: datetime.now(pytz.UTC)) checkin_time = Column(UTCDateTime, default=lambda: utcmin.datetime, index=True) notifications = relationship( 'AttractionNotification', cascade='save-update, merge, refresh-expire, expunge', backref=backref( 'signup', cascade='save-update,merge', uselist=False, viewonly=True), primaryjoin='and_(' 'AttractionSignup.attendee_id == foreign(AttractionNotification.attendee_id),' 'AttractionSignup.attraction_event_id == foreign(AttractionNotification.attraction_event_id))', order_by='AttractionNotification.sent_time', viewonly=True) __mapper_args__ = {'confirm_deleted_rows': False} __table_args__ = (UniqueConstraint('attraction_event_id', 'attendee_id'),) def __init__(self, attendee=None, event=None, **kwargs): super(AttractionSignup, self).__init__(**kwargs) if attendee: self.attendee = attendee if event: self.event = event if not self.attraction_id and self.event: self.attraction_id = self.event.attraction_id @presave_adjustment def _fix_attraction_id(self): if not self.attraction_id and self.event: self.attraction_id = self.event.attraction_id @property def checkin_time_local(self): if self.is_checked_in: return self.checkin_time.astimezone(c.EVENT_TIMEZONE) return None @property def checkin_time_label(self): if self.is_checked_in: return self.checkin_time_local.strftime('%-I:%M %p %A') return 'Not checked in' @property def signup_time_label(self): return self.signup_time_local.strftime('%-I:%M %p %A') @property def email(self): return self.attendee.email @property def email_model_name(self): return 'signup' @hybrid_property def is_checked_in(self): return self.checkin_time > utcmin.datetime @is_checked_in.expression def is_checked_in(cls): return cls.checkin_time > utcmin.datetime @hybrid_property def is_unchecked_in(self): return self.checkin_time <= utcmin.datetime @is_unchecked_in.expression def is_unchecked_in(cls): return cls.checkin_time <= utcmin.datetime