diff --git a/alembic/versions/318d761a5c62_update_artist_marketplace_application.py b/alembic/versions/318d761a5c62_update_artist_marketplace_application.py index 0de4e0b19..f951e154a 100644 --- a/alembic/versions/318d761a5c62_update_artist_marketplace_application.py +++ b/alembic/versions/318d761a5c62_update_artist_marketplace_application.py @@ -79,11 +79,9 @@ def upgrade(): op.drop_table('marketplace_application') op.add_column('receipt_item', sa.Column('fk_id', residue.UUID(), nullable=True)) op.add_column('receipt_item', sa.Column('fk_model', sa.Unicode(), server_default='', nullable=False)) - op.create_index(op.f('ix_receipt_item_fk_id'), 'receipt_item', ['fk_id'], unique=False) def downgrade(): - op.drop_index(op.f('ix_receipt_item_fk_id'), table_name='receipt_item') op.drop_column('receipt_item', 'fk_model') op.drop_column('receipt_item', 'fk_id') op.create_table('marketplace_application', diff --git a/alembic/versions/6f2db268b938_add_checkbox_to_opt_into_art_show_won_.py b/alembic/versions/6f2db268b938_add_checkbox_to_opt_into_art_show_won_.py new file mode 100644 index 000000000..8a5ac8773 --- /dev/null +++ b/alembic/versions/6f2db268b938_add_checkbox_to_opt_into_art_show_won_.py @@ -0,0 +1,59 @@ +"""Add checkbox to opt into art show won bids email + +Revision ID: 6f2db268b938 +Revises: df998079da32 +Create Date: 2024-11-11 15:46:11.969424 + +""" + + +# revision identifiers, used by Alembic. +revision = '6f2db268b938' +down_revision = 'df998079da32' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + + +try: + is_sqlite = op.get_context().dialect.name == 'sqlite' +except Exception: + is_sqlite = False + +if is_sqlite: + op.get_context().connection.execute('PRAGMA foreign_keys=ON;') + utcnow_server_default = "(datetime('now', 'utc'))" +else: + utcnow_server_default = "timezone('utc', current_timestamp)" + +def sqlite_column_reflect_listener(inspector, table, column_info): + """Adds parenthesis around SQLite datetime defaults for utcnow.""" + if column_info['default'] == "datetime('now', 'utc')": + column_info['default'] = utcnow_server_default + +sqlite_reflect_kwargs = { + 'listeners': [('column_reflect', sqlite_column_reflect_listener)] +} + +# =========================================================================== +# HOWTO: Handle alter statements in SQLite +# +# def upgrade(): +# if is_sqlite: +# with op.batch_alter_table('table_name', reflect_kwargs=sqlite_reflect_kwargs) as batch_op: +# batch_op.alter_column('column_name', type_=sa.Unicode(), server_default='', nullable=False) +# else: +# op.alter_column('table_name', 'column_name', type_=sa.Unicode(), server_default='', nullable=False) +# +# =========================================================================== + + +def upgrade(): + op.add_column('art_show_bidder', sa.Column('email_won_bids', sa.Boolean(), server_default='False', nullable=False)) + + +def downgrade(): + op.drop_column('art_show_bidder', 'email_won_bids') diff --git a/alembic/versions/8224640bc6cc_add_badge_pickup_groups.py b/alembic/versions/8224640bc6cc_add_badge_pickup_groups.py new file mode 100644 index 000000000..77220d369 --- /dev/null +++ b/alembic/versions/8224640bc6cc_add_badge_pickup_groups.py @@ -0,0 +1,73 @@ +"""Add badge pickup groups + +Revision ID: 8224640bc6cc +Revises: 6f2db268b938 +Create Date: 2024-11-13 04:55:18.730126 + +""" + + +# revision identifiers, used by Alembic. +revision = '8224640bc6cc' +down_revision = '6f2db268b938' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +import residue + + +try: + is_sqlite = op.get_context().dialect.name == 'sqlite' +except Exception: + is_sqlite = False + +if is_sqlite: + op.get_context().connection.execute('PRAGMA foreign_keys=ON;') + utcnow_server_default = "(datetime('now', 'utc'))" +else: + utcnow_server_default = "timezone('utc', current_timestamp)" + +def sqlite_column_reflect_listener(inspector, table, column_info): + """Adds parenthesis around SQLite datetime defaults for utcnow.""" + if column_info['default'] == "datetime('now', 'utc')": + column_info['default'] = utcnow_server_default + +sqlite_reflect_kwargs = { + 'listeners': [('column_reflect', sqlite_column_reflect_listener)] +} + +# =========================================================================== +# HOWTO: Handle alter statements in SQLite +# +# def upgrade(): +# if is_sqlite: +# with op.batch_alter_table('table_name', reflect_kwargs=sqlite_reflect_kwargs) as batch_op: +# batch_op.alter_column('column_name', type_=sa.Unicode(), server_default='', nullable=False) +# else: +# op.alter_column('table_name', 'column_name', type_=sa.Unicode(), server_default='', nullable=False) +# +# =========================================================================== + + +def upgrade(): + op.create_table('badge_pickup_group', + sa.Column('id', residue.UUID(), nullable=False), + sa.Column('created', residue.UTCDateTime(), server_default=sa.text("timezone('utc', current_timestamp)"), nullable=False), + sa.Column('last_updated', residue.UTCDateTime(), server_default=sa.text("timezone('utc', current_timestamp)"), nullable=False), + sa.Column('external_id', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False), + sa.Column('last_synced', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False), + sa.Column('public_id', residue.UUID(), nullable=True), + sa.Column('account_id', sa.Unicode(), server_default='', nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_badge_pickup_group')) + ) + op.add_column('attendee', sa.Column('badge_pickup_group_id', residue.UUID(), nullable=True)) + op.create_foreign_key(op.f('fk_attendee_badge_pickup_group_id_badge_pickup_group'), 'attendee', 'badge_pickup_group', ['badge_pickup_group_id'], ['id'], ondelete='SET NULL') + + +def downgrade(): + op.drop_constraint(op.f('fk_attendee_badge_pickup_group_id_badge_pickup_group'), 'attendee', type_='foreignkey') + op.drop_column('attendee', 'badge_pickup_group_id') + op.drop_table('badge_pickup_group') diff --git a/alembic/versions/9c23621e5e12_add_list_of_refund_txns_to_receipt_txns.py b/alembic/versions/9c23621e5e12_add_list_of_refund_txns_to_receipt_txns.py new file mode 100644 index 000000000..216f95562 --- /dev/null +++ b/alembic/versions/9c23621e5e12_add_list_of_refund_txns_to_receipt_txns.py @@ -0,0 +1,63 @@ +"""Add list of refund txns to receipt txns + +Revision ID: 9c23621e5e12 +Revises: f01a2ad10d79 +Create Date: 2024-11-06 17:59:12.695586 + +""" + + +# revision identifiers, used by Alembic. +revision = '9c23621e5e12' +down_revision = 'f01a2ad10d79' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +import residue + + +try: + is_sqlite = op.get_context().dialect.name == 'sqlite' +except Exception: + is_sqlite = False + +if is_sqlite: + op.get_context().connection.execute('PRAGMA foreign_keys=ON;') + utcnow_server_default = "(datetime('now', 'utc'))" +else: + utcnow_server_default = "timezone('utc', current_timestamp)" + +def sqlite_column_reflect_listener(inspector, table, column_info): + """Adds parenthesis around SQLite datetime defaults for utcnow.""" + if column_info['default'] == "datetime('now', 'utc')": + column_info['default'] = utcnow_server_default + +sqlite_reflect_kwargs = { + 'listeners': [('column_reflect', sqlite_column_reflect_listener)] +} + +# =========================================================================== +# HOWTO: Handle alter statements in SQLite +# +# def upgrade(): +# if is_sqlite: +# with op.batch_alter_table('table_name', reflect_kwargs=sqlite_reflect_kwargs) as batch_op: +# batch_op.alter_column('column_name', type_=sa.Unicode(), server_default='', nullable=False) +# else: +# op.alter_column('table_name', 'column_name', type_=sa.Unicode(), server_default='', nullable=False) +# +# =========================================================================== + + +def upgrade(): + #op.create_index(op.f('ix_receipt_item_fk_id'), 'receipt_item', ['fk_id'], unique=False) + op.add_column('receipt_transaction', sa.Column('refunded_txn_id', residue.UUID(), nullable=True)) + op.create_foreign_key(op.f('fk_receipt_transaction_refunded_txn_id_receipt_transaction'), 'receipt_transaction', 'receipt_transaction', ['refunded_txn_id'], ['id'], ondelete='SET NULL') + + +def downgrade(): + op.drop_constraint(op.f('fk_receipt_transaction_refunded_txn_id_receipt_transaction'), 'receipt_transaction', type_='foreignkey') + op.drop_column('receipt_transaction', 'refunded_txn_id') + op.drop_index(op.f('ix_receipt_item_fk_id'), table_name='receipt_item') diff --git a/alembic/versions/df998079da32_add_admin_notes_to_receipt_items.py b/alembic/versions/df998079da32_add_admin_notes_to_receipt_items.py new file mode 100644 index 000000000..8397ad670 --- /dev/null +++ b/alembic/versions/df998079da32_add_admin_notes_to_receipt_items.py @@ -0,0 +1,59 @@ +"""Add admin notes to receipt items + +Revision ID: df998079da32 +Revises: 9c23621e5e12 +Create Date: 2024-11-11 00:27:15.421118 + +""" + + +# revision identifiers, used by Alembic. +revision = 'df998079da32' +down_revision = '9c23621e5e12' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + + +try: + is_sqlite = op.get_context().dialect.name == 'sqlite' +except Exception: + is_sqlite = False + +if is_sqlite: + op.get_context().connection.execute('PRAGMA foreign_keys=ON;') + utcnow_server_default = "(datetime('now', 'utc'))" +else: + utcnow_server_default = "timezone('utc', current_timestamp)" + +def sqlite_column_reflect_listener(inspector, table, column_info): + """Adds parenthesis around SQLite datetime defaults for utcnow.""" + if column_info['default'] == "datetime('now', 'utc')": + column_info['default'] = utcnow_server_default + +sqlite_reflect_kwargs = { + 'listeners': [('column_reflect', sqlite_column_reflect_listener)] +} + +# =========================================================================== +# HOWTO: Handle alter statements in SQLite +# +# def upgrade(): +# if is_sqlite: +# with op.batch_alter_table('table_name', reflect_kwargs=sqlite_reflect_kwargs) as batch_op: +# batch_op.alter_column('column_name', type_=sa.Unicode(), server_default='', nullable=False) +# else: +# op.alter_column('table_name', 'column_name', type_=sa.Unicode(), server_default='', nullable=False) +# +# =========================================================================== + + +def upgrade(): + op.add_column('receipt_item', sa.Column('admin_notes', sa.Unicode(), server_default='', nullable=False)) + + +def downgrade(): + op.drop_column('receipt_item', 'admin_notes') diff --git a/alembic/versions/ecb1983d7dfd_add_escalation_tickets_for_attendee_.py b/alembic/versions/ecb1983d7dfd_add_escalation_tickets_for_attendee_.py new file mode 100644 index 000000000..7148f481a --- /dev/null +++ b/alembic/versions/ecb1983d7dfd_add_escalation_tickets_for_attendee_.py @@ -0,0 +1,87 @@ +"""Add escalation tickets for attendee check-in + +Revision ID: ecb1983d7dfd +Revises: 8224640bc6cc +Create Date: 2024-11-15 22:59:42.134196 + +""" + + +# revision identifiers, used by Alembic. +revision = 'ecb1983d7dfd' +down_revision = '8224640bc6cc' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.schema import Sequence, CreateSequence, DropSequence +import residue + + +try: + is_sqlite = op.get_context().dialect.name == 'sqlite' +except Exception: + is_sqlite = False + +if is_sqlite: + op.get_context().connection.execute('PRAGMA foreign_keys=ON;') + utcnow_server_default = "(datetime('now', 'utc'))" +else: + utcnow_server_default = "timezone('utc', current_timestamp)" + +def sqlite_column_reflect_listener(inspector, table, column_info): + """Adds parenthesis around SQLite datetime defaults for utcnow.""" + if column_info['default'] == "datetime('now', 'utc')": + column_info['default'] = utcnow_server_default + +sqlite_reflect_kwargs = { + 'listeners': [('column_reflect', sqlite_column_reflect_listener)] +} + +# =========================================================================== +# HOWTO: Handle alter statements in SQLite +# +# def upgrade(): +# if is_sqlite: +# with op.batch_alter_table('table_name', reflect_kwargs=sqlite_reflect_kwargs) as batch_op: +# batch_op.alter_column('column_name', type_=sa.Unicode(), server_default='', nullable=False) +# else: +# op.alter_column('table_name', 'column_name', type_=sa.Unicode(), server_default='', nullable=False) +# +# =========================================================================== + + +def upgrade(): + op.execute(CreateSequence(Sequence('escalation_ticket_ticket_id_seq'))) + op.create_table('escalation_ticket', + sa.Column('id', residue.UUID(), nullable=False), + sa.Column('created', residue.UTCDateTime(), server_default=sa.text("timezone('utc', current_timestamp)"), nullable=False), + sa.Column('last_updated', residue.UTCDateTime(), server_default=sa.text("timezone('utc', current_timestamp)"), nullable=False), + sa.Column('external_id', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False), + sa.Column('last_synced', postgresql.JSONB(astext_type=sa.Text()), server_default='{}', nullable=False), + sa.Column('ticket_id', sa.Integer(), server_default=sa.text("nextval('escalation_ticket_ticket_id_seq')"), nullable=False), + sa.Column('description', sa.Unicode(), server_default='', nullable=False), + sa.Column('admin_notes', sa.Unicode(), server_default='', nullable=False), + sa.Column('resolved', residue.UTCDateTime(), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_escalation_ticket')), + sa.UniqueConstraint('ticket_id', name=op.f('uq_escalation_ticket_ticket_id')) + ) + op.create_table('attendee_escalation_ticket', + sa.Column('attendee_id', residue.UUID(), nullable=False), + sa.Column('escalation_ticket_id', residue.UUID(), nullable=False), + sa.ForeignKeyConstraint(['attendee_id'], ['attendee.id'], name=op.f('fk_attendee_escalation_ticket_attendee_id_attendee')), + sa.ForeignKeyConstraint(['escalation_ticket_id'], ['escalation_ticket.id'], name=op.f('fk_attendee_escalation_ticket_escalation_ticket_id_escalation_ticket')), + sa.UniqueConstraint('attendee_id', 'escalation_ticket_id', name=op.f('uq_attendee_escalation_ticket_attendee_id')) + ) + op.create_index('ix_attendee_escalation_ticket_attendee_id', 'attendee_escalation_ticket', ['attendee_id'], unique=False) + op.create_index('ix_attendee_escalation_ticket_escalation_ticket_id', 'attendee_escalation_ticket', ['escalation_ticket_id'], unique=False) + + +def downgrade(): + op.drop_index('ix_attendee_escalation_ticket_escalation_ticket_id', table_name='attendee_escalation_ticket') + op.drop_index('ix_attendee_escalation_ticket_attendee_id', table_name='attendee_escalation_ticket') + op.drop_table('attendee_escalation_ticket') + op.drop_table('escalation_ticket') + op.execute(DropSequence(Sequence('escalation_ticket_ticket_id_seq'))) diff --git a/alembic/versions/f01a2ad10d79_add_header_thumbnail_flag_to_mivs_images.py b/alembic/versions/f01a2ad10d79_add_header_thumbnail_flag_to_mivs_images.py index a99628cbf..6ca30a767 100644 --- a/alembic/versions/f01a2ad10d79_add_header_thumbnail_flag_to_mivs_images.py +++ b/alembic/versions/f01a2ad10d79_add_header_thumbnail_flag_to_mivs_images.py @@ -52,16 +52,10 @@ def sqlite_column_reflect_listener(inspector, table, column_info): def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### op.add_column('indie_game_image', sa.Column('is_header', sa.Boolean(), server_default='False', nullable=False)) op.add_column('indie_game_image', sa.Column('is_thumbnail', sa.Boolean(), server_default='False', nullable=False)) - #op.create_unique_constraint(op.f('uq_lottery_application_attendee_id'), 'lottery_application', ['attendee_id']) - # ### end Alembic commands ### def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(op.f('uq_lottery_application_attendee_id'), 'lottery_application', type_='unique') op.drop_column('indie_game_image', 'is_thumbnail') op.drop_column('indie_game_image', 'is_header') - # ### end Alembic commands ### diff --git a/alembic/versions/f85c05a4410e_add_supervisor_column_to_tracking_tables.py b/alembic/versions/f85c05a4410e_add_supervisor_column_to_tracking_tables.py new file mode 100644 index 000000000..2699441e8 --- /dev/null +++ b/alembic/versions/f85c05a4410e_add_supervisor_column_to_tracking_tables.py @@ -0,0 +1,69 @@ +"""Add supervisor column to tracking tables + +Revision ID: f85c05a4410e +Revises: ecb1983d7dfd +Create Date: 2024-11-21 17:03:10.778841 + +""" + + +# revision identifiers, used by Alembic. +revision = 'f85c05a4410e' +down_revision = 'ecb1983d7dfd' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + + +try: + is_sqlite = op.get_context().dialect.name == 'sqlite' +except Exception: + is_sqlite = False + +if is_sqlite: + op.get_context().connection.execute('PRAGMA foreign_keys=ON;') + utcnow_server_default = "(datetime('now', 'utc'))" +else: + utcnow_server_default = "timezone('utc', current_timestamp)" + +def sqlite_column_reflect_listener(inspector, table, column_info): + """Adds parenthesis around SQLite datetime defaults for utcnow.""" + if column_info['default'] == "datetime('now', 'utc')": + column_info['default'] = utcnow_server_default + +sqlite_reflect_kwargs = { + 'listeners': [('column_reflect', sqlite_column_reflect_listener)] +} + +# =========================================================================== +# HOWTO: Handle alter statements in SQLite +# +# def upgrade(): +# if is_sqlite: +# with op.batch_alter_table('table_name', reflect_kwargs=sqlite_reflect_kwargs) as batch_op: +# batch_op.alter_column('column_name', type_=sa.Unicode(), server_default='', nullable=False) +# else: +# op.alter_column('table_name', 'column_name', type_=sa.Unicode(), server_default='', nullable=False) +# +# =========================================================================== + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_indie_developer_attendee_id', 'indie_developer', type_='unique') + op.add_column('page_view_tracking', sa.Column('supervisor', sa.Unicode(), server_default='', nullable=False)) + op.add_column('report_tracking', sa.Column('supervisor', sa.Unicode(), server_default='', nullable=False)) + op.add_column('tracking', sa.Column('supervisor', sa.Unicode(), server_default='', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tracking', 'supervisor') + op.drop_column('report_tracking', 'supervisor') + op.drop_column('page_view_tracking', 'supervisor') + op.create_unique_constraint('uq_indie_developer_attendee_id', 'indie_developer', ['attendee_id']) + # ### end Alembic commands ### diff --git a/uber/automated_emails.py b/uber/automated_emails.py index 155710b70..0cfc9fd14 100644 --- a/uber/automated_emails.py +++ b/uber/automated_emails.py @@ -23,7 +23,7 @@ from uber.config import c from uber import decorators from uber.jinja import JinjaEnv -from uber.models import (AdminAccount, Attendee, AttendeeAccount, ArtShowApplication, AutomatedEmail, Department, +from uber.models import (AdminAccount, Attendee, AttendeeAccount, ArtShowApplication, ArtShowBidder, AutomatedEmail, Department, Group, GuestGroup, IndieGame, IndieJudge, IndieStudio, ArtistMarketplaceApplication, MITSTeam, MITSApplicant, PanelApplication, PanelApplicant, PromoCodeGroup, Room, RoomAssignment, LotteryApplication, Shift) from uber.utils import after, before, days_after, days_before, days_between, localized_now, DeptChecklistConf @@ -275,10 +275,25 @@ def body(self): # ============================= AutomatedEmailFixture.queries.update({ ArtShowApplication: - lambda session: session.query(ArtShowApplication).options(subqueryload(ArtShowApplication.attendee)) + lambda session: session.query(ArtShowApplication).options(subqueryload(ArtShowApplication.attendee)), + ArtShowBidder: + lambda session: session.query(ArtShowBidder).options(subqueryload(ArtShowBidder.attendee), + subqueryload(ArtShowBidder.art_show_pieces)) }) +AutomatedEmailFixture( + ArtShowBidder, + 'Bidding Winner Notification for the {EVENT_NAME} Art Show', + 'art_show/pieces_won.html', + lambda a: a.email_won_bids and len( + [piece for piece in a.art_show_pieces if piece.winning_bid and piece.status == c.SOLD]) > 0, + needs_approval=True, + allow_at_the_con=True, + sender=c.ART_SHOW_EMAIL, + ident='art_show_pieces_won') + + class ArtShowAppEmailFixture(AutomatedEmailFixture): def __init__(self, subject, template, filter, ident, **kwargs): AutomatedEmailFixture.__init__(self, ArtShowApplication, subject, @@ -324,21 +339,21 @@ def __init__(self, subject, template, filter, ident, **kwargs): ArtShowAppEmailFixture( 'Reminder to pay for your {EVENT_NAME} Art Show application', 'art_show/payment_reminder.txt', - lambda a: a.status == c.APPROVED and a.is_unpaid, + lambda a: a.status == c.APPROVED and a.amount_unpaid, when=days_between((14, c.ART_SHOW_PAYMENT_DUE), (1, c.EPOCH)), ident='art_show_payment_reminder') ArtShowAppEmailFixture( '{EVENT_NAME} Art Show piece entry needed', 'art_show/pieces_reminder.txt', - lambda a: a.status == c.APPROVED and not a.is_unpaid and not a.art_show_pieces, + lambda a: a.status == c.APPROVED and not a.amount_unpaid and not a.art_show_pieces, when=days_before(15, c.EPOCH), ident='art_show_pieces_reminder') ArtShowAppEmailFixture( 'Reminder to assign an agent for your {EVENT_NAME} Art Show application', 'art_show/agent_reminder.html', - lambda a: a.status == c.APPROVED and not a.is_unpaid and a.delivery_method == c.AGENT and not a.agent, + lambda a: a.status == c.APPROVED and not a.amount_unpaid and a.delivery_method == c.AGENT and not a.current_agents, when=after(c.EVENT_TIMEZONE.localize(datetime(int(c.EVENT_YEAR), 11, 1))), ident='art_show_agent_reminder') @@ -346,7 +361,7 @@ def __init__(self, subject, template, filter, ident, **kwargs): ArtShowAppEmailFixture( '{EVENT_NAME} Art Show MAIL IN Instructions', 'art_show/mailing_in.html', - lambda a: a.status == c.APPROVED and not a.is_unpaid and a.delivery_method == c.BY_MAIL, + lambda a: a.status == c.APPROVED and not a.amount_unpaid and a.delivery_method == c.BY_MAIL, when=days_between((c.ART_SHOW_REG_START, 13), (16, c.ART_SHOW_WAITLIST if c.ART_SHOW_WAITLIST else c.ART_SHOW_DEADLINE)), ident='art_show_mail_in') diff --git a/uber/config.py b/uber/config.py index 42c584376..f474c145f 100644 --- a/uber/config.py +++ b/uber/config.py @@ -891,7 +891,7 @@ def CURRENT_ADMIN(self): try: from uber.models import Session, AdminAccount, Attendee with Session() as session: - attrs = Attendee.to_dict_default_attrs + ['admin_account', 'assigned_depts'] + attrs = Attendee.to_dict_default_attrs + ['admin_account', 'assigned_depts', 'logged_in_name'] admin_account = session.query(AdminAccount) \ .filter_by(id=cherrypy.session.get('account_id')) \ .options(subqueryload(AdminAccount.attendee).subqueryload(Attendee.assigned_depts)).one() @@ -904,9 +904,32 @@ def CURRENT_ADMIN(self): @dynamic def CURRENT_VOLUNTEER(self): try: - from uber.models import Session + from uber.models import Session, Attendee with Session() as session: + attrs = Attendee.to_dict_default_attrs + ['logged_in_name'] attendee = session.logged_in_volunteer() + return attendee.to_dict(attrs) + except Exception: + return {} + + @request_cached_property + @dynamic + def CURRENT_KIOSK_SUPERVISOR(self): + try: + from uber.models import Session + with Session() as session: + admin_account = session.current_supervisor_admin() + return admin_account.attendee.to_dict() + except Exception: + return {} + + @request_cached_property + @dynamic + def CURRENT_KIOSK_OPERATOR(self): + try: + from uber.models import Session + with Session() as session: + attendee = session.kiosk_operator_attendee() return attendee.to_dict() except Exception: return {} @@ -1587,8 +1610,6 @@ def _unrepr(d): c.PREREG_SHIRTS = {key: val for key, val in c.PREREG_SHIRT_OPTS} c.STAFF_SHIRT_OPTS = sorted(c.STAFF_SHIRT_OPTS if len(c.STAFF_SHIRT_OPTS) > 1 else c.SHIRT_OPTS) c.SHIRT_OPTS = sorted(c.SHIRT_OPTS) -c.MERCH_SHIRT_OPTS = [(c.SIZE_UNKNOWN, 'select a size')] + sorted(list(c.SHIRT_OPTS)) -c.MERCH_STAFF_SHIRT_OPTS = [(c.SIZE_UNKNOWN, 'select a size')] + sorted(list(c.STAFF_SHIRT_OPTS)) shirt_label_lookup = {val: key for key, val in c.SHIRT_OPTS} c.SHIRT_SIZE_STOCKS = {shirt_label_lookup[val]: key for key, val in c.SHIRT_STOCK_OPTS} diff --git a/uber/configspec.ini b/uber/configspec.ini index 83919d78e..a990184a3 100644 --- a/uber/configspec.ini +++ b/uber/configspec.ini @@ -124,6 +124,11 @@ groups_enabled = boolean(default=True) # badge using a link sent to the attendee's email address. attendee_accounts_enabled = boolean(default=True) +# If this is true, attendees under one account are put into a badge pickup group +# which admins can use to check multiple people in at once. +# TODO: Making this work without attendee accounts +badge_pickup_groups_enabled = boolean(default=True) + # If someone tries to log in or make and account and their email matches one # of these domains, we tell them to use SSO instead sso_email_domains = string_list(default=list()) @@ -453,12 +458,17 @@ shirts_per_staffer = integer(default=1) # and staff shirt size will not be shown on the registration form. staff_shirts_optional = boolean(default=False) -# Some events giv their staffers their special merch (such as swag shirts) +# Some events give their staffers their special merch (such as swag shirts) # at a separate location from the normal merch booth. If this setting is true, # then the merch page will have two separate buttons for merch and staff merch # but otherwise those will be combined and all distributed at once. separate_staff_merch = boolean(default=True) +# Different events have different policies for merch, such as offering +# a discount to staff. Override this list to remove the associated buttons +# from merch_admin/index +merch_ops = string_list(default=list('give_merch', 'discount', 'mpoints')) + # The max number of tables a dealer can apply for. Note that the admin # interface allows you to give a dealer a higher number than this. max_tables = integer(default=4) @@ -1507,7 +1517,6 @@ child_badge = string(default="Minor") __many__ = string [[badge_status]] -at_door_pending_status = string(default="At-Door Pending") pending_status = string(default="Pending") imported_status = string(default="Imported") new_status = string(default="New") @@ -1988,6 +1997,7 @@ mature = string(default="Mature") expected = string(default="Expected") received = string(default="Received") not_received = string(default="Not Received") +hanging = string(default="Hanging") hung = string(default="Hung") removed = string(default="Removed") voice_auction = string(default="Voice Auction") diff --git a/uber/decorators.py b/uber/decorators.py index 61027c501..c0553e200 100644 --- a/uber/decorators.py +++ b/uber/decorators.py @@ -178,7 +178,7 @@ def protected(*args, **kwargs): else: message_add = 'fill out this application' message = 'Please log in or create an account to {}!'.format(message_add) - raise HTTPRedirect('../landing/index?message={}'.format(message), save_location=True) + ajax_or_redirect(func, '../landing/index?message=', message, True) elif attendee_account_id is None and admin_account_id is None or \ attendee_account_id is None and c.PAGE_PATH == '/preregistration/homepage': message = 'You must log in to view this page.' @@ -203,7 +203,7 @@ def protected(*args, **kwargs): break if error and not attendee: - raise HTTPRedirect('../preregistration/not_found?id={}&message={}', model_id, error) + ajax_or_redirect(func, f'../preregistration/not_found?id={model_id}&message=', error) # Admin account override if session.admin_attendee_max_access(attendee): @@ -216,8 +216,8 @@ def protected(*args, **kwargs): if message: if admin_account_id: - raise HTTPRedirect('../accounts/homepage?message={}'.format(message)) - raise HTTPRedirect('../landing/index?message={}'.format(message), save_location=True) + ajax_or_redirect(func, '../accounts/homepage?message=', message) + ajax_or_redirect(func, '../landing/index?message=', message, True) return func(*args, **kwargs) return protected return model_requires_account @@ -234,7 +234,7 @@ def _protected(*args, **kwargs): with uber.models.Session() as session: message = check_can_edit_dept(session, department_id, inherent_role, override_access) if message: - raise HTTPRedirect('../accounts/homepage?message={}'.format(message), save_location=True) + ajax_or_redirect(func, '../accounts/homepage?message=', message, True) return func(*args, **kwargs) return _protected @@ -411,7 +411,7 @@ def check_shutdown(func): @wraps(func) def with_check(self, *args, **kwargs): if c.UBER_SHUT_DOWN: - raise HTTPRedirect('index?message={}', 'The page you requested is only available pre-event.') + ajax_or_redirect(func, 'index?message=', 'The page you requested is only available pre-event.') else: return func(self, *args, **kwargs) return with_check @@ -643,12 +643,12 @@ def with_rendering(*args, **kwargs): message = "Your CSRF token is invalid. Please go back and try again." uber.server.log_exception_with_verbose_context(msg=str(e)) if not c.DEV_BOX: - raise HTTPRedirect("../landing/invalid?message={}", message) + ajax_or_redirect(func, '../landing/invalid?message=', message) except (AssertionError, ValueError) as e: message = str(e) uber.server.log_exception_with_verbose_context(msg=message) if not c.DEV_BOX: - raise HTTPRedirect("../landing/invalid?message={}", message) + ajax_or_redirect(func, '../landing/invalid?message=', message) except TypeError as e: # Very restrictive pattern so we don't accidentally match legit errors pattern = r"^{}\(\) missing 1 required positional argument: '\S*?id'$".format(func.__name__) @@ -657,7 +657,7 @@ def with_rendering(*args, **kwargs): message = 'Looks like you tried to access a page without all the query parameters. '\ 'Please go back and try again.' if not c.DEV_BOX: - raise HTTPRedirect("../landing/invalid?message={}", message) + ajax_or_redirect(func, '../landing/invalid?message=', message) else: raise @@ -692,24 +692,23 @@ def streamable(func): func._cp_config['response.stream'] = True return func + def public(func): func.public = True return func -def any_admin_access(func): - func.public = True +def kiosk_login(login_url='index'): + def kiosk_decorator(func): + func.kiosk_login = login_url + return func + + return kiosk_decorator - @wraps(func) - def with_check(*args, **kwargs): - if cherrypy.session.get('account_id') is None: - raise HTTPRedirect('../accounts/login?message=You+are+not+logged+in', save_location=True) - with uber.models.Session() as session: - account = session.admin_account(cherrypy.session.get('account_id')) - if not account.access_groups: - return "You do not have any admin accesses." - return func(*args, **kwargs) - return with_check + +def any_admin_access(func): + func.any_admin_access = True + return func def attendee_view(func): @@ -718,7 +717,7 @@ def attendee_view(func): @wraps(func) def with_check(*args, **kwargs): if cherrypy.session.get('account_id') is None: - raise HTTPRedirect('../accounts/login?message=You+are+not+logged+in', save_location=True) + ajax_or_redirect(func, '../accounts/login?message=', "You are not logged in.", True) if kwargs.get('id') and str(kwargs.get('id')) != "None": with uber.models.Session() as session: @@ -743,24 +742,49 @@ def with_check(*args, **kwargs): return with_check +def ajax_or_redirect(func, redirect_url, message, save_location=False): + # Make sure redirect_url ends with 'message=' so we can append the message + + if getattr(func, 'ajax', None): + return json.dumps({'success': False, 'message': message, 'error': message}, cls=serializer).encode('utf-8') + raise HTTPRedirect(redirect_url + '{}', message, save_location=save_location) + + def restricted(func): @wraps(func) def with_restrictions(*args, **kwargs): - if not func.public: - if '/staffing/' in c.PAGE_PATH: - if not cherrypy.session.get('staffer_id'): - raise HTTPRedirect('../staffing/login?message=You+are+not+logged+in', save_location=True) + if func.public: + return func(*args, **kwargs) - elif cherrypy.session.get('account_id') is None: - raise HTTPRedirect('../accounts/login?message=You+are+not+logged+in', save_location=True) + if '/staffing/' in c.PAGE_PATH: + if not cherrypy.session.get('staffer_id'): + ajax_or_redirect(func, '../staffing/login?message=', "You are not logged in.", True) - elif '/mivs_judging/' in c.PAGE_PATH: - if not uber.models.AdminAccount.is_mivs_judge_or_admin: - return f'You need to be a MIVS Judge or have access to {c.PAGE_PATH}' + elif cherrypy.session.get('account_id') is None: + if getattr(func, 'kiosk_login', None): + if not cherrypy.session.get('kiosk_supervisor_id'): + cherrypy.session.pop('kiosk_operator_id', None) + ajax_or_redirect(func, '../accounts/login?message=', + "Session timed out. Please have your supervisor log in.", True) + if not cherrypy.session.get('kiosk_operator_id') and cherrypy.request.method == 'POST': + ajax_or_redirect(func, f'{func.kiosk_login}?message=', + "Please enter your badge number to log into the kiosk.") else: - if not c.has_section_or_page_access(include_read_only=True): - return f'You need access to {c.PAGE_PATH}.' + ajax_or_redirect(func, '../accounts/login?message=', "You are not logged in.", True) + + elif '/mivs_judging/' in c.PAGE_PATH: + if not uber.models.AdminAccount.is_mivs_judge_or_admin: + return f'You need to be a MIVS Judge or have access to {c.PAGE_PATH}' + + elif getattr(func, 'any_admin_access', None): + with uber.models.Session() as session: + account = session.admin_account(cherrypy.session.get('account_id')) + if not account.access_groups: + return "You do not have any admin accesses." + else: + if not c.has_section_or_page_access(include_read_only=True): + return f'You need access to {c.PAGE_PATH}.' return func(*args, **kwargs) return with_restrictions @@ -915,7 +939,7 @@ def model_id_required(func): def check_id(*args, **params): error, model_id = check_id_for_model(model=model, **params) if error: - raise HTTPRedirect('../preregistration/not_found?id={}&message={}', model_id, error) + ajax_or_redirect(func, f'../preregistration/not_found?id={model_id}&message=', error) return func(*args, **params) return check_id return model_id_required diff --git a/uber/forms/attendee.py b/uber/forms/attendee.py index 6c73a4145..eeef488a1 100644 --- a/uber/forms/attendee.py +++ b/uber/forms/attendee.py @@ -133,7 +133,7 @@ def get_optional_fields(self, attendee, is_admin=False): def get_non_admin_locked_fields(self, attendee): locked_fields = [] - if attendee.is_new or attendee.badge_status in [c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS]: + if attendee.is_new or attendee.badge_status == c.PENDING_STATUS or attendee.paid == c.PENDING: return locked_fields elif not attendee.is_valid or attendee.badge_status == c.REFUNDED_STATUS: return list(self._fields.keys()) @@ -484,7 +484,7 @@ class BadgeAdminNotes(MagForm): class CheckInForm(MagForm): - field_validation = CustomValidation() + field_validation, new_or_changed_validation = CustomValidation(), CustomValidation() full_name = HiddenField('Name') legal_name = HiddenField('Name on ID') @@ -509,6 +509,19 @@ def get_optional_fields(self, attendee, is_admin=False): return optional_list + @new_or_changed_validation.badge_num + def dupe_badge_num(form, field): + existing_name = '' + if c.NUMBERED_BADGES and field.data \ + and (not c.SHIFT_CUSTOM_BADGES or c.AFTER_PRINTED_BADGE_DEADLINE or c.AT_THE_CON): + with Session() as session: + existing = session.query(Attendee).filter_by(badge_num=field.data) + if not existing.count(): + return + else: + existing_name = existing.first().full_name + raise ValidationError('That badge number already belongs to {!r}'.format(existing_name)) + @field_validation.birthdate def birthdate_format(form, field): return PersonalInfo.birthdate_format(form, field) diff --git a/uber/menu.py b/uber/menu.py index fcc80858b..48a38becc 100644 --- a/uber/menu.py +++ b/uber/menu.py @@ -174,5 +174,4 @@ def get_external_schedule_menu_name(): MenuItem(name='Link to Apply', href='../art_show_applications/', access_override='art_show_admin'), MenuItem(name='At-Con Operations', href='../art_show_admin/ops'), MenuItem(name='Reports', href='../art_show_reports/index'), - MenuItem(name='Sales Charge Form', href='../art_show_admin/sales_charge_form'), ])) diff --git a/uber/model_checks.py b/uber/model_checks.py index a28f20239..6e1532573 100644 --- a/uber/model_checks.py +++ b/uber/model_checks.py @@ -1092,7 +1092,7 @@ def no_more_child_badges(attendee): @prereg_validation.Attendee def child_badge_over_13(attendee): - if not attendee.is_new and attendee.badge_status not in [c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS] \ + if not attendee.is_new and attendee.badge_status != c.PENDING_STATUS \ or attendee.unassigned_group_reg or attendee.valid_placeholder: return @@ -1105,7 +1105,7 @@ def child_badge_over_13(attendee): @prereg_validation.Attendee def attendee_badge_under_13(attendee): - if not attendee.is_new and attendee.badge_status not in [c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS] \ + if not attendee.is_new and attendee.badge_status != c.PENDING_STATUS \ or attendee.unassigned_group_reg or attendee.valid_placeholder: return diff --git a/uber/models/__init__.py b/uber/models/__init__.py index 4adfa2874..70b791b9e 100644 --- a/uber/models/__init__.py +++ b/uber/models/__init__.py @@ -25,7 +25,7 @@ from sqlalchemy.event import listen from sqlalchemy.exc import IntegrityError, NoResultFound from sqlalchemy.ext.mutable import MutableDict -from sqlalchemy.orm import Query, joinedload, subqueryload, aliased +from sqlalchemy.orm import Query, joinedload, subqueryload from sqlalchemy.orm.attributes import get_history, instance_state from sqlalchemy.schema import MetaData from sqlalchemy.types import Boolean, Integer, Float, Date, Numeric @@ -514,7 +514,10 @@ def coerce_column_data(self, column, value): value = datetime.strptime(value, c.DATE_FORMAT) except ValueError: value = dateparser.parse(value) - return value.date() + try: + return value.date() + except AttributeError: + return value elif isinstance(column.type, JSONB): if isinstance(value, str): @@ -749,6 +752,10 @@ class SessionMixin: def current_admin_account(self): if getattr(cherrypy, 'session', {}).get('account_id'): return self.admin_account(cherrypy.session.get('account_id')) + + def current_supervisor_admin(self): + if getattr(cherrypy, 'session', {}).get('kiosk_supervisor_id'): + return self.admin_account(cherrypy.session.get('kiosk_supervisor_id')) def admin_attendee(self): if getattr(cherrypy, 'session', {}).get('account_id'): @@ -756,6 +763,13 @@ def admin_attendee(self): return self.admin_account(cherrypy.session.get('account_id')).attendee except NoResultFound: return + + def kiosk_operator_attendee(self): + if self.current_supervisor_admin and getattr(cherrypy, 'session', {}).get('kiosk_operator_id'): + try: + return self.attendee(cherrypy.session.get('kiosk_operator_id')) + except NoResultFound: + return def current_attendee_account(self): if c.ATTENDEE_ACCOUNTS_ENABLED and getattr(cherrypy, 'session', {}).get('attendee_account_id'): @@ -1172,10 +1186,12 @@ def get_assigned_terminal_id(self): return "", c.TERMINAL_ID_TABLE[lookup_key] - def get_receipt_by_model(self, model, include_closed=False, who='', create_if_none=""): + def get_receipt_by_model(self, model, include_closed=False, who='', create_if_none="", options=[]): receipt_select = self.query(ModelReceipt).filter_by(owner_id=model.id, owner_model=model.__class__.__name__) if not include_closed: receipt_select = receipt_select.filter(ModelReceipt.closed == None) # noqa: E711 + if options: + receipt_select = receipt_select.options(*options) receipt = receipt_select.first() if not receipt and create_if_none: @@ -1224,6 +1240,11 @@ def refresh_receipt_and_model(self, model, is_prereg=False): pass return receipt + + def check_receipt_closed(self, receipt): + self.refresh(receipt) + if receipt.current_receipt_amount == 0: + receipt.close_all_items(self) def get_terminal_settlements(self): from uber.models import TerminalSettlement @@ -1693,7 +1714,7 @@ def all_attendees(self, only_staffing=False, pending=False): badge_statuses = [c.NEW_STATUS, c.COMPLETED_STATUS] if pending: - badge_statuses.extend([c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS]) + badge_statuses.append(c.PENDING_STATUS) badge_filter = Attendee.badge_status.in_(badge_statuses) @@ -1798,33 +1819,21 @@ def parse_attr_search_terms(self, search_text): def index_attendees(self): # Returns a base attendee query with extra joins for the index page - attendees = self.query(Attendee).outerjoin(Attendee.group) \ - .outerjoin(Attendee.promo_code) \ - .outerjoin(PromoCodeGroup, PromoCode.group) \ - .options( - joinedload(Attendee.group), - joinedload(Attendee.promo_code).joinedload(PromoCode.group) - ) + attendees = self.query(Attendee).outerjoin(Group, + Attendee.group_id == Group.id + ).outerjoin(BadgePickupGroup + ).outerjoin(PromoCode).outerjoin(PromoCodeGroup) if c.ATTENDEE_ACCOUNTS_ENABLED: - attendees = attendees.outerjoin(Attendee.managers).options(joinedload(Attendee.managers)) + attendees = attendees.outerjoin(AttendeeAccount, Attendee.managers) return attendees def search(self, text, *filters): - # We need to both outerjoin on the PromoCodeGroup table and also - # query it. In order to do this we need to alias it so that the - # reference to PromoCodeGroup in the joinedload doesn't conflict - # with the outerjoin. See https://docs.sqlalchemy.org/en/13/orm/query.html#sqlalchemy.orm.query.Query.join - aliased_pcg = aliased(PromoCodeGroup) - - attendees = self.query(Attendee).outerjoin(Attendee.group) \ - .outerjoin(Attendee.promo_code) \ - .outerjoin(aliased_pcg, PromoCode.group) \ - .options( - joinedload(Attendee.group), - joinedload(Attendee.promo_code).joinedload(PromoCode.group) - ) + attendees = self.query(Attendee).outerjoin(Group, + Attendee.group_id == Group.id + ).outerjoin(BadgePickupGroup + ).outerjoin(PromoCode).outerjoin(PromoCodeGroup) if c.ATTENDEE_ACCOUNTS_ENABLED: - attendees = attendees.outerjoin(Attendee.managers).options(joinedload(Attendee.managers)) + attendees = attendees.outerjoin(AttendeeAccount, Attendee.managers) attendees = attendees.filter(*filters) @@ -1861,9 +1870,11 @@ def search(self, text, *filters): id_list = [ Attendee.id == terms[0], Attendee.public_id == terms[0], - aliased_pcg.id == terms[0], + PromoCodeGroup.id == terms[0], Group.id == terms[0], - Group.public_id == terms[0]] + Group.public_id == terms[0], + BadgePickupGroup.id == terms[0], + BadgePickupGroup.public_id == terms[0]] if c.ATTENDEE_ACCOUNTS_ENABLED: id_list.extend([AttendeeAccount.id == terms[0], AttendeeAccount.public_id == terms[0]]) @@ -1883,7 +1894,7 @@ def search(self, text, *filters): def check_text_fields(search_text): check_list = [ Group.name.ilike('%' + search_text + '%'), - aliased_pcg.name.ilike('%' + search_text + '%'), + PromoCodeGroup.name.ilike('%' + search_text + '%'), ] if c.ATTENDEE_ACCOUNTS_ENABLED: diff --git a/uber/models/admin.py b/uber/models/admin.py index cbbc8caa1..eea95ff62 100644 --- a/uber/models/admin.py +++ b/uber/models/admin.py @@ -4,6 +4,7 @@ from pockets import classproperty, listify from pytz import UTC from residue import CoerceUTF8 as UnicodeText, UTCDateTime, UUID +from sqlalchemy import Sequence from sqlalchemy.dialects.postgresql.json import JSONB from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.orm import backref @@ -16,7 +17,7 @@ from uber.models.types import default_relationship as relationship, utcnow, DefaultColumn as Column -__all__ = ['AccessGroup', 'AdminAccount', 'PasswordReset', 'WatchList', 'WorkstationAssignment'] +__all__ = ['AccessGroup', 'AdminAccount', 'EscalationTicket', 'PasswordReset', 'WatchList', 'WorkstationAssignment'] # Many to many association table to tie Access Groups with Admin Accounts @@ -63,6 +64,31 @@ def admin_name(): return session.admin_attendee().full_name except Exception: return None + + @staticmethod + def admin_or_volunteer_name(): + try: + from uber.models import Session + with Session() as session: + admin = session.admin_attendee() + volunteer = session.kiosk_operator_attendee() + if volunteer and not admin: + return volunteer.full_name + " (Volunteer)" + elif not admin: + return session.current_supervisor_admin().attendee.full_name + else: + return admin.full_name + except Exception: + return None + + @staticmethod + def supervisor_name(): + try: + from uber.models import Session + with Session() as session: + return session.current_supervisor_admin().attendee.full_name + except Exception: + return None @staticmethod def admin_email(): @@ -316,7 +342,7 @@ class WatchList(MagModel): action = Column(UnicodeText) expiration = Column(Date, nullable=True, default=None) active = Column(Boolean, default=True) - attendees = relationship('Attendee', backref=backref('watch_list', load_on_pending=True)) + attendees = relationship('Attendee', backref=backref('watch_list'), cascade='save-update,merge,refresh-expire,expunge') @property def full_name(self): @@ -332,6 +358,34 @@ def fix_birthdate(self): self.birthdate = None +# Many to many association table to tie Attendees to Escalation Tickets +attendee_escalation_ticket = Table( + 'attendee_escalation_ticket', + MagModel.metadata, + Column('attendee_id', UUID, ForeignKey('attendee.id')), + Column('escalation_ticket_id', UUID, ForeignKey('escalation_ticket.id')), + UniqueConstraint('attendee_id', 'escalation_ticket_id'), + Index('ix_attendee_escalation_ticket_attendee_id', 'attendee_id'), + Index('ix_attendee_escalation_ticket_escalation_ticket_id', 'escalation_ticket_id'), +) + + +class EscalationTicket(MagModel): + attendees = relationship( + 'Attendee', backref='escalation_tickets', order_by='Attendee.full_name', + cascade='save-update,merge,refresh-expire,expunge', + secondary='attendee_escalation_ticket') + ticket_id_seq = Sequence('escalation_ticket_ticket_id_seq') + ticket_id = Column(Integer, ticket_id_seq, server_default=ticket_id_seq.next_value(), unique=True) + description = Column(UnicodeText) + admin_notes = Column(UnicodeText) + resolved = Column(UTCDateTime, nullable=True) + + @property + def attendee_names(self): + return [a.full_name for a in self.attendees] + + class WorkstationAssignment(MagModel): reg_station_id = Column(Integer) printer_id = Column(UnicodeText) diff --git a/uber/models/art_show.py b/uber/models/art_show.py index c37e3ebce..7343c29ce 100644 --- a/uber/models/art_show.py +++ b/uber/models/art_show.py @@ -1,6 +1,7 @@ import random import string +from collections import defaultdict from pockets import classproperty from sqlalchemy import func, case from datetime import datetime @@ -123,7 +124,7 @@ def generate_artist_id(self, banner_name): # 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(ArtShowApplication.artist_id).all()) + a for tup in session.query(ArtShowApplication.artist_id, ArtShowApplication.artist_id_ad).all() for a in tup) code_candidate = self._get_code_from_name(banner_name, old_codes) \ or self._get_code_from_name(self.artist_name, old_codes) \ @@ -457,7 +458,7 @@ def add_invoice_num(self): def subtotal(self): cost = 0 for piece in self.pieces: - cost += piece.sale_price * 100 + cost += piece.sale_price * 100 if piece.sale_price else 0 return cost @property @@ -503,6 +504,9 @@ class ArtShowBidder(MagModel): hotel_room_num = Column(UnicodeText) admin_notes = Column(UnicodeText) signed_up = Column(UTCDateTime, nullable=True) + email_won_bids = Column(Boolean, default=False) + + email_model_name = 'bidder' @presave_adjustment def zfill_bidder_num(self): @@ -513,6 +517,8 @@ def zfill_bidder_num(self): @classmethod def strip_bidder_num(cls, num): + if not num: + return 0 return int(num[2:]) @hybrid_property @@ -523,6 +529,19 @@ def bidder_num_stripped(self): def bidder_num_stripped(cls): return func.cast("0" + func.substr(cls.bidder_num, 3, func.length(cls.bidder_num)), Integer) + @property + def email(self): + if self.attendee: + return self.attendee.email + + @property + def won_pieces_by_gallery(self): + pieces_dict = defaultdict(list) + for piece in sorted(self.art_show_pieces, key=lambda p: p.artist_and_piece_id): + if piece.winning_bid and piece.status == c.SOLD: + pieces_dict[piece.gallery].append(piece) + return pieces_dict + @classproperty def required_fields(cls): # Override for independent art shows to force attendee fields to be filled out diff --git a/uber/models/attendee.py b/uber/models/attendee.py index 8e29a89c4..a0cc420ef 100644 --- a/uber/models/attendee.py +++ b/uber/models/attendee.py @@ -29,7 +29,7 @@ localized_now, mask_string, normalize_email, normalize_email_legacy, remove_opt -__all__ = ['Attendee', 'AttendeeAccount', 'FoodRestrictions'] +__all__ = ['Attendee', 'AttendeeAccount', 'BadgePickupGroup', 'FoodRestrictions'] RE_NONDIGIT = re.compile(r'\D+') @@ -152,6 +152,11 @@ class Attendee(MagModel, TakesPaymentMixin): group_id = Column(UUID, ForeignKey('group.id', ondelete='SET NULL'), nullable=True) group = relationship( Group, backref='attendees', foreign_keys=group_id, cascade='save-update,merge,refresh-expire,expunge') + + badge_pickup_group_id = Column(UUID, ForeignKey('badge_pickup_group.id', ondelete='SET NULL'), nullable=True) + badge_pickup_group = relationship( + 'BadgePickupGroup', backref=backref('attendees', order_by='Attendee.full_name'), foreign_keys=badge_pickup_group_id, + cascade='save-update,merge,refresh-expire,expunge', single_parent=True) creator_id = Column(UUID, ForeignKey('attendee.id'), nullable=True) creator = relationship( @@ -538,7 +543,7 @@ def _status_adjustments(self): if self.badge_status == c.WATCHED_STATUS and not self.banned: self.badge_status = c.NEW_STATUS - if self.badge_status in [c.NEW_STATUS, c.AT_DOOR_PENDING_STATUS] and self.banned: + if self.badge_status == c.NEW_STATUS and self.banned: self.badge_status = c.WATCHED_STATUS try: uber.tasks.email.send_email.delay( @@ -557,6 +562,9 @@ def _status_adjustments(self): and not self.group.is_unpaid): self.badge_status = c.COMPLETED_STATUS + if not self.has_or_will_have_badge: + self.badge_pickup_group = None + @presave_adjustment def _staffing_adjustments(self): if self.is_dept_head: @@ -591,6 +599,8 @@ def staffing_badge_and_ribbon_adjustments(self): @presave_adjustment def _badge_adjustments(self): from uber.badge_funcs import needs_badge_num + from uber.tasks.registration import assign_badge_num + if self.badge_type == c.PSEUDO_DEALER_BADGE: self.ribbon = add_opt(self.ribbon_ints, c.DEALER_RIBBON) @@ -602,7 +612,7 @@ def _badge_adjustments(self): if old_type != self.badge_type or old_num != self.badge_num: self.session.update_badge(self, old_type, old_num) elif needs_badge_num(self) and not self.badge_num: - self.badge_num = self.session.get_next_badge_num(self.badge_type) + assign_badge_num.delay(self.id) @presave_adjustment def _use_promo_code(self): @@ -619,7 +629,7 @@ def refunded_if_receipt_has_refund(self): @presave_adjustment def update_default_cost(self): - if self.is_valid or self.badge_status in [c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS]: + if self.is_valid or self.badge_status == c.PENDING_STATUS: self.default_cost = self.calc_default_cost() @hybrid_property @@ -769,8 +779,11 @@ def cannot_edit_badge_status_reason(self): full_reg_admin = bool(session.current_admin_account().full_registration_admin) if c.ADMIN_BADGES_NEED_APPROVAL and not full_reg_admin and self.badge_status == c.PENDING_STATUS: return "This badge must be approved by an admin." - if self.badge_status == c.WATCHED_STATUS and not c.HAS_SECURITY_ADMIN_ACCESS: - return "Please escalate this case to someone with access to the watchlist." + if self.badge_status == c.WATCHED_STATUS: + if not c.HAS_SECURITY_ADMIN_ACCESS: + return "Please escalate this case to someone with access to the watchlist." + else: + return "Please view this person's watchlist entry to clear them for check-in." if (c.AT_THE_CON or c.BADGE_PICKUP_ENABLED) and not c.HAS_REG_ADMIN_ACCESS: return "Altering the badge status is disabled during the event. The system will update it automatically." return '' @@ -945,11 +958,6 @@ def calculate_shipping_fee_cost(self): # Also so we can display the potential shipping fee cost to attendees return c.MERCH_SHIPPING_FEE - @property - def in_reg_cart_group(self): - if c.ATTENDEE_ACCOUNTS_ENABLED and self.managers: - return self.badge_status == c.AT_DOOR_PENDING_STATUS and len(self.managers[0].at_door_attendees) > 1 - @property def has_at_con_payments(self): return self.active_receipt.has_at_con_payments if self.active_receipt else False @@ -1051,12 +1059,12 @@ def valid_placeholder(self): @hybrid_property def is_valid(self): - return self.badge_status not in [c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS, c.INVALID_STATUS, + return self.badge_status not in [c.PENDING_STATUS, c.INVALID_STATUS, c.IMPORTED_STATUS, c.INVALID_GROUP_STATUS, c.REFUNDED_STATUS] @is_valid.expression def is_valid(cls): - return not_(cls.badge_status.in_([c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS, c.INVALID_STATUS, + return not_(cls.badge_status.in_([c.PENDING_STATUS, c.INVALID_STATUS, c.IMPORTED_STATUS, c.INVALID_GROUP_STATUS, c.REFUNDED_STATUS])) @hybrid_property @@ -1128,10 +1136,10 @@ def cannot_check_in_reason(self): if self.badge_status == c.WATCHED_STATUS: if self.banned or not self.regdesk_info: regdesk_info_append = " [{}]".format(self.regdesk_info) if self.regdesk_info else "" - return "MUST TALK TO SECURITY before picking up badge{}".format(regdesk_info_append) + return "MUST TALK TO MANAGER before picking up badge{}".format(regdesk_info_append) return self.regdesk_info or "Badge status is {}".format(self.badge_status_label) - if self.badge_status not in [c.COMPLETED_STATUS, c.NEW_STATUS, c.AT_DOOR_PENDING_STATUS]: + if self.badge_status not in [c.COMPLETED_STATUS, c.NEW_STATUS]: return "Badge status is {}".format(self.badge_status_label) if self.group and self.paid == c.PAID_BY_GROUP and self.group.is_dealer \ @@ -1150,6 +1158,9 @@ def cannot_check_in_reason(self): if self.is_presold_oneday: if self.badge_type_label != localized_now().strftime('%A'): return "Wrong day" + + if self.active_escalation_tickets: + return "Must be cleared for check-in by manager" message = uber.utils.check(self) return message @@ -1603,6 +1614,10 @@ def check_in_notes(self): notes.append(f"Please check this attendee in {self.accoutrements}.") return "

".join(notes) + + @property + def active_escalation_tickets(self): + return [ticket for ticket in self.escalation_tickets if ticket.resolved == None] @property def multiply_assigned(self): @@ -2208,6 +2223,18 @@ def attraction_features(self): @property def attractions(self): return list({e.feature.attraction for e in self.attraction_events}) + + @property + def name_and_badge_info(self): + return f'{self.badge_or_masked_name} ({self.badge})' + + @property + def badge_or_masked_name(self): + return self.badge_printed_name or self.masked_name + + @property + def logged_in_name(self): + return self.full_name @property def masked_name(self): @@ -2355,20 +2382,15 @@ def pending_attendees(self): return [attendee for attendee in self.attendees if attendee.badge_status == c.PENDING_STATUS] @property - def at_door_attendees(self): - return sorted([attendee for attendee in self.attendees if attendee.badge_status == c.AT_DOOR_PENDING_STATUS], - key=lambda a: a.first_name) - - @property - def at_door_under_18s(self): - return sorted([attendee for attendee in self.attendees if attendee.badge_status == c.AT_DOOR_PENDING_STATUS - and attendee.age_now_or_at_con < 18], - key=lambda a: a.first_name) + def at_door_pending_attendees(self): + return sorted([attendee for attendee in self.attendees if + attendee.badge_status == c.NEW_STATUS and attendee.paid == c.PENDING], + key=lambda a: a.first_name) @property def invalid_attendees(self): return [attendee for attendee in self.attendees if not attendee.is_valid and - attendee.badge_status not in [c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS]] + attendee.badge_status != c.PENDING_STATUS] @property def refunded_deferred_attendees(self): @@ -2376,6 +2398,33 @@ def refunded_deferred_attendees(self): if attendee.badge_status in [c.REFUNDED_STATUS, c.DEFERRED_STATUS]] +class BadgePickupGroup(MagModel): + public_id = Column(UUID, default=lambda: str(uuid4()), nullable=True) + account_id = Column(UnicodeText) + + def build_from_account(self, account): + for attendee in account.attendees: + if attendee.has_badge: + self.attendees.append(attendee) + + @property + def pending_paid_attendees(self): + return [attendee for attendee in self.attendees if attendee.paid == c.PENDING and + not attendee.checked_in and not attendee.cannot_check_in_reason] + + @property + def checked_in_attendees(self): + return [attendee for attendee in self.attendees if attendee.checked_in] + + @property + def check_inable_attendees(self): + return [attendee for attendee in self.attendees if not attendee.checked_in and not attendee.cannot_check_in_reason] + + @property + def under_18_badges(self): + return [attendee for attendee in self.check_inable_attendees if attendee.age_now_or_at_con < 18] + + class FoodRestrictions(MagModel): attendee_id = Column(UUID, ForeignKey('attendee.id'), unique=True) standard = Column(MultiChoice(c.FOOD_RESTRICTION_OPTS)) diff --git a/uber/models/commerce.py b/uber/models/commerce.py index 4c49198f9..e8ffdabac 100644 --- a/uber/models/commerce.py +++ b/uber/models/commerce.py @@ -100,6 +100,22 @@ class ModelReceipt(MagModel): owner_model = Column(UnicodeText) closed = Column(UTCDateTime, nullable=True) + def close_all_items(self, session): + for item in self.open_receipt_items: + if item.receipt_txn: + item.closed = item.receipt_txn.added + else: + if item.amount < 0: + latest_txn = self.sorted_txns[-1] + else: + latest_txn = sorted([txn for txn in self.receipt_txns if txn.amount > 0], + key=lambda x: x.added, reverse=True)[0] + + item.receipt_txn = latest_txn + item.closed = datetime.now() + session.add(item) + session.commit() + @property def all_sorted_items_and_txns(self): return sorted(self.receipt_items + self.receipt_txns, key=lambda x: x.added) @@ -125,12 +141,16 @@ def open_receipt_items(self): return [item for item in self.receipt_items if not item.closed] @property - def closed_receipt_items(self): - return [item for item in self.receipt_items if item.closed] + def open_purchase_items(self): + return [item for item in self.open_receipt_items if item.amount >= 0] + + @property + def open_credit_items(self): + return [item for item in self.open_receipt_items if item.amount < 0] @property def charge_description_list(self): - return ", ".join([item.desc + " x" + str(item.count) for item in self.open_receipt_items if item.amount > 0]) + return ", ".join([item.desc + " x" + str(item.count) for item in self.open_purchase_items]) @property def cancelled_txns(self): @@ -268,7 +288,7 @@ class ReceiptTransaction(MagModel): Stripe payments will start with an `intent_id` and, if completed, have a `charge_id` set. Stripe refunds will have a `refund_id`. - Stripe payments will track how much has been refunded for that transaction with `refunded` -- + Stripe payments will track how much has been refunded for that transaction with `refunded` return this is an important number to track because it helps prevent refund errors. All payments keep a list of `receipt_items`. This lets admins track what has been paid for already, @@ -283,6 +303,13 @@ class ReceiptTransaction(MagModel): receipt_info = relationship('ReceiptInfo', foreign_keys=receipt_info_id, cascade='save-update, merge', backref=backref('receipt_txns', cascade='save-update, merge')) + refunded_txn_id = Column(UUID, ForeignKey('receipt_transaction.id', ondelete='SET NULL'), nullable=True) + refunded_txn = relationship('ReceiptTransaction', foreign_keys='ReceiptTransaction.refunded_txn_id', + backref=backref('refund_txns', order_by='ReceiptTransaction.added'), + cascade='save-update,merge,refresh-expire,expunge', + remote_side='ReceiptTransaction.id', + single_parent=True) + refunded = Column(Integer, default=0) intent_id = Column(UnicodeText) charge_id = Column(UnicodeText) refund_id = Column(UnicodeText) @@ -291,7 +318,6 @@ class ReceiptTransaction(MagModel): amount = Column(Integer) txn_total = Column(Integer, default=0) processing_fee = Column(Integer, default=0) - refunded = Column(Integer, default=0) added = Column(UTCDateTime, default=lambda: datetime.now(UTC)) cancelled = Column(UTCDateTime, nullable=True) who = Column(UnicodeText) @@ -316,7 +342,7 @@ def available_actions(self): if not self.stripe_id: actions.append('remove_receipt_item') - if self.receipt_info: + if self.receipt_info and self.receipt_info.fk_email_id: actions.append('resend_receipt') return actions @@ -483,7 +509,6 @@ class ReceiptItem(MagModel): receipt_txn = relationship('ReceiptTransaction', foreign_keys=txn_id, cascade='save-update, merge', backref=backref('receipt_items', cascade='save-update, merge')) - # TODO: Add a view-only "refund_txns" relationship to ReceiptTransaction so credit items can track their associated refunds fk_id = Column(UUID, index=True, nullable=True) fk_model = Column(UnicodeText) department = Column(Choice(c.RECEIPT_ITEM_DEPT_OPTS), default=c.OTHER_RECEIPT_ITEM) @@ -496,6 +521,7 @@ class ReceiptItem(MagModel): closed = Column(UTCDateTime, nullable=True) who = Column(UnicodeText) desc = Column(UnicodeText) + admin_notes = Column(UnicodeText) revert_change = Column(JSON, default={}, server_default='{}') @property @@ -507,6 +533,16 @@ def paid(self): if not self.closed: return return self.receipt_txn.added and self.receipt_txn.amount > 0 + + @property + def closed_type(self): + if not self.closed: + return "" + if self.amount > 0 and self.receipt_txn.amount > 0: + return "Paid" + if self.amount < 0 and self.receipt_txn.amount < 0: + return "Refunded" + return "Closed" @property def available_actions(self): @@ -547,11 +583,120 @@ class ReceiptInfo(MagModel): charged = Column(UTCDateTime) voided = Column(UTCDateTime, nullable=True) card_data = Column(MutableDict.as_mutable(JSONB), default={}) - txn_info = Column(MutableDict.as_mutable(JSONB), default={}) emv_data = Column(MutableDict.as_mutable(JSONB), default={}) + txn_info = Column(MutableDict.as_mutable(JSONB), default={}) signature = Column(UnicodeText) receipt_html = Column(UnicodeText) + @property + def response_code_str(self): + if not self.txn_info or not self.txn_info['response']: + return '' + + if self.receipt_txns[0].method == c.STRIPE and c.AUTHORIZENET_LOGIN_ID: + match self.txn_info['response'].get('response_code', ''): + case '1': + return 'Approved' + case '2': + return 'Declined' + case '3': + return 'Error' + case '4': + return 'Held for Review' + case _: + return '' + + @property + def avs_str(self): + if not self.txn_info or not self.txn_info['fraud_info']: + log.error(self.txn_info['fraud_info']) + return '' + + if self.receipt_txns[0].method == c.STRIPE and c.AUTHORIZENET_LOGIN_ID: + match self.txn_info['fraud_info'].get('avs', ''): + case 'A': + return "The street address matched, but the postal code did not." + case 'B': + return "No address information was provided." + case 'E': + return "The AVS check returned an error." + case 'G': + return "The card was issued by a bank outside the U.S. and does not support AVS." + case 'N': + return "Neither the street address nor postal code matched." + case 'P': + return "AVS is not applicable for this transaction." + case 'R': + return "Retry — AVS was unavailable or timed out." + case 'S': + return "AVS is not supported by card issuer." + case 'U': + return "Address information is unavailable." + case 'W': + return "The US ZIP+4 code matches, but the street address does not." + case 'X': + return "Both the street address and the US ZIP+4 code matched." + case 'Y': + return "The street address and postal code matched." + case 'Z': + return "The postal code matched, but the street address did not." + case _: + return '' + + @property + def cvv_str(self): + if not self.txn_info or not self.txn_info['fraud_info']: + return '' + + if self.receipt_txns[0].method == c.STRIPE and c.AUTHORIZENET_LOGIN_ID: + match self.txn_info['fraud_info'].get('cvv', ''): + case 'M': + return "CVV matched." + case 'N': + return "CVV did not match." + case 'P': + return "CVV was not processed." + case 'S': + return "CVV should have been present but was not indicated." + case 'U': + return "The issuer was unable to process the CVV check." + case _: + return '' + + @property + def cavv_str(self): + if not self.txn_info or not self.txn_info['fraud_info']: + return '' + + if self.receipt_txns[0].method == c.STRIPE and c.AUTHORIZENET_LOGIN_ID: + match self.txn_info['fraud_info'].get('cavv', ''): + case '0': + return "CAVV was not validated because erroneous data was submitted." + case '1': + return "CAVV failed validation." + case '2': + return "CAVV passed validation." + case '3': + return "CAVV validation could not be performed; issuer attempt incomplete." + case '4': + return "CAVV validation could not be performed; issuer system error." + case '5': + return "Reserved for future use." + case '6': + return "Reserved for future use." + case '7': + return "CAVV failed validation, but the issuer is available. Valid for U.S.-issued card submitted to non-U.S acquirer." + case '8': + return "CAVV passed validation and the issuer is available. Valid for U.S.-issued card submitted to non-U.S. acquirer." + case '9': + return "CAVV failed validation and the issuer is unavailable. Valid for U.S.-issued card submitted to non-U.S acquirer." + case 'A': + return "CAVV passed validation but the issuer unavailable. Valid for U.S.-issued card submitted to non-U.S acquirer." + case 'B': + return "CAVV passed validation, information only, no liability shift." + case _: + return "CAVV not validated." + class TerminalSettlement(MagModel): batch_timestamp = Column(UnicodeText) diff --git a/uber/models/tracking.py b/uber/models/tracking.py index e94273f2f..11e33e6ee 100644 --- a/uber/models/tracking.py +++ b/uber/models/tracking.py @@ -1,6 +1,7 @@ import json import sys from datetime import datetime +from markupsafe import Markup from threading import current_thread from urllib.parse import parse_qsl @@ -32,14 +33,22 @@ class ReportTracking(MagModel): when = Column(UTCDateTime, default=lambda: datetime.now(UTC)) who = Column(UnicodeText) + supervisor = Column(UnicodeText) page = Column(UnicodeText) params = Column(MutableDict.as_mutable(JSONB), default={}) + @property + def who_repr(self): + if self.supervisor: + return Markup(f'{self.who};
{self.supervisor} (Supervisor)') + return self.who + @classmethod def track_report(cls, params): from uber.models import Session with Session() as session: - session.add(ReportTracking(who=AdminAccount.admin_name(), + session.add(ReportTracking(who=AdminAccount.admin_or_volunteer_name(), + supervisor=AdminAccount.supervisor_name() or '', page=c.PAGE_PATH, params={key: val for key, val in params.items() if key not in ['self', 'out', 'session']})) @@ -49,9 +58,16 @@ def track_report(cls, params): class PageViewTracking(MagModel): when = Column(UTCDateTime, default=lambda: datetime.now(UTC)) who = Column(UnicodeText) + supervisor = Column(UnicodeText) page = Column(UnicodeText) which = Column(UnicodeText) + @property + def who_repr(self): + if self.supervisor: + return Markup(f'{self.who};
{self.supervisor} (Supervisor)') + return self.who + @classmethod def track_pageview(cls): url, query = cherrypy.request.path_info, cherrypy.request.query_string @@ -84,7 +100,10 @@ def track_pageview(cls): else: return - session.add(PageViewTracking(who=AdminAccount.admin_name(), page=c.PAGE_PATH, which=which)) + session.add(PageViewTracking( + who=AdminAccount.admin_or_volunteer_name(), + supervisor=AdminAccount.supervisor_name() or '', + page=c.PAGE_PATH, which=which)) session.commit() @@ -93,6 +112,7 @@ class Tracking(MagModel): model = Column(UnicodeText) when = Column(UTCDateTime, default=lambda: datetime.now(UTC)) who = Column(UnicodeText) + supervisor = Column(UnicodeText) page = Column(UnicodeText) which = Column(UnicodeText) links = Column(UnicodeText) @@ -100,6 +120,12 @@ class Tracking(MagModel): data = Column(UnicodeText) snapshot = Column(UnicodeText) + @property + def who_repr(self): + if self.supervisor: + return Markup(f'{self.who};
{self.supervisor} (Supervisor)') + return self.who + @classmethod def format(cls, values): return ', '.join('{}={}'.format(k, v) for k, v in values.items()) @@ -129,10 +155,12 @@ def repr(cls, column, value): def differences(cls, instance): diff = {} for attr, column in instance.__table__.columns.items(): - if attr in ['currently_sending', 'last_send_time', - 'unapproved_count', 'last_updated', 'last_synced', 'inventory_updated']: + if attr in ['last_updated', 'last_synced', 'inventory_updated', 'unapproved_count']: continue + if attr in ['currently_sending', 'last_send_time']: + return {} + new_val = getattr(instance, attr) if new_val: new_val = instance.coerce_column_data(column, new_val) @@ -179,7 +207,7 @@ def track_collection_change(cls, action, target, instance): if sys.argv == ['']: who = 'server admin' else: - who = AdminAccount.admin_name() or (current_thread().name if current_thread().daemon else 'non-admin') + who = AdminAccount.admin_or_volunteer_name() or (current_thread().name if current_thread().daemon else 'non-admin') with Session() as session: session.add(Tracking( @@ -187,6 +215,7 @@ def track_collection_change(cls, action, target, instance): fk_id=target.id, which=repr(target), who=who, + supervisor=AdminAccount.supervisor_name() or '', page=c.PAGE_PATH, action=action, data=repr(instance), @@ -220,7 +249,7 @@ def track(cls, action, instance): if sys.argv == ['']: who = 'server admin' else: - who = AdminAccount.admin_name() or (current_thread().name if current_thread().daemon else 'non-admin') + who = AdminAccount.admin_or_volunteer_name() or (current_thread().name if current_thread().daemon else 'non-admin') try: snapshot = json.dumps(instance.to_dict(), cls=serializer) @@ -233,6 +262,7 @@ def _insert(session): fk_id=instance.id, which=repr(instance), who=who, + supervisor=AdminAccount.supervisor_name() or '', page=c.PAGE_PATH, links=links, action=action, diff --git a/uber/payments.py b/uber/payments.py index 8f30ec478..5ff5e0008 100644 --- a/uber/payments.py +++ b/uber/payments.py @@ -472,7 +472,7 @@ def _process_refund(self, txn, department=None): if message: return message - self.receipt_manager.create_refund_transaction(txn.receipt, + self.receipt_manager.create_refund_transaction(txn, "Automatic refund of transaction " + txn.stripe_id, str(self.response_id), self.amount, @@ -662,6 +662,7 @@ def send_authorizenet_txn(self, txn_type=c.AUTHCAPTURE, **params): payment_profile = None order = None + intent_id = params.get('intent_id') params_str = [f"{name}: {params[name]}" for name in params] log.debug(f"Transaction {self.tracking_id} building an AuthNet transaction request, request type " @@ -730,40 +731,83 @@ def send_authorizenet_txn(self, txn_type=c.AUTHCAPTURE, **params): response = transactionController.getresponse() + txn_info = {} + card_info = {} + txn_info['fraud_info'] = {} + txn_info['response'] = {} + if response is not None: if response.messages.resultCode == "Ok": + txn_response_dict = response.transactionResponse.__dict__ + + txn_info['txn_id'] = str(txn_response_dict.get("transId", '')) + txn_info['response']['response_code'] = txn_response_dict.get('responseCode', '') + txn_info['response']['auth_code'] = txn_response_dict.get("authCode", '') + txn_info['fraud_info']['avs'] = txn_response_dict.get("avsResultCode", '') + txn_info['fraud_info']['cvv'] = txn_response_dict.get("cvvResultCode", '') + txn_info['fraud_info']['cavv'] = txn_response_dict.get("cavvResultCode", '') + + card_info['CardType'] = str(txn_response_dict.get("accountType", '')) + card_info['Last4'] = str(txn_response_dict.get('accountNumber', '')) + + if card_info['Last4']: + card_info['Last4'] = card_info['Last4'][4:] + if hasattr(response.transactionResponse, 'messages') is True: self.response = response.transactionResponse auth_txn_id = str(self.response.transId) + txn_info['response']['message_code'] = str(response.transactionResponse.messages.message[0].code) + txn_info['response']['message'] = str(response.transactionResponse.messages.message[0].description) + log.debug(f"Transaction {self.tracking_id} request successful. Transaction ID: {auth_txn_id}") + self.log_authorizenet_response(intent_id, txn_info, card_info) if txn_type in [c.AUTHCAPTURE, c.CAPTURE]: ReceiptManager.mark_paid_from_ids(params.get('intent_id'), auth_txn_id) else: - error_code = str(response.transactionResponse.errors.error[0].errorCode) - error_msg = str(response.transactionResponse.errors.error[0].errorText) + txn_info['response']['message_code'] = str(response.transactionResponse.errors.error[0].errorCode) + txn_info['response']['message'] = str(response.transactionResponse.errors.error[0].errorText) log.debug(f"Transaction {self.tracking_id} declined! " - f"{error_code}: {error_msg}") + f"{txn_info['response']['message_code']}: {txn_info['response']['message']}") + self.log_authorizenet_response(intent_id, txn_info, card_info) - return "Transaction declined. Please ensure you are entering the correct " + \ - "expiration date, card CVV/CVC, and ZIP Code." + return "Transaction declined. Please ensure you are entering the correct expiration date, card CVV/CVC, and ZIP Code." else: if hasattr(response, 'transactionResponse') is True \ and hasattr(response.transactionResponse, 'errors') is True: - error_code = str(response.transactionResponse.errors.error[0].errorCode) - error_msg = str(response.transactionResponse.errors.error[0].errorText) + txn_info['response']['message_code'] = str(response.transactionResponse.errors.error[0].errorCode) + txn_info['response']['message'] = str(response.transactionResponse.errors.error[0].errorText) else: - error_code = str(response.messages.message[0]['code'].text) - error_msg = str(response.messages.message[0]['text'].text) + txn_info['response']['message_code'] = str(response.messages.message[0]['code'].text) + txn_info['response']['message'] = str(response.messages.message[0]['text'].text) - log.error(f"Transaction {self.tracking_id} request failed! {error_code}: {error_msg}") + log.error(f"Transaction {self.tracking_id} request failed! {txn_info['response']['message_code']}: {txn_info['response']['message']}") + self.log_authorizenet_response(intent_id, txn_info, card_info) return "Transaction failed. Please refresh the page and try again, " + \ f"or contact us at {email_only(c.REGDESK_EMAIL)}." else: log.error(f"Transaction {self.tracking_id} request to AuthNet failed: no response received.") + def log_authorizenet_response(self, intent_id, txn_info, card_info): + from uber.models import ReceiptInfo, ReceiptTransaction, Session + + session = Session().session + matching_txns = session.query(ReceiptTransaction).filter_by(intent_id=intent_id).all() + + # AuthNet returns "StringElement" but we want strings + txn_info['response'] = {key: str(val) for key, val in txn_info['response'].items()} + txn_info['fraud_info'] = {key: str(val) for key, val in txn_info['fraud_info'].items()} + + if not matching_txns: + log.debug(f"Tried to save receipt info for intent ID {intent_id} but we couldn't find any matching payments!") + + for txn in matching_txns: + txn.receipt_info = ReceiptInfo(txn_info=txn_info, card_data=card_info, charged=datetime.now()) + session.add(txn.receipt_info) + session.commit() + class SpinTerminalRequest(TransactionRequest): def __init__(self, terminal_id='', amount=0, capture_signature=None, tracker=None, spin_payment_type="Credit", @@ -1046,7 +1090,7 @@ def _process_refund(self, txn, department=None): self.receipt_manager.items_to_add.append(self.tracker) - refund_txn = self.receipt_manager.create_refund_transaction(txn.receipt, + refund_txn = self.receipt_manager.create_refund_transaction(txn, "Automatic refund of transaction " + txn.stripe_id, self.intent_id_from_txn_tracker(self.tracker), refund_amount, @@ -1190,29 +1234,30 @@ def create_payment_transaction(self, desc='', intent=None, amount=0, txn_total=0 department=department or self.receipt.default_department, amount=amount, txn_total=txn_total or amount, - receipt_items=self.receipt.open_receipt_items, + receipt_items=self.receipt.open_purchase_items, desc=desc, who=self.who or AdminAccount.admin_name() or 'non-admin' )) if not intent: - for item in self.receipt.open_receipt_items: + for item in self.receipt.open_purchase_items: item.closed = datetime.now() self.items_to_add.append(item) - def create_refund_transaction(self, receipt, desc, refund_id, amount, method=c.STRIPE, department=None): + def create_refund_transaction(self, refunded_txn, desc, refund_id, amount, method=c.STRIPE, department=None): from uber.models import AdminAccount, ReceiptTransaction - receipt_txn = ReceiptTransaction(receipt_id=receipt.id, + receipt_txn = ReceiptTransaction(receipt_id=refunded_txn.receipt.id, refund_id=refund_id, + refunded_txn_id=refunded_txn.id, method=method, - department=department or receipt.default_department, + department=department or refunded_txn.receipt.default_department, amount=amount * -1, - receipt_items=receipt.open_receipt_items, + receipt_items=refunded_txn.receipt.open_credit_items, desc=desc, who=self.who or AdminAccount.admin_name() or 'non-admin' ) - for item in receipt.open_receipt_items: + for item in refunded_txn.receipt.open_credit_items: self.items_to_add.append(item) item.closed = datetime.now() @@ -1562,8 +1607,9 @@ def mark_paid_from_ids(intent_id, charge_id): session.add(model) session.commit() + session.check_receipt_closed(txn_receipt) - if model and isinstance(model, Group) and model.is_dealer and not txn.receipt.open_receipt_items: + if model and isinstance(model, Group) and model.is_dealer and not txn.receipt.open_purchase_items: try: send_email.delay( c.MARKETPLACE_EMAIL, @@ -1573,7 +1619,7 @@ def mark_paid_from_ids(intent_id, charge_id): model=model.to_dict('id')) except Exception: log.error('Unable to send {} payment confirmation email'.format(c.DEALER_TERM), exc_info=True) - if model and isinstance(model, ArtShowApplication) and not txn.receipt.open_receipt_items: + if model and isinstance(model, ArtShowApplication) and not txn.receipt.open_purchase_items: try: send_email.delay( c.ART_SHOW_EMAIL, @@ -1583,7 +1629,7 @@ def mark_paid_from_ids(intent_id, charge_id): model=model.to_dict('id')) except Exception: log.error('Unable to send Art Show payment confirmation email', exc_info=True) - if model and isinstance(model, ArtistMarketplaceApplication) and not txn.receipt.open_receipt_items: + if model and isinstance(model, ArtistMarketplaceApplication) and not txn.receipt.open_purchase_items: send_email.delay( c.ARTIST_MARKETPLACE_EMAIL, c.ARTIST_MARKETPLACE_EMAIL, diff --git a/uber/receipt_items.py b/uber/receipt_items.py index aec6bd5c3..cf64e0497 100644 --- a/uber/receipt_items.py +++ b/uber/receipt_items.py @@ -259,32 +259,41 @@ def overridden_badge_cost(attendee, new_attendee=None): return (f"{label} Custom Badge Price", new_cost - old_cost, 'overridden_price') +def needs_badge_change_calc(attendee): + return attendee.is_presold_oneday or attendee.badge_type == c.ONE_DAY_BADGE or attendee.badge_type in c.BADGE_TYPE_PRICES + + +def one_day_or_upgraded_badge_cost(attendee): + if attendee.badge_type in c.BADGE_TYPE_PRICES: + return c.BADGE_TYPE_PRICES[attendee.badge_type] + if attendee.qualifies_for_discounts: + return attendee.new_badge_cost - min(attendee.new_badge_cost, abs(attendee.age_discount)) + + @receipt_calculation.Attendee def badge_upgrade_cost(attendee, new_attendee=None): - if not new_attendee and attendee.badge_type not in c.BADGE_TYPE_PRICES: + if not new_attendee and not needs_badge_change_calc(attendee): return elif not new_attendee: old_cost = attendee.new_badge_cost if attendee.overridden_price is None else attendee.overridden_price - diff = (c.BADGE_TYPE_PRICES[attendee.badge_type] - old_cost) * 100 + diff = (one_day_or_upgraded_badge_cost(attendee) - old_cost) * 100 return (f"{attendee.badge_type_label} Badge Upgrade", diff, 'badge_type') - if attendee.badge_type not in c.BADGE_TYPE_PRICES and new_attendee.badge_type not in c.BADGE_TYPE_PRICES: + if not needs_badge_change_calc(attendee) and not needs_badge_change_calc(new_attendee): return old_cost = attendee.base_badge_prices_cost * 100 new_cost = new_attendee.base_badge_prices_cost * 100 - if attendee.badge_type in c.BADGE_TYPE_PRICES and new_attendee.badge_type in c.BADGE_TYPE_PRICES: - old_cost = c.BADGE_TYPE_PRICES[attendee.badge_type] * 100 - new_cost = c.BADGE_TYPE_PRICES[new_attendee.badge_type] * 100 - label = "Upgrade" if new_cost > old_cost else "Downgrade" - elif attendee.badge_type in c.BADGE_TYPE_PRICES: - old_cost = c.BADGE_TYPE_PRICES[attendee.badge_type] * 100 - label = "Downgrade" - elif new_attendee.badge_type in c.BADGE_TYPE_PRICES: - new_cost = c.BADGE_TYPE_PRICES[new_attendee.badge_type] * 100 - label = "Upgrade" - + if needs_badge_change_calc(attendee) and needs_badge_change_calc(new_attendee): + old_cost = one_day_or_upgraded_badge_cost(attendee) * 100 + new_cost = one_day_or_upgraded_badge_cost(new_attendee) * 100 + elif needs_badge_change_calc(attendee): + old_cost = one_day_or_upgraded_badge_cost(attendee) * 100 + elif needs_badge_change_calc(new_attendee): + new_cost = one_day_or_upgraded_badge_cost(new_attendee) * 100 + label = "Upgrade" if new_cost > old_cost else "Downgrade" + if old_cost == new_cost: return @@ -397,6 +406,10 @@ def age_discount_credit(attendee, new_attendee=None): diff = attendee.age_discount * 100 return ("Age Discount", diff, c.BADGE_DISCOUNT) + if needs_badge_change_calc(attendee) or needs_badge_change_calc(new_attendee): + # Age discount is included in the badge upgrade/downgrade function + return + old_credit = attendee.age_discount if attendee.qualifies_for_discounts else 0 new_credit = new_attendee.age_discount if new_attendee.qualifies_for_discounts else 0 diff --git a/uber/site_sections/accounts.py b/uber/site_sections/accounts.py index ff25ccc45..ee53dc9db 100644 --- a/uber/site_sections/accounts.py +++ b/uber/site_sections/accounts.py @@ -42,6 +42,7 @@ def index(self, session, message=''): .join(Attendee) .options(subqueryload(AdminAccount.attendee).subqueryload(Attendee.assigned_depts)) .order_by(Attendee.last_first).all()), + 'attendee_labels': sorted([label for val, label in attendees]), 'all_attendees': sorted(attendees, key=lambda tup: tup[1]), } @@ -197,6 +198,11 @@ def login(self, session, message='', original_location=None, **params): if not message: cherrypy.session['account_id'] = account.id + + # Forcibly exit any volunteer kiosks that were running + cherrypy.session.pop('kiosk_operator_id', None) + cherrypy.session.pop('kiosk_supervisor_id', None) + ensure_csrf_token_exists() raise HTTPRedirect(original_location) diff --git a/uber/site_sections/art_show_admin.py b/uber/site_sections/art_show_admin.py index 0a3c61952..1f18b9e93 100644 --- a/uber/site_sections/art_show_admin.py +++ b/uber/site_sections/art_show_admin.py @@ -6,6 +6,7 @@ from datetime import datetime from decimal import Decimal +from pockets.autolog import log from sqlalchemy import or_, and_ from sqlalchemy.orm import joinedload from sqlalchemy.orm.exc import NoResultFound @@ -106,22 +107,23 @@ def ops(self, session, message=''): 'message': message, } - def close_out(self, session, message='', piece_code='', bidder_num='', **params): - found_piece, found_bidder, data_error = None, None, '' + def close_out(self, session, message='', piece_code='', bidder_num='', winning_bid='', **params): + found_piece, found_bidder = None, None if piece_code: if len(piece_code.split('-')) != 2: - data_error = 'Please enter just one piece code.' + message = 'Please enter just one piece code.' else: artist_id, piece_id = piece_code.split('-') try: piece_id = int(piece_id) except Exception: - data_error = 'Please use the format XXX-# for the piece code.' + message = 'Please use the format XXX-# for the piece code.' - if not data_error: + if not message: piece = session.query(ArtShowPiece).join(ArtShowPiece.app).filter( - ArtShowApplication.artist_id == artist_id.upper(), + or_(ArtShowApplication.artist_id == artist_id.upper(), + ArtShowApplication.artist_id_ad == artist_id.upper()), ArtShowPiece.piece_id == piece_id ) if not piece.count(): @@ -131,88 +133,88 @@ def close_out(self, session, message='', piece_code='', bidder_num='', **params) else: found_piece = piece.one() - if bidder_num: + if found_piece and cherrypy.request.method == 'POST': + action = params.get('action', '') + if action in ['set_winner', 'voice_auction'] and not found_piece.valid_for_sale: + message = "This piece is not for sale and cannot have any bids." + elif action != 'get_info' and found_piece.status in [c.PAID, c.RETURN]: + message = "You cannot close out a piece that has been marked as paid for or returned to artist." + elif action == 'voice_auction': + found_piece.status = c.VOICE_AUCTION + session.add(found_piece) + elif action == 'no_bids': + if found_piece.valid_quick_sale: + found_piece.status = c.QUICK_SALE + message = f"Piece {found_piece.artist_and_piece_id} set to {found_piece.status_label} for {format_currency(found_piece.quick_sale_price)}." + else: + found_piece.status = c.RETURN + session.add(found_piece) + session.commit() + elif action == 'get_info': + message = f"Piece {found_piece.artist_and_piece_id} information retrieved." + elif action == 'set_winner': + if not bidder_num: + message = "Please enter the winning bidder number." + elif not winning_bid: + message = "Please enter a winning bid." + elif not winning_bid.isdigit(): + message = "Please enter only numbers for the winning bid." + elif int(winning_bid) < found_piece.opening_bid: + message = f'The winning bid ({format_currency(winning_bid)}) cannot be less than the minimum bid ({format_currency(found_piece.opening_bid)}).' + else: bidder = session.query(ArtShowBidder).filter(ArtShowBidder.bidder_num.ilike(bidder_num)) if not bidder.count(): message = 'Could not find bidder with number {}.'.format(bidder_num) elif bidder.count() > 1: message = 'Multiple bidders matched the number you entered for some reason.' else: - found_bidder = bidder.one().attendee - else: - message = data_error + found_bidder = bidder.one() + if not found_bidder.attendee: + message = "This bidder number does not have an attendee attached so we cannot sell anything to them." + + if found_bidder and not message: + if not found_bidder.attendee.art_show_receipt: + receipt = ArtShowReceipt(attendee=found_bidder.attendee) + session.add(receipt) + session.commit() + else: + receipt = found_bidder.attendee.art_show_receipt + + if not message: + found_piece.status = c.SOLD + found_piece.winning_bid = int(winning_bid) + found_piece.winning_bidder = found_bidder + found_piece.receipt = receipt + session.add(found_piece) + if found_bidder.attendee.badge_printed_name: + bidder_name = f"{found_bidder.attendee.badge_printed_name} ({found_bidder.attendee.full_name})" + else: + bidder_name = f"{found_bidder.attendee.full_name}" + message = f"Piece {found_piece.artist_and_piece_id} set to {found_piece.status_label} for {format_currency(winning_bid)} to {bidder_num}, {bidder_name}." + session.commit() + + if not message: + session.commit() + message = f"Piece {found_piece.artist_and_piece_id} set to {found_piece.status_label}." return { 'message': message, 'piece_code': piece_code, 'bidder_num': bidder_num, - 'piece': found_piece, - 'bidder': found_bidder + 'winning_bid': winning_bid, + 'piece': found_piece if params.get('action', '') == 'get_info' else None, } - def close_out_piece(self, session, message='', **params): - if 'id' not in params: - raise HTTPRedirect('close_out?piece_code={}&bidder_num={}&message={}', - params['piece_code'], params['bidder_num'], 'Error: no piece ID submitted.') - - piece = session.art_show_piece(params) - session.add(piece) - - if piece.status == c.QUICK_SALE and not piece.valid_quick_sale: - message = 'This piece does not have a valid quick-sale price.' - elif piece.status == c.RETURN and piece.valid_quick_sale: - message = 'This piece has a quick-sale price and so cannot yet be marked as Return to Artist.' - elif (piece.winning_bid or piece.status == c.SOLD) and not piece.valid_for_sale: - message = 'This piece is not for sale!' - elif 'bidder_id' not in params: - if piece.status == c.SOLD: - message = 'You cannot mark a piece as Sold without a bidder. Please add a bidder number in step 1.' - elif piece.winning_bid: - message = 'You cannot enter a winning bid without a bidder. Please add a bidder number in step 1.' - elif piece.status != c.SOLD: - if 'bidder_id' in params: - message = 'You cannot assign a piece to a bidder\'s receipt without marking it as Sold.' - if piece.winning_bid: - message = 'You cannot enter a winning bid for a piece without also marking it as Sold.' - elif piece.status == c.SOLD and not piece.winning_bid: - message = 'Please enter the winning bid for this piece.' - elif piece.status == c.SOLD and piece.winning_bid < piece.opening_bid: - message = 'The winning bid (${}) cannot be less than the minimum bid (${}).'\ - .format(piece.winning_bid, piece.opening_bid) - - if piece.status == c.PAID: - message = 'Please process sales via the sales page.' - - if 'bidder_id' in params: - attendee = session.attendee(params['bidder_id']) - if not attendee: - message = 'Attendee not found for some reason.' - elif not attendee.art_show_receipt: - receipt = ArtShowReceipt(attendee=attendee) - session.add(receipt) - session.commit() - else: - receipt = attendee.art_show_receipt - - if not message: - piece.winning_bidder = attendee.art_show_bidder - piece.receipt = receipt - - if message: - session.rollback() - raise HTTPRedirect('close_out?piece_code={}&bidder_num={}&message={}', - params['piece_code'], params['bidder_num'], message) - else: - raise HTTPRedirect('close_out?message={}', - 'Close-out successful for piece {}'.format(piece.artist_and_piece_id)) - - def artist_check_in_out(self, session, checkout=False, message='', page=1, search_text='', order='first_name'): + def artist_check_in_out(self, session, checkout=False, hanging=False, message='', page=1, search_text='', order='first_name'): filters = [ArtShowApplication.status == c.APPROVED] if checkout: filters.append(ArtShowApplication.checked_in != None) # noqa: E711 else: filters.append(ArtShowApplication.checked_out == None) # noqa: E711 + if hanging: + filters.append(ArtShowApplication.art_show_pieces.any(ArtShowPiece.status == c.HANGING)) + search_text = search_text.strip() search_filters = [] if search_text: @@ -225,7 +227,8 @@ def artist_check_in_out(self, session, checkout=False, message='', page=1, searc applications = session.query(ArtShowApplication).join(ArtShowApplication.attendee)\ .filter(*filters).filter(or_(*search_filters))\ - .order_by(Attendee.first_name.desc() if '-' in str(order) else Attendee.first_name) + .order_by(Attendee.first_name.desc() if '-' in str(order) else Attendee.first_name).options( + joinedload(ArtShowApplication.art_show_pieces)) count = applications.count() page = int(page) or 1 @@ -245,6 +248,7 @@ def artist_check_in_out(self, session, checkout=False, message='', page=1, searc 'applications': applications, 'order': Order(order), 'checkout': checkout, + 'hanging': hanging, } @public @@ -277,12 +281,14 @@ def save_and_check_in_out(self, session, **params): session.rollback() return {'error': message, 'app_id': app.id} else: - if 'check_in' in params and params['check_in']: + if params.get('check_in', ''): app.checked_in = localized_now() success = 'Artist successfully checked-in' - if 'check_out' in params and params['check_out']: + if params.get('check_out', ''): app.checked_out = localized_now() success = 'Artist successfully checked-out' + if params.get('hanging', ''): + success = 'Art marked as Hanging' session.commit() if 'check_in' in params: @@ -339,10 +345,17 @@ def save_and_check_in_out(self, session, **params): session.rollback() break else: - if 'check_in' in params and params['check_in'] and piece.status == c.EXPECTED: + if params.get('hanging', '') and piece.status == c.EXPECTED: + piece.status = c.HANGING + elif params.get('check_in', '') and piece.status in [c.EXPECTED, c.HANGING]: piece.status = c.HUNG - elif 'check_out' in params and params['check_out'] and piece.status == c.HUNG: - piece.status = c.RETURN + elif params.get('check_out', ''): + if piece.orig_value_of('status') == c.PAID: + # Accounts for the surprisingly-common situation where an + # artist checks out WHILE their pieces are actively being paid for + piece.status = c.PAID + elif piece.status == c.HUNG: + piece.status = c.RETURN session.commit() # We save as we go so it's less annoying if there's an error for piece in app.art_show_pieces: if 'check_in' in params and params['check_in'] and piece.status == c.EXPECTED: @@ -547,7 +560,7 @@ def sign_up_bidder(self, session, **params): if params.get(field_name, None): if hasattr(attendee, field_name) and not hasattr(ArtShowBidder(), field_name): setattr(attendee, field_name, params.pop(field_name)) - elif c.INDEPENDENT_ART_SHOW and field_name in ArtShowBidder.required_fields.keys(): + elif field_name in ArtShowBidder.required_fields.keys(): missing_fields.append(ArtShowBidder.required_fields[field_name]) if missing_fields: @@ -555,13 +568,13 @@ def sign_up_bidder(self, session, **params): 'attendee_id': attendee.id} if params['id']: - bidder = session.art_show_bidder(params) + bidder = session.art_show_bidder(params, bools=['email_won_bids']) else: params.pop('id') bidder = ArtShowBidder() attendee.art_show_bidder = bidder - bidder.apply(params, restricted=False) + bidder.apply(params, restricted=False, bools=['email_won_bids']) bidder_num_dupe = session.query(ArtShowBidder).filter( ArtShowBidder.id != bidder.id, @@ -681,7 +694,8 @@ def pieces_bought(self, session, id, search_text='', message='', **params): artist_id, piece_id = search_text.split('-') pieces = session.query(ArtShowPiece).join(ArtShowPiece.app).filter( ArtShowPiece.piece_id == int(piece_id), - ArtShowApplication.artist_id == artist_id.upper() + or_(ArtShowApplication.artist_id == artist_id.upper(), + ArtShowApplication.artist_id_ad == artist_id.upper()) ) else: pieces = session.query(ArtShowPiece).filter(ArtShowPiece.name.ilike('%{}%'.format(search_text))) @@ -795,10 +809,10 @@ def undo_payment(self, session, id, **params): raise HTTPRedirect('pieces_bought?id={}&message={}', payment.receipt.attendee.id, payment_or_refund + " deleted") - def print_receipt(self, session, id, **params): + def print_receipt(self, session, id, close=False, **params): receipt = session.art_show_receipt(id) - if not receipt.closed: + if close and True: receipt.closed = localized_now() for piece in receipt.pieces: piece.status = c.PAID @@ -808,32 +822,61 @@ def print_receipt(self, session, id, **params): # Now that we're not changing the receipt anymore, record the item total and the cash sum attendee_receipt = session.get_receipt_by_model(receipt.attendee, create_if_none="BLANK") - sales_item = ReceiptItem( - receipt_id=attendee_receipt.id, - department=c.ART_SHOW_RECEIPT_ITEM, - category=c.PURCHASE, - desc=f"Art Show Receipt #{receipt.invoice_num}", - amount=receipt.total, - who=AdminAccount.admin_name() or 'non-admin', - ) - session.add(sales_item) - total_cash = receipt.cash_total if total_cash != 0: cash_txn = ReceiptTransaction( receipt_id=attendee_receipt.id, method=c.CASH, department=c.ART_SHOW_RECEIPT_ITEM, - desc="{} Art Show Receipt #{}".format( + desc="{} Art Show Invoice #{}".format( "Payment for" if total_cash > 0 else "Refund for", receipt.invoice_num), amount=total_cash, who=AdminAccount.admin_name() or 'non-admin', ) session.add(cash_txn) - if total_cash == receipt.total: # TODO: Fix this when items can have multiple txns - sales_item.receipt_txn = cash_txn - sales_item.closed = datetime.now() + session.commit() + session.refresh(attendee_receipt) + sales_item = ReceiptItem( + receipt_id=attendee_receipt.id, + fk_id=receipt.id, + fk_model="ArtShowReceipt", + department=c.ART_SHOW_RECEIPT_ITEM, + category=c.PURCHASE, + desc=f"Art Show Receipt #{receipt.invoice_num}", + amount=receipt.total, + who=AdminAccount.admin_name() or 'non-admin', + ) + + main_txn = None + cash_total, credit_total, credit_num = 0, 0, 0 + for txn in [txn for txn in attendee_receipt.receipt_txns if f"Art Show Invoice #{receipt.invoice_num}" in txn.desc]: + if not main_txn or txn.amount > main_txn.amount: + main_txn = txn + if txn.method == c.CASH: + cash_total += txn.amount + else: + credit_num += 1 + credit_total += txn.amount + + log.error(main_txn) + + admin_notes = [] + if cash_total: + admin_notes.append(f"Cash: {format_currency(cash_total / 100)}") + if credit_total: + credit_note = f"Credit: {format_currency(credit_total / 100)}" + if credit_num > 1: + credit_note += f" ({credit_num} payments)" + admin_notes.append(credit_note) + + log.error(admin_notes) + log.error(sales_item) + + sales_item.receipt_txn = main_txn + sales_item.admin_notes = "; ".join(admin_notes) + sales_item.closed = datetime.now() + session.add(sales_item) session.commit() return { @@ -857,9 +900,9 @@ def purchases_charge(self, session, id, amount, receipt_id): attendee_receipt = session.get_receipt_by_model(attendee, create_if_none="BLANK") charge = TransactionRequest(attendee_receipt, receipt_email=attendee.email, - description='{}ayment for {}\'s art show purchases'.format( + description='{}ayment for Art Show Invoice #{}'.format( 'P' if int(float(amount)) == receipt.total else 'Partial p', - attendee.full_name), + receipt.invoice_num), amount=int(float(amount))) message = charge.prepare_payment(department=c.ART_SHOW_RECEIPT_ITEM) if message: @@ -957,41 +1000,6 @@ def paid_with_cash(self, session, id): session.add_all(receipt_manager.items_to_add) session.commit() + session.check_receipt_closed(receipt) raise HTTPRedirect('form?id={}&message={}', id, f"Cash payment of {format_currency(amount_owed / 100)} recorded.") - - @public - def sales_charge_form(self, message='', amount=None, description='', - sale_id=None): - charge = False - if amount is not None: - if not description: - message = "You must enter a brief description " \ - "of what's being sold" - else: - charge = True - - return { - 'message': message, - 'amount': amount, - 'description': description, - 'sale_id': sale_id, - 'charge': charge, - } - - @public - @ajax - @credit_card - def sales_charge(self, session, id, amount, description): - charge = TransactionRequest(amount=100 * float(amount), description=description) - message = charge.create_stripe_intent() - if message: - return {'error': message} - else: - session.add(ArbitraryCharge( - amount=int(charge.dollar_amount), - what=charge.description, - )) - return {'stripe_intent': charge.intent, - 'success_url': 'sales_charge_form?message={}'.format('Charge successfully processed'), - 'cancel_url': '../merch_admin/cancel_arbitrary_charge'} diff --git a/uber/site_sections/art_show_reports.py b/uber/site_sections/art_show_reports.py index aea1f7b1d..bd6c094ce 100644 --- a/uber/site_sections/art_show_reports.py +++ b/uber/site_sections/art_show_reports.py @@ -211,7 +211,7 @@ def approved_international_artists(self, out, session): for app in session.query(ArtShowApplication ).join(Attendee, ArtShowApplication.attendee_id == Attendee.id - ).filter(ArtShowApplication.status == c.approved, + ).filter(ArtShowApplication.status == c.APPROVED, or_(and_(ArtShowApplication.country != '', ArtShowApplication.country != 'United States'), and_(Attendee.country != '', diff --git a/uber/site_sections/budget.py b/uber/site_sections/budget.py index 0fc811d73..51578a594 100644 --- a/uber/site_sections/budget.py +++ b/uber/site_sections/budget.py @@ -1,13 +1,18 @@ -from collections import defaultdict +import math +import re +from collections import defaultdict +from pockets.autolog import log +from residue import CoerceUTF8 as UnicodeText from sqlalchemy.orm import joinedload -from sqlalchemy import or_, func, not_ +from sqlalchemy import or_, func, not_, and_ from uber.config import c from uber.decorators import all_renderable, log_pageview -from uber.models import ArbitraryCharge, Attendee, Group, MPointsForCash, ReceiptItem, Sale, PromoCode, PromoCodeGroup +from uber.models import (ArbitraryCharge, Attendee, Group, ModelReceipt, MPointsForCash, ReceiptInfo, ReceiptItem, + ReceiptTransaction, Sale, PromoCode, PromoCodeGroup) from uber.server import redirect_site_section -from uber.utils import localized_now +from uber.utils import localized_now, Order def get_grouped_costs(session, filters, joins=[], selector=Attendee.badge_cost): diff --git a/uber/site_sections/group_admin.py b/uber/site_sections/group_admin.py index beb738b90..4e9becb0f 100644 --- a/uber/site_sections/group_admin.py +++ b/uber/site_sections/group_admin.py @@ -1,5 +1,6 @@ import cherrypy +from collections import defaultdict from datetime import datetime from pockets import readable_join from pockets.autolog import log @@ -30,28 +31,41 @@ def _required_message(self, params, fields): def index(self, session, message='', show_all=None): groups = session.viewable_groups() - dealer_groups = groups.filter(Group.is_dealer == True) # noqa: E712 - guest_groups = groups.join(Group.guest) + dealer_counts = defaultdict(int) if not show_all: - dealer_groups = dealer_groups.filter(Group.status != c.IMPORTED) + groups = groups.filter(Group.status != c.IMPORTED) + + dealer_groups = groups.filter(Group.is_dealer == True) # noqa: E712 + dealer_counts['total'] = dealer_groups.count() + for group in dealer_groups: + dealer_counts['tables'] += group.tables + dealer_counts['badges'] += group.badges + match group.status: + case c.UNAPPROVED: + dealer_counts['unapproved'] += group.tables + case c.WAITLISTED: + dealer_counts['waitlisted'] += group.tables + case c.APPROVED: + dealer_counts['approved'] += group.tables + case c.SHARED: + dealer_counts['approved'] += group.tables + + guest_groups = groups.join(Group.guest) return { 'message': message, - 'groups': groups.options(joinedload(Group.attendees), joinedload(Group.leader), - joinedload(Group.active_receipt)), - 'guest_groups': guest_groups.options(joinedload(Group.attendees), joinedload(Group.leader)), + 'show_all': show_all, + 'all_groups': groups.options(joinedload(Group.attendees), joinedload(Group.active_receipt)).all(), + 'guest_groups': guest_groups.options(joinedload(Group.attendees)), 'guest_checklist_items': GuestGroup(group_type=c.GUEST).sorted_checklist_items, 'band_checklist_items': GuestGroup(group_type=c.BAND).sorted_checklist_items, - 'num_dealer_groups': dealer_groups.count(), - 'dealer_groups': dealer_groups.options(joinedload(Group.attendees), - joinedload(Group.leader), joinedload(Group.active_receipt)), - 'dealer_badges': sum(g.badges for g in dealer_groups), - 'tables': sum(g.tables for g in dealer_groups), - 'show_all': show_all, - 'unapproved_tables': sum(g.tables for g in dealer_groups if g.status == c.UNAPPROVED), - 'waitlisted_tables': sum(g.tables for g in dealer_groups if g.status == c.WAITLISTED), - 'approved_tables': sum(g.tables for g in dealer_groups if g.status in [c.APPROVED, c.SHARED]) + 'num_dealer_groups': dealer_counts['total'], + 'dealer_badges': dealer_counts['badges'], + 'tables': dealer_counts['tables'], + 'unapproved_tables': dealer_counts['unapproved'], + 'waitlisted_tables': dealer_counts['waitlisted'], + 'approved_tables': dealer_counts['approved'], } def new_group_from_attendee(self, session, id): @@ -263,6 +277,7 @@ def paid_with_cash(self, session, id): session.add_all(receipt_manager.items_to_add) session.commit() + session.check_receipt_closed(receipt) raise HTTPRedirect('form?id={}&message={}', id, f"Cash payment of {format_currency(amount_owed / 100)} recorded.") diff --git a/uber/site_sections/marketplace.py b/uber/site_sections/marketplace.py index 43852f589..0c86c052c 100644 --- a/uber/site_sections/marketplace.py +++ b/uber/site_sections/marketplace.py @@ -166,6 +166,8 @@ def cancel(self, session, id): f"There was an issue processing your refund. " "Please contact us at {email_only(c.ARTIST_MARKETPLACE_EMAIL)}.") session.add_all(refund.get_receipt_items_to_add()) + session.commit() + session.check_receipt_closed(session.get_receipt_by_model(app.attendee)) if app.status == c.ACCEPTED: send_email.delay( diff --git a/uber/site_sections/merch_admin.py b/uber/site_sections/merch_admin.py index 89cc4b5af..c106e60af 100644 --- a/uber/site_sections/merch_admin.py +++ b/uber/site_sections/merch_admin.py @@ -1,18 +1,86 @@ import cherrypy +import re from pockets import listify +from sqlalchemy import or_ from uber.config import c -from uber.decorators import ajax, all_renderable, credit_card, public +from uber.decorators import ajax, all_renderable, credit_card, public, kiosk_login +from uber.errors import HTTPRedirect from uber.models import ArbitraryCharge, Attendee, MerchDiscount, MerchPickup, \ MPointsForCash, NoShirt, OldMPointExchange from uber.utils import check, check_csrf from uber.payments import TransactionRequest +def attendee_from_id_or_badge_num(session, badge_num_or_qr_code): + attendee, id = None, None + message = '' + + if not badge_num_or_qr_code: + message = 'Please enter or scan a badge number or check-in QR code.' + elif re.match('^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$', badge_num_or_qr_code): + id = badge_num_or_qr_code + elif not badge_num_or_qr_code.isdigit(): + message = 'Invalid badge number.' + + if id: + attendee = session.query(Attendee).filter(or_(Attendee.id == id, Attendee.public_id == id)).first() + if not attendee: + message = f"No attendee found with ID {id}." + elif not message: + attendee = session.query(Attendee).filter_by(badge_num=badge_num_or_qr_code).first() + if not attendee: + message = f'No attendee has badge number {badge_num_or_qr_code}.' + + return attendee, message + + @all_renderable() class Root: - def index(self, message=''): - return {'message': message} + @kiosk_login() + def index(self, session, message='', **params): + if params.get('enter_kiosk'): + supervisor = session.current_admin_account() + if not supervisor: + message = "Could not set kiosk mode. Please log in again or contact your developer." + else: + cherrypy.session['kiosk_supervisor_id'] = supervisor.id + cherrypy.session.pop('account_id', None) + cherrypy.session.pop('attendee_account_id', None) + elif params.get('volunteer_logout'): + cherrypy.session.pop('kiosk_operator_id', None) + elif params.get('exit_kiosk'): + cherrypy.session.pop('kiosk_supervisor_id', None) + cherrypy.session.pop('kiosk_operator_id', None) + raise HTTPRedirect('index?message={}', "Kiosk mode ended.") + + return { + 'message': message, + 'supervisor': session.current_supervisor_admin(), + 'logged_in_volunteer': cherrypy.session.get('kiosk_operator_id'), + } + + @ajax + def log_in_volunteer(self, session, message='', badge_num=''): + attendee = None + + if not badge_num: + message = "Please enter a badge number." + elif not badge_num.isdigit(): + message = 'Invalid badge number.' + else: + attendee = session.query(Attendee).filter_by(badge_num=badge_num).first() + if not attendee: + message = f'No attendee has badge number {badge_num}.' + + if message: + return {'success': False, 'message': message} + + cherrypy.session['kiosk_operator_id'] = attendee.id + return {'success': True, + 'message': f"Logged in as {attendee.name_and_badge_info}!", + 'operator_name': attendee.full_name, + } @public def arbitrary_charge_form(self, message='', amount=None, description='', email='', sale_id=None): @@ -58,6 +126,7 @@ def arbitrary_charge(self, session, id, amount, description, email, return_to='a 'success_url': '{}?message={}'.format(return_to, 'Charge successfully processed'), 'cancel_url': 'cancel_arbitrary_charge'} + @kiosk_login() def multi_merch_pickup(self, session, message="", csrf_token=None, picker_upper=None, badges=(), **shirt_sizes): picked_up = [] if csrf_token: @@ -77,13 +146,13 @@ def multi_merch_pickup(self, session, message="", csrf_token=None, picker_upper= else: if attendee.got_merch: picked_up.append( - '{a.full_name} (badge {a.badge_num}) already got their merch'.format(a=attendee)) + '{a.name_and_badge_info} already got their merch'.format(a=attendee)) else: attendee.got_merch = True shirt_key = 'shirt_{}'.format(attendee.badge_num) if shirt_key in shirt_sizes: attendee.shirt = int(listify(shirt_sizes.get(shirt_key, c.SIZE_UNKNOWN))[0]) - picked_up.append('{a.full_name} (badge {a.badge_num}): {a.merch}'.format(a=attendee)) + picked_up.append('{a.name_and_badge_info}: {a.merch}'.format(a=attendee)) session.add(MerchPickup(picked_up_by=picker_upper, picked_up_for=attendee)) session.commit() @@ -94,73 +163,71 @@ def multi_merch_pickup(self, session, message="", csrf_token=None, picker_upper= } @ajax - def check_merch(self, session, badge_num, staff_merch=''): + @kiosk_login() + def check_merch(self, session, badge_num_or_qr_code, staff_merch=''): id = shirt = gets_swadge = None merch_items = [] - if not (badge_num.isdigit() and 0 < int(badge_num) < 99999): - message = 'Invalid badge number' - else: - attendee = session.query(Attendee).filter_by(badge_num=badge_num).first() - if not attendee: - message = 'No attendee has badge number {}'.format(badge_num) + + attendee, message = attendee_from_id_or_badge_num(session, badge_num_or_qr_code.strip()) + + if not message: + if staff_merch: + merch = attendee.staff_merch + got_merch = attendee.got_staff_merch else: - if staff_merch: - merch = attendee.staff_merch - got_merch = attendee.got_staff_merch - else: - merch, got_merch = attendee.merch, attendee.got_merch + merch, got_merch = attendee.merch, attendee.got_merch - if staff_merch and c.STAFF_SHIRT_OPTS != c.SHIRT_OPTS: - shirt_size = c.STAFF_SHIRTS[attendee.staff_shirt] - else: - shirt_size = c.SHIRTS[attendee.shirt] - - if not merch: - message = '{a.full_name} ({a.badge}) has no merch'.format(a=attendee) - elif got_merch: - if not (not staff_merch and attendee.gets_swadge - and not attendee.got_swadge): - message = '{a.full_name} ({a.badge}) already got {merch}. Their shirt size is {shirt}'.format( - a=attendee, merch=merch, shirt=shirt_size) - else: - id = attendee.id - gets_swadge = True - shirt = c.NO_SHIRT - message = '{a.full_name} has received all of their merch except for their swadge. ' \ - 'Click the "Give Merch" button below to mark them as receiving that.'.format(a=attendee) + if staff_merch and c.STAFF_SHIRT_OPTS != c.SHIRT_OPTS: + shirt_size = c.STAFF_SHIRTS[attendee.staff_shirt] + else: + shirt_size = c.SHIRTS[attendee.shirt] + + if not merch or merch == 'N/A': + message = f'{attendee.name_and_badge_info} does not have any merch!' + elif got_merch: + if not (not staff_merch and attendee.gets_swadge and not attendee.got_swadge): + message = f'{attendee.name_and_badge_info} already got {merch}. Their shirt size is {shirt_size}.' else: id = attendee.id + gets_swadge = True + shirt = c.NO_SHIRT + message = f'{attendee.name_and_badge_info} has received all of their merch except for their swadge. ' \ + 'Click the "Give Merch" button below to mark them as receiving it.' + else: + id = attendee.id - if staff_merch: - merch_items = attendee.staff_merch_items - else: - merch_items = attendee.merch_items - gets_swadge = attendee.gets_swadge + if staff_merch: + merch_items = attendee.staff_merch_items + else: + merch_items = attendee.merch_items + gets_swadge = attendee.gets_swadge - if (staff_merch and attendee.num_staff_shirts_owed) or \ - (not staff_merch and attendee.num_event_shirts_owed): - if staff_merch and c.STAFF_SHIRT_OPTS != c.SHIRT_OPTS: - shirt = attendee.staff_shirt or c.SIZE_UNKNOWN - else: - shirt = attendee.shirt or c.SIZE_UNKNOWN + if (staff_merch and attendee.num_staff_shirts_owed) or \ + (not staff_merch and attendee.num_event_shirts_owed): + if staff_merch and c.STAFF_SHIRT_OPTS != c.SHIRT_OPTS: + shirt = attendee.staff_shirt or c.SIZE_UNKNOWN else: - shirt = c.NO_SHIRT + shirt = attendee.shirt or c.SIZE_UNKNOWN + else: + shirt = c.NO_SHIRT - message = '{a.full_name} ({a.badge}) has not yet received their merch.'.format(a=attendee) - if attendee.amount_unpaid and not staff_merch: - merch_items.insert(0, - 'WARNING: Attendee is not fully paid up and may not have paid for their ' - 'merch. Please contact Registration.') + message = f'{attendee.name_and_badge_info} has not yet received their merch.' + if attendee.amount_unpaid and not staff_merch: + merch_items.insert(0, + 'WARNING: Attendee is not fully paid up and may not have paid for their ' + 'merch. Please contact Registration.') return { 'id': id, 'shirt': shirt, 'message': message, + 'display_name': '' if not attendee else attendee.name_and_badge_info, 'merch_items': merch_items, 'gets_swadge': gets_swadge, } @ajax + @kiosk_login() def give_merch(self, session, id, shirt_size, no_shirt, staff_merch, give_swadge=None): try: shirt_size = int(shirt_size) @@ -172,23 +239,23 @@ def give_merch(self, session, id, shirt_size, no_shirt, staff_merch, give_swadge merch = attendee.staff_merch if staff_merch else attendee.merch got = attendee.got_staff_merch if staff_merch else attendee.got_merch if not merch: - message = '{} has no merch'.format(attendee.full_name) + message = '{} has no merch.'.format(attendee.name_and_badge_info) elif got and give_swadge and not attendee.got_swadge: - message = '{a.full_name} marked as receiving their swadge'.format( + message = '{a.name_and_badge_info} marked as receiving their swadge.'.format( a=attendee) success = True attendee.got_swadge = True session.commit() elif got: - message = '{} already got {}'.format(attendee.full_name, merch) - elif shirt_size == c.SIZE_UNKNOWN: - message = 'You must select a shirt size' + message = '{} already got {}.'.format(attendee.name_and_badge_info, merch) + elif shirt_size in [c.NO_SHIRT, c.SIZE_UNKNOWN]: + message = 'You must select a shirt size.' else: if no_shirt: - message = '{} is now marked as having received all of the following (EXCEPT FOR THE SHIRT): {}' + message = '{} is now marked as having received all of the following (EXCEPT FOR THE SHIRT): {}.' else: - message = '{} is now marked as having received {}' - message = message.format(attendee.full_name, merch) + message = '{} is now marked as having received {}.' + message = message.format(attendee.name_and_badge_info, merch) setattr(attendee, 'got_staff_merch' if staff_merch else 'got_merch', True) if give_swadge: @@ -210,6 +277,7 @@ def give_merch(self, session, id, shirt_size, no_shirt, staff_merch, give_swadge } @ajax + @kiosk_login() def take_back_merch(self, session, id, staff_merch=None): attendee = session.attendee(id, allow_invalid=True) if staff_merch: @@ -219,14 +287,14 @@ def take_back_merch(self, session, id, staff_merch=None): if attendee.no_shirt: session.delete(attendee.no_shirt) session.commit() - return '{a.full_name} ({a.badge}) merch handout canceled'.format(a=attendee) + return '{a.name_and_badge_info} merch handout cancelled.'.format(a=attendee) @ajax - def redeem_merch_discount(self, session, badge_num, apply=''): - try: - attendee = session.query(Attendee).filter_by(badge_num=badge_num).one() - except Exception: - return {'error': 'No attendee exists with that badge number.'} + @kiosk_login() + def redeem_merch_discount(self, session, badge_num_or_qr_code, apply=''): + attendee, message = attendee_from_id_or_badge_num(session, badge_num_or_qr_code) + if message: + return {'error': message} if attendee.badge_type != c.STAFF_BADGE: return {'error': 'Only staff badges are eligible for discount.'} @@ -236,7 +304,7 @@ def redeem_merch_discount(self, session, badge_num, apply=''): if discount: return { 'warning': True, - 'message': 'This staffer has already redeemed their discount {} time{}'.format( + 'message': 'This staffer has already redeemed their discount {} time{}.'.format( discount.uses, 's' if discount.uses > 1 else '') } else: @@ -247,14 +315,14 @@ def redeem_merch_discount(self, session, badge_num, apply=''): discount.uses += 1 session.add(discount) session.commit() - return {'success': True, 'message': 'Discount on badge #{} has been marked as redeemed.'.format(badge_num)} + return {'success': True, 'message': 'Discount for {} has been marked as redeemed.'.format(attendee.name_and_badge_info)} @ajax - def record_mpoint_cashout(self, session, badge_num, amount): - try: - attendee = session.attendee(badge_num=badge_num) - except Exception: - return {'success': False, 'message': 'No one has badge number {}'.format(badge_num)} + @kiosk_login() + def record_mpoint_cashout(self, session, badge_num_or_qr_code, amount): + attendee, message = attendee_from_id_or_badge_num(session, badge_num_or_qr_code) + if message: + return {'success': False, 'message': message} mfc = MPointsForCash(attendee=attendee, amount=amount) message = check(mfc) @@ -263,20 +331,21 @@ def record_mpoint_cashout(self, session, badge_num, amount): else: session.add(mfc) session.commit() - message = '{mfc.attendee.full_name} exchanged {mfc.amount} MPoints for cash'.format(mfc=mfc) + message = '{mfc.attendee.name_and_badge_info} exchanged {mfc.amount} MPoints for cash.'.format(mfc=mfc) return {'id': mfc.id, 'success': True, 'message': message} @ajax + @kiosk_login() def undo_mpoint_cashout(self, session, id): session.delete(session.mpoints_for_cash(id)) return 'MPoint usage deleted' @ajax - def record_old_mpoint_exchange(self, session, badge_num, amount): - try: - attendee = session.attendee(badge_num=badge_num) - except Exception: - return {'success': False, 'message': 'No one has badge number {}'.format(badge_num)} + @kiosk_login() + def record_old_mpoint_exchange(self, session, badge_num_or_qr_code, amount): + attendee, message = attendee_from_id_or_badge_num(session, badge_num_or_qr_code) + if message: + return {'success': False, 'message': message} ome = OldMPointExchange(attendee=attendee, amount=amount) message = check(ome) @@ -285,7 +354,7 @@ def record_old_mpoint_exchange(self, session, badge_num, amount): else: session.add(ome) session.commit() - message = "{ome.attendee.full_name} exchanged {ome.amount} of last year's MPoints".format(ome=ome) + message = "{ome.attendee.name_and_badge_info} marked as having exchanged {ome.amount} of last year's MPoints.".format(ome=ome) return {'id': ome.id, 'success': True, 'message': message} @ajax @@ -295,6 +364,7 @@ def undo_mpoint_exchange(self, session, id): return 'MPoint exchange deleted' @ajax + @kiosk_login() def record_sale(self, session, badge_num=None, **params): params['reg_station'] = cherrypy.session.get('reg_station', 0) sale = session.sale(params) @@ -312,11 +382,12 @@ def record_sale(self, session, badge_num=None, **params): session.commit() message = '{sale.what} sold{to} for ${sale.cash}{mpoints}' \ .format(sale=sale, - to=(' to ' + sale.attendee.full_name) if sale.attendee else '', - mpoints=' and {} MPoints'.format(sale.mpoints) if sale.mpoints else '') + to=(' to ' + sale.attendee.name_and_badge_info) if sale.attendee else '', + mpoints=' and {} MPoints.'.format(sale.mpoints) if sale.mpoints else '') return {'id': sale.id, 'success': True, 'message': message} @ajax + @kiosk_login() def undo_sale(self, session, id): session.delete(session.sale(id)) return 'Sale deleted' diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index be8dc79c7..4c1e6e5f4 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -17,7 +17,7 @@ redirect_if_at_con_to_kiosk, render, requires_account from uber.errors import HTTPRedirect from uber.forms import load_forms -from uber.models import Attendee, AttendeeAccount, Attraction, Email, Group, PromoCode, PromoCodeGroup, \ +from uber.models import Attendee, AttendeeAccount, Attraction, BadgePickupGroup, Email, Group, PromoCode, PromoCodeGroup, \ ModelReceipt, ReceiptItem, ReceiptTransaction, Tracking from uber.tasks.email import send_email from uber.utils import add_opt, check, localized_now, normalize_email, normalize_email_legacy, genpasswd, valid_email, \ @@ -727,31 +727,47 @@ def banned(self, **params): } def at_door_confirmation(self, session, message='', qr_code_id='', **params): - # Currently the cart feature relies on attendee accounts and "At Door Pending Status" - # We will want real "carts" later so we can support group check-in for prereg attendees - cart = PreregCart(listify(PreregCart.unpaid_preregs.values())) used_codes = defaultdict(int) registrations_list = [] - account = None + account = session.current_attendee_account() if c.ATTENDEE_ACCOUNTS_ENABLED else None + account_pickup_group = session.query(BadgePickupGroup).filter_by(account_id=account.id).first() if account else None + pickup_group = None if not listify(PreregCart.unpaid_preregs.values()): - if c.ATTENDEE_ACCOUNTS_ENABLED and qr_code_id: - account = session.query(AttendeeAccount).filter_by(public_id=qr_code_id).first() - for attendee in account.at_door_attendees: + if qr_code_id: + current_pickup_group = session.query(BadgePickupGroup).filter_by(public_id=qr_code_id).first() + for attendee in current_pickup_group.attendees: registrations_list.append(attendee.full_name) elif c.ATTENDEE_ACCOUNTS_ENABLED: - account = session.current_attendee_account() - qr_code_id = qr_code_id or (account.public_id if account else '') + qr_code_id = qr_code_id or (account_pickup_group.public_id if account_pickup_group else '') if not qr_code_id: raise HTTPRedirect('form') + else: + if c.ATTENDEE_ACCOUNTS_ENABLED: + if account_pickup_group and not account_pickup_group.checked_in_attendees: + pickup_group = account_pickup_group + + if not pickup_group and len(cart.attendees) > 1: + pickup_group = BadgePickupGroup() + if not account_pickup_group and c.ATTENDEE_ACCOUNTS_ENABLED: + pickup_group.account_id = account.id + session.add(pickup_group) + session.commit() + session.refresh(pickup_group) + + if pickup_group: + qr_code_id = pickup_group.public_id for attendee in cart.attendees: registrations_list.append(attendee.full_name) if c.ATTENDEE_ACCOUNTS_ENABLED: - attendee.badge_status = c.AT_DOOR_PENDING_STATUS - # Setting this makes the badge count against our badge cap (does not work if at-door pending status is used) + session.add_attendee_to_account(attendee, account) + if pickup_group: + pickup_group.attendees.append(attendee) + + # Setting this makes the badge count against our badge cap attendee.paid = c.PENDING if attendee.id in cherrypy.session.setdefault('imported_attendee_ids', {}): @@ -1084,7 +1100,7 @@ def paid_preregistrations(self, session, total_cost=None, message=''): for prereg in preregs: receipt = session.get_receipt_by_model(prereg) - if isinstance(prereg, Attendee): + if isinstance(prereg, Attendee) and receipt: session.refresh_receipt_and_model(prereg, is_prereg=True) session.update_paid_from_receipt(prereg, receipt) @@ -1828,10 +1844,12 @@ def homepage(self, session, message='', **params): account = session.query(AttendeeAccount).get(cherrypy.session.get('attendee_account_id')) attendees_who_owe_money = {} - for attendee in account.attendees: - receipt = session.get_receipt_by_model(attendee) - if receipt and receipt.current_amount_owed and attendee.is_valid: - attendees_who_owe_money[attendee.full_name] = receipt.current_amount_owed + if not c.AFTER_PREREG_TAKEDOWN or not c.SPIN_TERMINAL_AUTH_KEY: + for attendee in account.valid_attendees: + if attendee not in account.at_door_pending_attendees: + receipt = session.get_receipt_by_model(attendee) + if receipt and receipt.current_amount_owed: + attendees_who_owe_money[attendee.full_name] = receipt.current_amount_owed account_attendee = None account_attendees = session.valid_attendees().filter(~Attendee.badge_status.in_([c.REFUNDED_STATUS, @@ -1918,7 +1936,8 @@ def confirm(self, session, message='', return_to='confirm', undoing_extra='', ** page = ('badge_updated?id=' + attendee.id + '&') if return_to == 'confirm' else (return_to + '?') if not receipt: receipt = session.get_receipt_by_model(attendee, create_if_none="DEFAULT") - if not receipt.current_amount_owed or receipt.pending_total: + if not receipt.current_amount_owed or receipt.pending_total or (c.AFTER_PREREG_TAKEDOWN + and c.SPIN_TERMINAL_AUTH_KEY): raise HTTPRedirect(page + 'message=' + message) elif receipt.current_amount_owed and not receipt.pending_total: # TODO: could use some cleanup, needed because of how we handle the placeholder attr @@ -1933,7 +1952,8 @@ def confirm(self, session, message='', return_to='confirm', undoing_extra='', ** elif not message and not c.ATTENDEE_ACCOUNTS_ENABLED and attendee.badge_status == c.COMPLETED_STATUS: message = 'You are already registered but you may update your information with this form.' - if receipt and receipt.current_amount_owed and not receipt.pending_total and not attendee.placeholder: + if receipt and receipt.current_amount_owed and not receipt.pending_total and not attendee.placeholder and ( + c.BEFORE_PREREG_TAKEDOWN or not c.SPIN_TERMINAL_AUTH_KEY): raise HTTPRedirect('new_badge_payment?id={}&message={}&return_to={}', attendee.id, message, return_to) return { @@ -2046,7 +2066,7 @@ def purchase_upgrades(self, session, id, **params): except Exception: return {'error': "Cannot find your receipt, please contact registration"} - if receipt.open_receipt_items and receipt.current_amount_owed: + if receipt.open_purchase_items and receipt.current_amount_owed: return {'error': "You already have an outstanding balance, please refresh the page to pay \ for your current items or contact {}".format(email_only(c.REGDESK_EMAIL))} @@ -2175,6 +2195,8 @@ def process_attendee_payment(self, session, id, receipt_id, message='', **params @id_required(Attendee) @requires_account(Attendee) def new_badge_payment(self, session, id, return_to, message=''): + if c.AFTER_PREREG_TAKEDOWN and c.SPIN_TERMINAL_AUTH_KEY: + raise HTTPRedirect('confirm?id={}&message={}', id, "Please go to Registration to pay for this badge.") attendee = session.attendee(id) return { 'attendee': attendee, diff --git a/uber/site_sections/reg_admin.py b/uber/site_sections/reg_admin.py index ebe5b23b2..713f76ce2 100644 --- a/uber/site_sections/reg_admin.py +++ b/uber/site_sections/reg_admin.py @@ -3,27 +3,59 @@ import cherrypy import json +import math import re from datetime import datetime from decimal import Decimal from pockets import groupify from pockets.autolog import log -from sqlalchemy import and_, or_, func +from residue import CoerceUTF8 as UnicodeText +from sqlalchemy import or_, func, not_, and_ from sqlalchemy.orm import joinedload, raiseload, subqueryload from sqlalchemy.orm.exc import NoResultFound from uber.config import c -from uber.custom_tags import datetime_local_filter, pluralize, format_currency, readable_join +from uber.custom_tags import datetime_local_filter, time_day_local, pluralize, format_currency, readable_join from uber.decorators import ajax, all_renderable, csv_file, not_site_mappable, site_mappable from uber.errors import HTTPRedirect from uber.models import AdminAccount, ApiJob, ArtShowApplication, Attendee, Group, ModelReceipt, ReceiptItem, \ - ReceiptTransaction, Tracking, WorkstationAssignment + ReceiptInfo, ReceiptTransaction, Tracking, WorkstationAssignment, EscalationTicket from uber.site_sections import devtools from uber.utils import check, get_api_service_from_server, normalize_email, normalize_email_legacy, valid_email, \ - TaskUtils + TaskUtils, Order from uber.payments import ReceiptManager, TransactionRequest, SpinTerminalRequest +def _search(all_processor_txns, text): + receipt_txns = all_processor_txns.outerjoin(ReceiptTransaction.receipt_info) + check_list = [] + + terms = text.split() + if len(terms) == 1 \ + and re.match('^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$', terms[0]): + id_list = [ReceiptTransaction.id == terms[0], + ReceiptTransaction.receipt_id == terms[0], + ReceiptTransaction.receipt_info_id == terms[0], + ReceiptTransaction.refunded_txn_id == terms[0], + ModelReceipt.owner_id == terms[0] + ] + + return receipt_txns.filter(or_(*id_list)), '' + + for attr in [col for col in ReceiptTransaction().__table__.columns if isinstance(col.type, UnicodeText)]: + if attr != ReceiptTransaction.desc: + check_list.append(attr.ilike('%' + text + '%')) + + check_list.append(ModelReceipt.owner_model == text) + if terms[0].isdigit() and len(terms[0]) < 5: + check_list.append(ModelReceipt.invoice_num == text) + + for attr in [ReceiptInfo.terminal_id, ReceiptInfo.reference_id]: + check_list.append(and_(ReceiptTransaction.receipt_info_id != None, attr.ilike('%' + text + '%'))) + + return receipt_txns.filter(or_(*check_list)), '' + + def check_custom_receipt_item_txn(params, is_txn=False): from decimal import Decimal if not params.get('amount'): @@ -101,7 +133,109 @@ def assign_account_by_email(session, attendee, account_email): @all_renderable() class Root: - def receipt_items(self, session, id, message=''): + def automated_transactions(self, session, message='', page='0', search_text='', order='-added', closed=''): + if c.DEV_BOX and not int(page): + page = 1 + + all_processor_txns = session.query(ReceiptTransaction).filter(or_( + ReceiptTransaction.intent_id != '', + ReceiptTransaction.charge_id != '', + ReceiptTransaction.refund_id != '')) + if not closed: + all_processor_txns = all_processor_txns.join(ModelReceipt).filter(ModelReceipt.closed == None) + total_count = all_processor_txns.count() + payment_count = all_processor_txns.filter(ReceiptTransaction.amount > 0).count() + refund_count = all_processor_txns.filter(ReceiptTransaction.amount < 0).count() + count = 0 + search_text = search_text.strip() + if search_text: + search_results, message = _search(all_processor_txns, search_text) + if search_results and search_results.count(): + receipt_txns = search_results + count = receipt_txns.count() + if count == total_count: + message = 'Every transaction matched this search.' + elif not message: + message = 'No matches found.' + if not count: + receipt_txns = all_processor_txns.outerjoin(ReceiptTransaction.receipt_info) + count = receipt_txns.count() + + receipt_txns = receipt_txns.order(order) + + page = int(page) + if search_text: + page = page or 1 + + pages = range(1, int(math.ceil(count / 100)) + 1) + receipt_txns = receipt_txns[-100 + 100*page: 100*page] if page else [] + + return { + 'message': message if isinstance(message, str) else message[-1], + 'page': page, + 'pages': pages, + 'closed': closed, + 'search_text': search_text, + 'search_results': bool(search_text), + 'receipt_txns': receipt_txns, + 'order': Order(order), + 'search_count': count, + 'total_count': total_count, + 'payment_count': payment_count, + 'refund_count': refund_count, + 'processors': { + c.STRIPE: "Authorize.net" if c.AUTHORIZENET_LOGIN_ID else "Stripe", + c.SQUARE: "SPIn" if c.SPIN_TERMINAL_AUTH_KEY else "Square", + c.MANUAL: "Stripe"}, + } + + def escalation_tickets(self, session, message='', closed=''): + escalation_tickets = session.query(EscalationTicket) + if not closed: + escalation_tickets = escalation_tickets.filter(EscalationTicket.resolved == None) + return { + 'message': message, + 'closed': closed, + 'total_count': escalation_tickets.count(), + 'tickets': escalation_tickets.options(joinedload(EscalationTicket.attendees)) + } + + @ajax + def update_escalation_ticket(self, session, id, resolve=None, unresolve=None, **params): + try: + ticket = session.escalation_ticket(id) + except NoResultFound: + return {'success': False, 'message': "Ticket not found!"} + + ticket.apply(params) + message = "Ticket updated" + if resolve: + ticket.resolved = datetime.now() + message = message + " and resolved" + elif unresolve: + ticket.resolved = None + message = message + " and marked as unresolved" + + session.commit() + + return { + 'success': True, + 'message': message + ".", + 'resolved': '' if not ticket.resolved else f"Resolved {time_day_local(ticket.resolved)}" + } + + @ajax + def delete_escalation_ticket(self, session, id, **params): + try: + ticket = session.escalation_ticket(id) + except NoResultFound: + return {'success': False, 'message': "Ticket not found!"} + + session.delete(ticket) + session.commit() + return {'success': True, 'message': "Ticket deleted."} + + def receipt_items(self, session, id, message='', highlight_id=''): group_leader_receipt = None group_processing_fee = 0 refund_txn_candidates = [] @@ -121,7 +255,10 @@ def receipt_items(self, session, id, message=''): except NoResultFound: model = session.art_show_application(id) - receipt = session.get_receipt_by_model(model) + options = [joinedload(ModelReceipt.receipt_items), + joinedload(ModelReceipt.receipt_txns).joinedload(ReceiptTransaction.receipt_info)] + + receipt = session.get_receipt_by_model(model, options=options) if receipt: receipt.changes = session.query(Tracking).filter( or_(Tracking.links.like('%model_receipt({})%' @@ -137,7 +274,7 @@ def receipt_items(self, session, id, message=''): other_receipts = set() if isinstance(model, Attendee): for app in model.art_show_applications: - other_receipt = session.get_receipt_by_model(app) + other_receipt = session.get_receipt_by_model(app, options=options) if other_receipt: other_receipt.changes = session.query(Tracking).filter( or_(Tracking.links.like('%model_receipt({})%' @@ -150,14 +287,15 @@ def receipt_items(self, session, id, message=''): 'attendee': model if isinstance(model, Attendee) else None, 'group': model if isinstance(model, Group) else None, 'art_show_app': model if isinstance(model, ArtShowApplication) else None, - 'group_leader_receipt': group_leader_receipt, + 'group_leader_receipt_id': group_leader_receipt.id if group_leader_receipt else None, 'group_processing_fee': group_processing_fee, 'receipt': receipt, 'other_receipts': other_receipts, 'closed_receipts': session.query(ModelReceipt).filter(ModelReceipt.owner_id == id, ModelReceipt.owner_model == model.__class__.__name__, - ModelReceipt.closed != None).all(), # noqa: E711 + ModelReceipt.closed != None).options(*options).all(), # noqa: E711 'message': message, + 'highlight_id': highlight_id, 'processors': { c.STRIPE: "Authorize.net" if c.AUTHORIZENET_LOGIN_ID else "Stripe", c.SQUARE: "SPIn" if c.SPIN_TERMINAL_AUTH_KEY else "Square", @@ -332,7 +470,7 @@ def comp_refund_receipt_item(self, session, id='', **params): session.add(txn) txn.refunded = txn.amount refund_id = str(refund.response_id) or getattr(refund, 'ref_id') - refund.receipt_manager.create_refund_transaction(txn.receipt, + refund.receipt_manager.create_refund_transaction(txn, "Automatic void of transaction " + txn.stripe_id, refund_id, txn.amount, method=refund.method) @@ -351,6 +489,7 @@ def comp_refund_receipt_item(self, session, id='', **params): item.comped = True session.commit() + session.check_receipt_closed(item.receipt) return {'message': "Item comped{}".format(message_add)} @@ -403,6 +542,7 @@ def undo_refund_receipt_item(self, session, id='', **params): item.reverted = True session.commit() + session.check_receipt_closed(item.receipt) return {'message': "Item reverted{}".format(message_add)} @@ -508,11 +648,11 @@ def refresh_receipt_txn(self, session, id='', **params): refund_id=last_refund_id, department=txn.receipt.default_department, amount=(new_amount - prior_amount) * -1, - receipt_items=txn.receipt.open_receipt_items, + receipt_items=txn.receipt.open_credit_items, desc="Automatic refund of Stripe transaction " + txn.stripe_id, who=AdminAccount.admin_name() or 'non-admin' )) - for item in txn.receipt.open_receipt_items: + for item in txn.receipt.open_credit_items: item.closed = datetime.now() session.commit() @@ -524,23 +664,6 @@ def refresh_receipt_txn(self, session, id='', **params): 'message': ' '.join(messages) if messages else "Transaction already up-to-date.", } - @ajax - def refund_receipt_txn(self, session, id, amount, **params): - txn = session.receipt_transaction(id) - return {'error': float(params.get('amount', 0)) * 100} - - refund = TransactionRequest(txn.receipt, amount=Decimal(amount) * 100) - error = refund.refund_or_cancel(txn) - if error: - return {'error': error} - - return { - 'refunded': id, - 'message': "Successfully refunded {}".format(format_currency(txn.refunded)), - 'refund_total': txn.refunded, - 'new_total': txn.receipt.total_str, - } - @ajax def resend_receipt(self, session, id, **params): from uber.tasks.registration import send_receipt_email @@ -582,6 +705,8 @@ def settle_up(self, session, id=''): session.add_all(refund.get_receipt_items_to_add()) session.commit() + session.check_receipt_closed(receipt) + raise HTTPRedirect('../reg_admin/receipt_items?id={}&message={}', session.get_model_by_receipt(receipt).id, f"{format_currency(refund_amount / 100)} refunded.") @@ -709,6 +834,7 @@ def process_full_refund(self, session, id='', attendee_id='', group_id='', exclu raise HTTPRedirect('../reg_admin/receipt_items?id={}&message={}', attendee_id or group_id, message) session.add_all(refund.get_receipt_items_to_add()) session.commit() + session.check_receipt_closed(receipt) message_end = f" Their group leader was refunded {format_currency(group_refund_amount / 100)}."\ if group_refund_amount else "" @@ -905,6 +1031,7 @@ def invalidate_badge(self, session, id): return {'invalidated': id} + @site_mappable def manage_workstations(self, session, message='', **params): if cherrypy.request.method == 'POST': skipped_reg_stations = [] @@ -991,22 +1118,6 @@ def update_workstation(self, session, id, **params): session.commit() return {'success': True, 'message': "Workstation assignment updated."} - def close_out_check(self, session): - closeout_requests = c.REDIS_STORE.hgetall(c.REDIS_PREFIX + 'closeout_requests') - processed_list = [] - - for request_timestamp, terminal_ids in closeout_requests.items(): - closeout_report = c.REDIS_STORE.hget(c.REDIS_PREFIX + 'completed_closeout_requests', request_timestamp) - if closeout_report: - # TODO: Finish the report part of this - report_dict = json.loads(closeout_report) - log.debug(report_dict) - return "All terminals have been closed out!" - for terminal_id in terminal_ids: - if c.REDIS_STORE.hget(c.REDIS_PREFIX + 'spin_terminal_closeout:' + terminal_id, - 'last_request_timestamp'): - processed_list.append(terminal_id) - def close_out_terminals(self, session, **params): from uber.tasks.registration import close_out_terminals diff --git a/uber/site_sections/reg_reports.py b/uber/site_sections/reg_reports.py index 02907ebd9..2c70f806e 100644 --- a/uber/site_sections/reg_reports.py +++ b/uber/site_sections/reg_reports.py @@ -1,10 +1,30 @@ +import six +import calendar from collections import defaultdict from sqlalchemy import or_, and_ from sqlalchemy.sql import func +from sqlalchemy.sql.expression import literal from uber.config import c -from uber.decorators import all_renderable, log_pageview, streamable -from uber.models import Attendee, Group, PromoCode, ReceiptTransaction, ModelReceipt, ReceiptItem +from uber.decorators import all_renderable, log_pageview, csv_file +from uber.models import Attendee, Group, PromoCode, ReceiptTransaction, ModelReceipt, ReceiptItem, Tracking +from uber.utils import localize_datetime + + +def date_trunc_hour(*args, **kwargs): + # sqlite doesn't support date_trunc + if c.SQLALCHEMY_URL.startswith('sqlite'): + return func.strftime(literal('%Y-%m-%d %H:00'), *args, **kwargs) + else: + return func.date_trunc(literal('hour'), *args, **kwargs) + + +def checkins_by_hour_query(session): + return session.query(date_trunc_hour(Attendee.checked_in), + func.count(date_trunc_hour(Attendee.checked_in))) \ + .filter(Attendee.checked_in.isnot(None)) \ + .group_by(date_trunc_hour(Attendee.checked_in)) \ + .order_by(date_trunc_hour(Attendee.checked_in)) @all_renderable() @@ -60,8 +80,7 @@ def attendee_receipt_discrepancies(self, session, include_pending=False, page=1) offset = (page - 1) * 50 if include_pending: - filter = or_(Attendee.badge_status.in_([c.PENDING_STATUS, c.AT_DOOR_PENDING_STATUS]), - Attendee.is_valid == True) # noqa: E712 + filter = or_(Attendee.badge_status == c.PENDING_STATUS, Attendee.is_valid == True) # noqa: E712 else: filter = Attendee.is_valid == True # noqa: E712 @@ -130,3 +149,67 @@ def self_service_refunds(self, session): 'refund_models': refund_models, 'counts': counts, } + + def checkins_by_hour(self, session): + query_result = checkins_by_hour_query(session).all() + + hourly_checkins = dict() + daily_checkins = defaultdict(int) + outside_event_checkins = [] + for result in query_result: + localized_hour = localize_datetime(result[0]) + hourly_checkins[localized_hour] = result[1] + if localized_hour > c.EPOCH and localized_hour < c.ESCHATON: + daily_checkins[calendar.day_name[localized_hour.weekday()]] += result[1] + else: + outside_event_checkins.append(localized_hour) + + return { + 'checkins': hourly_checkins, + 'daily_checkins': daily_checkins, + 'outside_event_checkins': outside_event_checkins, + } + + @csv_file + def checkins_by_hour_csv(self, out, session): + out.writerow(["Time", "# Checked In"]) + query_result = checkins_by_hour_query(session).all() + + for result in query_result: + hour = localize_datetime(result[0]) + count = result[1] + out.writerow([hour, count]) + + @csv_file + def checkins_by_admin_by_hour(self, out, session): + header = ["Time", "Total Checked In"] + admins = session.query(Tracking.who).filter(Tracking.action == c.UPDATED, + Tracking.model == "Attendee", + Tracking.data.contains("checked_in='None -> datetime") + ).group_by(Tracking.who).order_by(Tracking.who).distinct().all() + for admin in admins: + if not isinstance(admin, six.string_types): + admin = admin[0] # SQLAlchemy quirk + + header.append(f"{admin} # Checked In") + + out.writerow(header) + + query_result = checkins_by_hour_query(session).all() + + for result in query_result: + hour = localize_datetime(result[0]) + count = result[1] + row = [hour, count] + + hour_admins = session.query( + Tracking.who, + func.count(Tracking.who)).filter( + date_trunc_hour(Tracking.when) == result[0], + Tracking.action == c.UPDATED, + Tracking.model == "Attendee", + Tracking.data.contains("checked_in='None -> datetime") + ).group_by(Tracking.who).order_by(Tracking.who) + for admin, admin_count in hour_admins: + row.append(admin_count) + out.writerow(row) diff --git a/uber/site_sections/registration.py b/uber/site_sections/registration.py index dabb2fef1..88068d5f2 100644 --- a/uber/site_sections/registration.py +++ b/uber/site_sections/registration.py @@ -23,9 +23,9 @@ requires_account, site_mappable, public from uber.errors import HTTPRedirect from uber.forms import load_forms -from uber.models import Attendee, AttendeeAccount, AdminAccount, Email, Group, Job, PageViewTracking, PrintJob, \ - PromoCode, PromoCodeGroup, ReportTracking, Sale, Session, Shift, Tracking, ReceiptTransaction, \ - WorkstationAssignment +from uber.models import (Attendee, AttendeeAccount, AdminAccount, Email, EscalationTicket, Group, Job, PageViewTracking, + PrintJob, PromoCode, PromoCodeGroup, ReportTracking, Sale, Session, Shift, Tracking, ReceiptTransaction, + WorkstationAssignment) from uber.site_sections.preregistration import check_if_can_reg from uber.utils import add_opt, check, check_pii_consent, get_page, hour_day_format, \ localized_now, Order, validate_model @@ -100,8 +100,6 @@ def index(self, session, message='', page='0', search_text='', uploaded_id='', o ).filter_by(reg_station_id=reg_station_id or -1).first() status_list = [c.NEW_STATUS, c.COMPLETED_STATUS, c.WATCHED_STATUS, c.UNAPPROVED_DEALER_STATUS] - if c.AT_THE_CON: - status_list.append(c.AT_DOOR_PENDING_STATUS) filter = [Attendee.badge_status.in_(status_list)] if not invalid else [] total_count = session.query(Attendee.id).filter(*filter).count() count = 0 @@ -288,7 +286,7 @@ def form(self, session, message='', return_to='', **params): } # noqa: E711 @ajax - def start_terminal_payment(self, session, model_id='', account_id='', **params): + def start_terminal_payment(self, session, model_id='', pickup_group_id='', **params): from uber.tasks.registration import process_terminal_sale error, terminal_id = session.get_assigned_terminal_id() @@ -310,7 +308,7 @@ def start_terminal_payment(self, session, model_id='', account_id='', **params): process_terminal_sale.delay(workstation_num=cherrypy.session.get('reg_station'), terminal_id=terminal_id, model_id=model_id, - account_id=account_id, + pickup_group_id=pickup_group_id, description=description) return {'success': True} @@ -336,7 +334,7 @@ def check_txn_status(self, session, intent_id='', **params): @ajax def poll_terminal_payment(self, session, **params): - from spin_rest_utils import utils as spin_rest_utils + import uber.spin_rest_utils as spin_rest_utils error, terminal_id = session.get_assigned_terminal_id() if error: @@ -608,15 +606,12 @@ def print_and_check_in_badges(self, session, message='', printer_id='', minor_pr from uber.site_sections.badge_printing import pre_print_check id = params.get('id', None) - if id in [None, '', 'None']: - account = AttendeeAccount() - else: - account = session.attendee_account(id) + pickup_group = session.badge_pickup_group(id) if not printer_id and not minor_printer_id: return {'success': False, 'message': 'You must set a printer ID.'} - if len(account.at_door_under_18s) != len(account.at_door_attendees) and not printer_id: + if len(pickup_group.under_18_badges) != len(pickup_group.check_inable_attendees) and not printer_id: return {'success': False, 'message': 'You must set a printer ID for the adult badges that are being checked in.'} @@ -627,7 +622,7 @@ def print_and_check_in_badges(self, session, message='', printer_id='', minor_pr cherrypy.session['cart_success_list'] = [] cherrypy.session['cart_printer_error_list'] = [] - for attendee in account.at_door_attendees: + for attendee in pickup_group.check_inable_attendees: success, message = pre_print_check(session, attendee, printer_id, dry_run=True, **params) if not success: @@ -642,8 +637,6 @@ def print_and_check_in_badges(self, session, message='', printer_id='', minor_pr session.add_to_print_queue(attendee, printer_id, cherrypy.session.get('reg_station'), params.get('fee_amount')) - if attendee.badge_status == c.AT_DOOR_PENDING_STATUS: - attendee.badge_status = c.NEW_STATUS attendee.checked_in = localized_now() checked_in[attendee.id] = { 'badge': attendee.badge, @@ -671,17 +664,17 @@ def print_and_check_in_badges(self, session, message='', printer_id='', minor_pr } return {'success': True, 'message': success_message, 'checked_in': checked_in} - def minor_check_form(self, session, printer_id, attendee_id='', account_id='', reprint_fee=0, num_adults=0): - if account_id: - account = session.attendee_account(account_id) - attendees = account.at_door_under_18s + def minor_check_form(self, session, printer_id, attendee_id='', pickup_group_id='', reprint_fee=0, num_adults=0): + if pickup_group_id: + pickup_group = session.badge_pickup_group(pickup_group_id) + attendees = pickup_group.under_18_badges elif attendee_id: attendee = session.attendee(attendee_id) attendees = [attendee] return { 'attendees': attendees, - 'account_id': account_id, + 'pickup_group_id': pickup_group_id, 'attendee_id': attendee_id, 'printer_id': printer_id, 'reprint_fee': reprint_fee, @@ -689,10 +682,10 @@ def minor_check_form(self, session, printer_id, attendee_id='', account_id='', r } @ajax_gettable - def complete_minor_check(self, session, printer_id, attendee_id='', account_id='', reprint_fee=0): - if account_id: - account = session.attendee_account(account_id) - attendees = account.at_door_under_18s + def complete_minor_check(self, session, printer_id, attendee_id='', pickup_group_id='', reprint_fee=0): + if pickup_group_id: + pickup_group = session.badge_pickup_group(pickup_group_id) + attendees = pickup_group.under_18_badges elif attendee_id: attendee = session.attendee(attendee_id) attendees = [attendee] @@ -704,14 +697,12 @@ def complete_minor_check(self, session, printer_id, attendee_id='', account_id=' for attendee in attendees: _, errors = session.add_to_print_queue(attendee, printer_id, cherrypy.session.get('reg_station'), reprint_fee) - if errors and not account_id: + if errors and not pickup_group_id: return {'success': False, 'message': "
".join(errors)} elif errors: printer_messages.append(f"There was a problem with printing {attendee.full_name}'s " f"badge: {' '.join(errors)}") else: - if attendee.badge_status == c.AT_DOOR_PENDING_STATUS: - attendee.badge_status = c.NEW_STATUS attendee.checked_in = localized_now() checked_in[attendee.id] = { 'badge': attendee.badge, @@ -757,34 +748,32 @@ def check_in_form(self, session, id): 'forms': forms, } - def check_in_cart_form(self, session, id): - account = session.attendee_account(id) + def check_in_group_form(self, session, id): + pickup_group = session.badge_pickup_group(id) reg_station_id = cherrypy.session.get('reg_station', '') workstation_assignment = session.query(WorkstationAssignment).filter_by( reg_station_id=reg_station_id or -1).first() total_cost = 0 - for attendee in account.at_door_attendees: + for attendee in pickup_group.pending_paid_attendees: receipt = session.get_receipt_by_model(attendee, create_if_none="DEFAULT") total_cost += receipt.current_amount_owed return { - 'account': account, + 'pickup_group': pickup_group, + 'checked_in_names': [attendee.full_name for attendee in pickup_group.checked_in_attendees], 'total_cost': total_cost, 'workstation_assignment': workstation_assignment, } @ajax - def remove_attendee_from_cart(self, session, **params): + def remove_attendee_from_pickup_group(self, session, **params): id = params.get('id', None) if id in [None, '', 'None']: return {'error': "No ID provided."} attendee = session.attendee(id) - if attendee.badge_status != c.AT_DOOR_PENDING_STATUS: - return {'error': f"This attendee's badge status is actually {attendee.badge_status_label}. Please refresh."} - - attendee.badge_status = c.NEW_STATUS + attendee.badge_pickup_group = None session.commit() return {'success': True} @@ -807,6 +796,28 @@ def save_no_check_in(self, session, **params): return {'success': True} + @check_for_encrypted_badge_num + @ajax + def save_no_check_in_all(self, session, message='', **params): + if 'id' in params: + for id in params.get('id'): + attendee_params = {key.replace(f'_{id}', ''): val for key, val in params.items() if f'_{id}' in key} + attendee_params['id'] = id + + attendee = session.attendee(id) + + validations = json.loads(self.validate_attendee(form_list=['CheckInForm'], **attendee_params)) + if 'error' in validations: + session.rollback() + message = ' '.join([item for sublist in validations['error'].values() for item in sublist]) + return {'success': False, + 'message': f"Could not save attendee {attendee.full_name}: {message}"} + else: + save_attendee(session, attendee, attendee_params) + session.commit() + return {'success': True, + 'message': "Attendees updated!"} + @check_for_encrypted_badge_num @ajax def check_in(self, session, message='', **params): @@ -826,8 +837,6 @@ def check_in(self, session, message='', **params): success, increment = False, False attendee.checked_in = localized_now() - if attendee.badge_status == c.AT_DOOR_PENDING_STATUS: - attendee.badge_status = c.NEW_STATUS success = True session.commit() increment = True @@ -852,6 +861,28 @@ def undo_checkin(self, session, id, pre_badge): session.commit() return 'Attendee successfully un-checked-in' + @ajax + def create_escalation_ticket(self, session, attendee_ids='', description='', **params): + if not attendee_ids: + return {'success': False, 'message': "Please select at least one person to make an escalation ticket for."} + if not description: + return {'success': False, 'message': "Please enter a description for the escalation ticket."} + + attendee_ids = json.loads(attendee_ids) + + ticket = EscalationTicket(description=description) + for id in attendee_ids: + try: + attendee = session.attendee(id) + except NoResultFound: + return {'success': False, 'message': f"Cannot find attendee for ID {id}! Please refresh and try again."} + ticket.attendees.append(attendee) + + session.add(ticket) + session.commit() + + return {'success': True, 'message': "Escalation ticket created."} + def recent(self, session): return {'attendees': session.query(Attendee) .options(joinedload(Attendee.group)) @@ -1065,16 +1096,16 @@ def mark_as_paid(self, session, id, payment_method): if not cherrypy.session.get('reg_station'): return {'success': False, 'message': 'You must set a workstation ID to take payments.'} - account = None + pickup_group = None try: attendee = session.attendee(id) attendees = [attendee] except NoResultFound: - account = session.attendee_account(id) + pickup_group = session.badge_pickup_group(id) - if account: - attendees = account.at_door_attendees + if pickup_group: + attendees = pickup_group.pending_paid_attendees for attendee in attendees: receipt = session.get_receipt_by_model(attendee, create_if_none="DEFAULT") @@ -1096,9 +1127,10 @@ def mark_as_paid(self, session, id, payment_method): attendee.reg_station = cherrypy.session.get('reg_station') session.commit() + session.check_receipt_closed(receipt) return { 'success': True, - 'message': 'Attendee{} marked as paid.'.format('s' if account else ''), + 'message': 'Attendee{} marked as paid.'.format('s' if pickup_group else ''), 'id': attendee.id } diff --git a/uber/site_sections/saml.py b/uber/site_sections/saml.py index 7335be40c..6f5e22aa0 100644 --- a/uber/site_sections/saml.py +++ b/uber/site_sections/saml.py @@ -91,6 +91,10 @@ def acs(self, session, **params): if not admin_account and not account: raise HTTPRedirect("../landing/index?message={}", message) + # Forcibly exit any volunteer kiosks that were running + cherrypy.session.pop('kiosk_operator_id', None) + cherrypy.session.pop('kiosk_supervisor_id', None) + if admin_account: attendee_to_update = admin_account.attendee else: diff --git a/uber/site_sections/staffing.py b/uber/site_sections/staffing.py index 3e58ca905..50baa658a 100644 --- a/uber/site_sections/staffing.py +++ b/uber/site_sections/staffing.py @@ -215,22 +215,17 @@ def hotel(self, session, message='', decline=None, **params): } @check_shutdown - def shifts(self, session, start=''): + def shifts(self, session, **params): volunteer = session.logged_in_volunteer() has_setup = volunteer.can_work_setup or any(d.is_setup_approval_exempt for d in volunteer.assigned_depts) has_teardown = volunteer.can_work_teardown or any( d.is_teardown_approval_exempt for d in volunteer.assigned_depts) - if not start and has_setup: + if has_setup: start = c.SETUP_JOB_START - elif not start: - start = c.EPOCH else: - if start.endswith('Z'): - start = datetime.strptime(start[:-1], '%Y-%m-%dT%H:%M:%S.%f') - else: - start = datetime.strptime(start, '%Y-%m-%dT%H:%M:%S.%f') + start = c.EPOCH end = c.TEARDOWN_JOB_END if has_teardown else c.ESCHATON diff --git a/uber/site_sections/statistics.py b/uber/site_sections/statistics.py index c42b82283..8ce1e2257 100644 --- a/uber/site_sections/statistics.py +++ b/uber/site_sections/statistics.py @@ -10,8 +10,7 @@ from uber.config import c from uber.decorators import ajax, all_renderable, csv_file, not_site_mappable from uber.jinja import JinjaEnv -from uber.models import Attendee, Group, PromoCode, Tracking -from uber.utils import localize_datetime +from uber.models import Attendee, Group, PromoCode @JinjaEnv.jinja_filter @@ -19,14 +18,6 @@ def get_count(counter, key): return counter.get(key) -def date_trunc_hour(*args, **kwargs): - # sqlite doesn't support date_trunc - if c.SQLALCHEMY_URL.startswith('sqlite'): - return func.strftime(literal('%Y-%m-%d %H:00'), *args, **kwargs) - else: - return func.date_trunc(literal('hour'), *args, **kwargs) - - class RegistrationDataOneYear: def __init__(self): self.event_name = "" @@ -226,23 +217,6 @@ def index(self, session): 'counts': counts, } - @csv_file - def checkins_by_hour(self, out, session): - out.writerow(["Time", "# Checked In"]) - query_result = session.query( - date_trunc_hour(Attendee.checked_in), - func.count(date_trunc_hour(Attendee.checked_in)) - ) \ - .filter(Attendee.checked_in.isnot(None)) \ - .group_by(date_trunc_hour(Attendee.checked_in)) \ - .order_by(date_trunc_hour(Attendee.checked_in)) \ - .all() - - for result in query_result: - hour = localize_datetime(result[0]) - count = result[1] - out.writerow([hour, count]) - def badges_sold(self, session): graph_data_current_year = RegistrationDataOneYear() graph_data_current_year.query_current_year(session) @@ -251,46 +225,6 @@ def badges_sold(self, session): 'current_registrations': graph_data_current_year.dump_data(), } - @csv_file - def checkins_by_admin_by_hour(self, out, session): - header = ["Time", "Total Checked In"] - admins = session.query(Tracking.who).filter(Tracking.action == c.UPDATED, - Tracking.model == "Attendee", - Tracking.data.contains("checked_in='None -> datetime") - ).group_by(Tracking.who).order_by(Tracking.who).distinct().all() - for admin in admins: - if not isinstance(admin, six.string_types): - admin = admin[0] # SQLAlchemy quirk - - header.append(f"{admin} # Checked In") - - out.writerow(header) - - query_result = session.query( - date_trunc_hour(Attendee.checked_in), - func.count(date_trunc_hour(Attendee.checked_in)) - ) \ - .filter(Attendee.checked_in.isnot(None)) \ - .group_by(date_trunc_hour(Attendee.checked_in)) \ - .order_by(date_trunc_hour(Attendee.checked_in)) \ - .all() - - for result in query_result: - hour = localize_datetime(result[0]) - count = result[1] - row = [hour, count] - for admin in admins: - if not isinstance(admin, six.string_types): - admin = admin[0] # SQLAlchemy quirk - admin_count = session.query(Tracking.which).filter( - date_trunc_hour(Tracking.when) == result[0], - Tracking.who == admin, - Tracking.action == c.UPDATED, - Tracking.model == "Attendee", - Tracking.data.contains("checked_in='None -> datetime")).group_by(Tracking.which).count() - row.append(admin_count) - out.writerow(row) - if c.MAPS_ENABLED: from uszipcode import SearchEngine diff --git a/uber/spin_rest_utils.py b/uber/spin_rest_utils.py index af6ba09af..74d110f5c 100644 --- a/uber/spin_rest_utils.py +++ b/uber/spin_rest_utils.py @@ -45,10 +45,12 @@ def signature_from_response(response_json): def txn_info_from_response(response_json): txn_info = {} + txn_info['response'] = {} txn_info['amount'] = response_json[strings.get("dollar_amounts")][strings.get("total_amount")] if response_json.get(strings.get("dollar_amounts")) else 0 - txn_info['code'] = response_json.get(strings.get("auth_code"), '') - txn_info['response'] = response_json[strings.get("gen_resp")][strings.get("det_msg")] if response_json.get(strings.get("gen_resp")) else '' + txn_info['response']['auth_code'] = response_json.get(strings.get("auth_code"), '') + txn_info['response']['response_code'] = response_json[strings.get("gen_resp")][strings.get("sts_code")] if response_json.get(strings.get("gen_resp")) else '' + txn_info['response']['message'] = response_json[strings.get("gen_resp")][strings.get("det_msg")] if response_json.get(strings.get("gen_resp")) else '' if response_json.get(strings.get("edata"), {}): app_name = response_json[strings.get("edata")].get(strings.get("app_name"), '') diff --git a/uber/tasks/registration.py b/uber/tasks/registration.py index 6a8b37e53..83fbb5f7e 100644 --- a/uber/tasks/registration.py +++ b/uber/tasks/registration.py @@ -13,7 +13,8 @@ from uber.config import c from uber.custom_tags import readable_join from uber.decorators import render -from uber.models import ApiJob, Attendee, TerminalSettlement, Email, Session, ReceiptInfo, ReceiptTransaction +from uber.models import (ApiJob, Attendee, AttendeeAccount, TerminalSettlement, Email, Session, BadgePickupGroup, + ReceiptInfo, ReceiptTransaction) from uber.tasks.email import send_email from uber.tasks import celery from uber.utils import localized_now, TaskUtils @@ -22,7 +23,34 @@ __all__ = ['check_duplicate_registrations', 'check_placeholder_registrations', 'check_pending_badges', 'check_unassigned_volunteers', 'check_near_cap', 'check_missed_stripe_payments', 'process_api_queue', - 'process_terminal_sale', 'send_receipt_email'] + 'process_terminal_sale', 'send_receipt_email', 'assign_badge_num', 'create_badge_pickup_groups', 'update_receipt'] + + +@celery.task +def assign_badge_num(attendee_id): + time.sleep(1) # Allow some time to save to DB + with Session() as session: + attendee = session.query(Attendee).filter_by(id=attendee_id).first() + if not attendee: + time.sleep(60) + attendee = session.query(Attendee).filter_by(id=attendee_id).first() + if not attendee: + log.error(f"Timed out when trying to assign a badge number to attendee {attendee_id}!") + return + attendee.badge_num = session.get_next_badge_num(attendee.badge_type) + session.add(attendee) + session.commit() + + +@celery.task +def update_receipt(attendee_id, params): + with Session() as session: + attendee = session.attendee(attendee_id) + receipt = session.get_receipt_by_model(attendee) + if receipt: + receipt_items = ReceiptManager.auto_update_receipt(attendee, receipt, params) + session.add_all(receipt_items) + session.commit() @celery.schedule(crontab(minute=0, hour='*/6')) @@ -273,7 +301,7 @@ def close_out_terminals(workstation_and_terminal_ids, who): @celery.task -def process_terminal_sale(workstation_num, terminal_id, model_id=None, account_id=None, **kwargs): +def process_terminal_sale(workstation_num, terminal_id, model_id=None, pickup_group_id=None, **kwargs): from uber.payments import SpinTerminalRequest from uber.models import TxnRequestTracking, AdminAccount @@ -290,19 +318,22 @@ def process_terminal_sale(workstation_num, terminal_id, model_id=None, account_i c.REDIS_STORE.hset(c.REDIS_PREFIX + 'spin_terminal_txns:' + terminal_id, 'tracking_id', txn_tracker.id) intent_id = SpinTerminalRequest.intent_id_from_txn_tracker(txn_tracker) - if account_id: + if pickup_group_id: try: - account = session.attendee_account(account_id) + pickup_group = session.badge_pickup_group(pickup_group_id) except NoResultFound: - txn_tracker.internal_error = f"Account {account_id} not found!" + txn_tracker.internal_error = f"Badge pickup group {pickup_group_id} not found!" session.commit() return txn_total = 0 attendee_names_list = [] receipts = [] + account_email = '' try: - for attendee in account.at_door_attendees: + for attendee in pickup_group.pending_paid_attendees: + if attendee.managers and not account_email: + account_email = attendee.primary_account_email receipt = session.get_receipt_by_model(attendee) if receipt: incomplete_txn = receipt.get_last_incomplete_txn() @@ -324,11 +355,11 @@ def process_terminal_sale(workstation_num, terminal_id, model_id=None, account_i session.commit() return - # Accounts get a custom payment description defined here, so get rid of whatever was passed in + # Pickup groups get a custom payment description defined here, so get rid of whatever was passed in kwargs.pop("description", None) payment_request = SpinTerminalRequest(terminal_id=terminal_id, - receipt_email=account.email, + receipt_email=account_email, description="At-door registration for " f"{readable_join(attendee_names_list)}", amount=txn_total, @@ -412,6 +443,18 @@ def check_missed_stripe_payments(): return paid_ids +@celery.schedule(timedelta(days=1)) +def create_badge_pickup_groups(): + if c.ATTENDEE_ACCOUNTS_ENABLED and c.BADGE_PICKUP_GROUPS_ENABLED and (c.AFTER_PREREG_TAKEDOWN or c.DEV_BOX): + with Session() as session: + skip_account_ids = set(s for (s,) in session.query(BadgePickupGroup.account_id).all()) + for account in session.query(AttendeeAccount).filter(~AttendeeAccount.id.in_(skip_account_ids)): + pickup_group = BadgePickupGroup(account_id=account.id) + pickup_group.build_from_account(account) + session.add(pickup_group) + session.commit() + + @celery.task def import_attendee_accounts(accounts, admin_id, admin_name, target_server, api_token): already_queued = 0 diff --git a/uber/templates/accounts/index.html b/uber/templates/accounts/index.html index bda0db1ba..aca47dfb6 100644 --- a/uber/templates/accounts/index.html +++ b/uber/templates/accounts/index.html @@ -8,6 +8,37 @@ } + +
New Account @@ -19,10 +50,30 @@
- + +
{{ macros.checkgroup_opts( diff --git a/uber/templates/art_show_admin/artist_check_in_out.html b/uber/templates/art_show_admin/artist_check_in_out.html index 11dd67155..04d093381 100644 --- a/uber/templates/art_show_admin/artist_check_in_out.html +++ b/uber/templates/art_show_admin/artist_check_in_out.html @@ -19,7 +19,8 @@

Artist Check-{{ checkout|yesno("Out, In") }}

- {% if checkout %}{% endif %} + {% if checkout %} + {% elif hanging %}{% endif %}
{% if search_text %} - + {% endif %} +
+ {% if hanging and not checkout %} + Show All Artists + {% else %} + Show Artists Hanging Art + {% endif %} +
{% set new_app_link = '../art_show_applications/' if c.INDEPENDENT_ART_SHOW else 'form?new_app=True' %} - @@ -73,13 +81,14 @@

Artist Check-{{ checkout|yesno("Out, In") }}

Artist Code Artist's Name Display Name - Pieces Submitted + # Pieces General Panels General Tables Mature Panels Mature Tables Check-In Notes Admin Notes + Hanging? Check-{{ checkout|yesno("Out,In") }} {% for app in applications %} @@ -95,6 +104,7 @@

Artist Check-{{ checkout|yesno("Out, In") }}

{{ app.tables_ad }} {{ app.check_in_notes }} {{ app.admin_notes }} + {{ app.art_show_pieces|selectattr('status','equalto',c.HANGING)|first|yesno("Yes,No") }} {% if not app.checked_in %} diff --git a/uber/templates/art_show_admin/assign_locations.html b/uber/templates/art_show_admin/assign_locations.html index 99a5d0bf5..3dcc633e8 100644 --- a/uber/templates/art_show_admin/assign_locations.html +++ b/uber/templates/art_show_admin/assign_locations.html @@ -1,5 +1,5 @@ {% extends "base.html" %}{% set admin_area=True %} -{% set title = "Assign Locations" %} +{% set title_text = "Assign Locations" %} {% block content %} +

{% if not admin_report %}High Bids Report -- {{ now|datetime("%m/%d/%Y, %-I:%M%p") }}{% else %}Bidder Report{% endif %}

+ - + {% for piece in won_pieces %} - - + @@ -26,12 +31,12 @@

Bidder Report

Bidder NumberPiece InformationPiece ID Piece Name Winning Bid
{{ piece.winning_bidder_num }}{{ piece.app.artist_id }}{{ piece.piece_id }}{{ piece.artist_and_piece_id }} {{ piece.name|wordwrap(25, wrapstring="
"|safe) }}
{{ piece.winning_bid }}
- + {% else %} - + {% endif %} {% endif %} {% endfor %} diff --git a/uber/templates/badge_printing/queue_badge.html b/uber/templates/badge_printing/queue_badge.html index b4c606b97..554a871fe 100644 --- a/uber/templates/badge_printing/queue_badge.html +++ b/uber/templates/badge_printing/queue_badge.html @@ -1,6 +1,15 @@ {% if not attendee.is_new and c.BADGE_PRINTING_ENABLED %} +
- + {{ csrf_token() }}
diff --git a/uber/templates/base.html b/uber/templates/base.html index 3aac79029..5c3600028 100644 --- a/uber/templates/base.html +++ b/uber/templates/base.html @@ -435,16 +435,24 @@ {% if admin_area %}
{% endif %} - {% if c.AT_THE_CON or c.BADGE_PICKUP_ENABLED or c.AFTER_EPOCH %} + {% if c.AT_THE_CON or c.BADGE_PICKUP_ENABLED or c.AFTER_EPOCH or attendee.checked_in %}
Checked In
diff --git a/uber/templates/forms/attendee/check_in_form.html b/uber/templates/forms/attendee/check_in_form.html index a01a51029..ba5af589d 100644 --- a/uber/templates/forms/attendee/check_in_form.html +++ b/uber/templates/forms/attendee/check_in_form.html @@ -76,14 +76,38 @@ {% endblock %} {% block badge_printing %} -{% if c.BADGE_PRINTING_ENABLED %} -
-
- -
+
+ +{% if c.BADGE_PRINTING_ENABLED %} +
+ +
+
{% endif %} +
+ +
+
+

+ This will add {{ attendee.full_name }} to the manager queue and they will not be able to check in before a manager + clears their ticket(s). If you're sure you want to do this, please describe the problem below and click + "Create Escalation Ticket". +

+
+
+
Escalation Note
+ + + Cancel +
+
+
+
{% endblock %} {% block check_in_notes %} diff --git a/uber/templates/group_admin/dealers.html b/uber/templates/group_admin/dealers.html index 989471779..b4f6cea83 100644 --- a/uber/templates/group_admin/dealers.html +++ b/uber/templates/group_admin/dealers.html @@ -38,7 +38,7 @@
-{% for group in dealer_groups %} +{% for group in all_groups|selectattr("is_dealer") %} diff --git a/uber/templates/group_admin/index.html b/uber/templates/group_admin/index.html index e301acfe0..f317d3930 100644 --- a/uber/templates/group_admin/index.html +++ b/uber/templates/group_admin/index.html @@ -61,7 +61,7 @@

- {% for group in groups -%} + {% for group in all_groups -%} - + diff --git a/uber/templates/hotel_lottery_admin/history.html b/uber/templates/hotel_lottery_admin/history.html index c0b5c8681..c5dccb06a 100644 --- a/uber/templates/hotel_lottery_admin/history.html +++ b/uber/templates/hotel_lottery_admin/history.html @@ -25,7 +25,7 @@

Changelog for {{ application.attendee_name }}'s Lottery Application

- + {% endfor %} @@ -42,7 +42,7 @@

Page View History for {{ application.attendee_name }}'s Lottery Application< {% for view in pageviews %}

- + {% endfor %} diff --git a/uber/templates/marketplace/edit.html b/uber/templates/marketplace/edit.html index acca03d1f..224797acb 100644 --- a/uber/templates/marketplace/edit.html +++ b/uber/templates/marketplace/edit.html @@ -1,5 +1,5 @@ {% extends "preregistration/preregbase.html" %} -{% set title = "Marketplace Application" %} +{% set title_text = "Marketplace Application" %} {% import 'forms/macros.html' as form_macros with context %} {% block content %} {% set attendee = app.attendee %} diff --git a/uber/templates/marketplace_admin/history.html b/uber/templates/marketplace_admin/history.html index 1210faf65..c84c656b9 100644 --- a/uber/templates/marketplace_admin/history.html +++ b/uber/templates/marketplace_admin/history.html @@ -25,7 +25,7 @@

Changelog for {{ app.attendee.full_name }}'s Marketplace Application

- + {% endfor %} @@ -42,7 +42,7 @@

Page View History

{% for view in pageviews %} - + {% endfor %} diff --git a/uber/templates/merch_admin/index.html b/uber/templates/merch_admin/index.html index b2eb20bc9..ad5fc7567 100644 --- a/uber/templates/merch_admin/index.html +++ b/uber/templates/merch_admin/index.html @@ -1,25 +1,11 @@ {% extends "base.html" %}{% set admin_area=True %} -{% block title %}Merch Booth{% endblock %} +{% set title_text = "Merch Booth Ops" %} {% block content %} {% include 'barcode_client.html' %} - - +{% block adminheader %} +{% endblock adminheader %} +{% block admin_controls %} -
 
- -
- Square not working? In a pinch, you can create arbitrary charges here. -
See outstanding t-shirt counts here. -
To have one person pick up merch for others, click here. + -
Bidder NumberPiece InformationPiece ID Piece Name Winning Bid
{{ group.name|default('?????', boolean=True) }}
{{ group|form_link }} diff --git a/uber/templates/hotel_lottery_admin/feed.html b/uber/templates/hotel_lottery_admin/feed.html index ec15377cd..c5d16b496 100644 --- a/uber/templates/hotel_lottery_admin/feed.html +++ b/uber/templates/hotel_lottery_admin/feed.html @@ -46,7 +46,7 @@

Feed of Database Changes to Lottery Applications {{ tracked.when|full_datetime_local }}

{{ tracked.who }}{{ tracked.who_repr }} {{ tracked.page }} {{ tracked.which or 'N/A' }} {{ tracked.action_label }} {{ tracked.model }} {{ tracked.action_label }} {{ tracked.when|full_datetime_local }}{{ tracked.who }}{{ tracked.who_repr }} {{ tracked.data }}
{{ view.when|full_datetime_local }}{{ view.who }}{{ view.who_repr }} {{ view.page }}
{{ tracked.model }} {{ tracked.action_label }} {{ tracked.when|full_datetime_local }}{{ tracked.who }}{{ tracked.who_repr }} {{ tracked.data }}
{{ view.when|full_datetime_local }}{{ view.who }}{{ view.who_repr }} {{ view.page }}
- - - - - - - - - - - - - - - - - - - - -
Last year's MPoints turned in:   Badge num: exchanged MPoints
---OR---
Apply a merch discount - Badge num: - -   - -
---OR---
Give Merch - Badge Number: - - {% if c.SEPARATE_STAFF_MERCH %} - +
+

Merch Booth

+ {% if supervisor %} + Volunteer Log Out + {% endif %} +
+
+
At-Con Operations
+
+
+
Enter or scan the badge number or check-in QR code and select an action.
+
+ {% if not c.SPIN_TERMINAL_AUTH_KEY %}Create Arbitrary Charge{% endif %} + Multi-Merch Pickup + {% if not supervisor %} + + {% endif %} +
+
+ {{ csrf_token() }} +
+
+ +
+ +
+
+
+ +
+ {% if 'give_merch' in c.MERCH_OPS %} + + {% if c.SEPARATE_STAFF_MERCH %} + + {% endif %} {% endif %} -
+ {% if 'discount' in c.MERCH_OPS %} + + {% endif %} + {% if 'mpoints' in c.MERCH_OPS %} + + {% endif %} +
+
+
+
+ Enter the number of MPoints exchanged below.
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
Shirt Size
+ +
+
+
Staff Shirt Size
+ +
+
+
+ + {% if c.OUT_OF_SHIRTS %} + + {% endif %} +
+
+
+
+
+{% endblock admin_controls %} +
+{% if c.HAS_MERCH_REPORTS_ACCESS %} +
+
Merch Reports
+ +
+{% endif %} {% endblock %} diff --git a/uber/templates/model_history_base.html b/uber/templates/model_history_base.html index 255b1e4c0..1f8f53139 100644 --- a/uber/templates/model_history_base.html +++ b/uber/templates/model_history_base.html @@ -16,7 +16,7 @@

Changelog for {{ model.full_name|default(model.name) }} {% if c.AT_THE_CON a {{ tracked.model }} {{ tracked.action_label }} {{ tracked.when|full_datetime_local }} - {{ tracked.who }} + {{ tracked.who_repr }} {{ tracked.data }} {% endfor %} @@ -33,7 +33,7 @@

Page View History for {{ model.full_name|default(model.name) }} {% if c.AT_T {% for view in pageviews %} {{ view.when|full_datetime_local }} - {{ view.who }} + {{ view.who_repr }} {{ view.page }} {% endfor %} diff --git a/uber/templates/preregistration/at_door_confirmation.html b/uber/templates/preregistration/at_door_confirmation.html index 6d60ec0bb..99c777a46 100644 --- a/uber/templates/preregistration/at_door_confirmation.html +++ b/uber/templates/preregistration/at_door_confirmation.html @@ -15,10 +15,7 @@

Registration{{ completed_registrations|length|pluralize }} Saved!

your badges at any time{% else %} reserved for you to pay for and pick up at the Registration desk{% endif %}.

-

- {% if account and account.at_door_attendees|length > completed_registrations|length %} - It looks like your account already has at least one prior at-door registration. - {% endif %} +

If you think you may have made a mistake, like registering the same badge twice, there's no need to worry -- you can request any adjustments you need at the registration desk before paying.

diff --git a/uber/templates/preregistration/homepage.html b/uber/templates/preregistration/homepage.html index 5980c18d0..c3da56441 100644 --- a/uber/templates/preregistration/homepage.html +++ b/uber/templates/preregistration/homepage.html @@ -16,16 +16,16 @@ {{ c.EVENT_NAME_AND_YEAR }} has sold out! {% endif %} {% endset %} -{% if c.AT_THE_CON and account.at_door_attendees %} +{% if c.AT_THE_CON and account.at_door_pending_attendees %}

Pending At-Door Registrations

Please go to Registration to pay for and claim the badges below.
- {% for attendee in account.at_door_attendees %} + {% for attendee in account.at_door_pending_attendees %}
{% include "preregistration/attendee_card.html" %}
{% endfor %} {% endif %} -{% if not account.valid_attendees and (not c.AT_THE_CON or not account.at_door_attendees) %} +{% if not account.valid_attendees and (not c.AT_THE_CON or not account.at_door_pending_attendees) %}
You have no current registrations

{{ prereg_message }}

diff --git a/uber/templates/preregistration/index_payment_form.html b/uber/templates/preregistration/index_payment_form.html index f1a3494a8..a0d6cc0b1 100644 --- a/uber/templates/preregistration/index_payment_form.html +++ b/uber/templates/preregistration/index_payment_form.html @@ -1,28 +1,7 @@ -{% if c.ATTENDEE_ACCOUNTS_ENABLED %} -
-
- {{ macros.stripe_button("Add Another Registration") }} - or - {% if cart.total_cost > 0 %} - {% if c.AT_THE_CON %} - {% if c.SPIN_TERMINAL_AUTH_KEY %} - Confirm Registrations and Get Check-in Barcode - {% else %} - {{ stripe_form('prereg_payment') }} - {% endif %} - {% else %} - {{ stripe_form('prereg_payment') }} - {% endif %} - {% else %} - {{ macros.stripe_button("Complete Registration!") }} - {% endif %} -
-
-{% else %}
{% if cart.total_cost > 0 %} - {% if c.AT_THE_CON and c.ATTENDEE_ACCOUNTS_ENABLED and c.SPIN_TERMINAL_AUTH_KEY %} + {% if c.AT_THE_CON and c.SPIN_TERMINAL_AUTH_KEY %} Confirm Registrations and Get Check-in Barcode {% else %} {{ stripe_form('prereg_payment', text="Pay with Card Now") }} @@ -36,5 +15,4 @@
or
{{ macros.stripe_button("Add Another Registration") }}
-
-{% endif %} \ No newline at end of file +
\ No newline at end of file diff --git a/uber/templates/reg_admin/automated_transactions.html b/uber/templates/reg_admin/automated_transactions.html new file mode 100644 index 000000000..053678291 --- /dev/null +++ b/uber/templates/reg_admin/automated_transactions.html @@ -0,0 +1,165 @@ +{% extends "base.html" %}{% set admin_area=True %} +{% set title_text = "Automated Transactions" %} +{% block content %} + + +
+
+ +
+
+ + +
+ + +
+
+
+ +
+
+ +
+
+
+ Search by transaction ID, receipt ID, receipt owner ID, terminal ID, intent/charge/refund ID, receipt #, or admin name. +
+ +
+
+ {{ total_count }} total automated transaction{{ total_count|pluralize }} + {{ payment_count }} automated payment transaction{{ payment_count|pluralize }} + {{ refund_count }} automated refund transaction{{ refund_count|pluralize }} +
+
+
+
+ +{% if search_results %} +
+
+ Click here to view full transaction list instead of only search results. +
+
+{% endif %} + +{% block table %} +
+ + {% if page %} + + + + {% block tableheadings %} + + + + + + + + + + {% endblock tableheadings %} + + + + {% for txn in receipt_txns %} + + {% block tablerows scoped %} + + + + + + + + + + {% endblock tablerows %} + + {% endfor %} + +
Receipt TypeTransaction IDMethodDepartmentAmountAddedCancelledAdmin
+ {{ txn.receipt.owner_model }} + {% if c.HAS_REG_ADMIN_ACCESS %} + (View) + {% endif %} + {% if txn.refund_id %}Refund{% elif txn.charge_id %}Charge{% else %}Intent{% endif %} ID: {{ txn.stripe_id }}{{ txn.method_label }}{{ txn.department_label }} + {{ (txn.amount / 100)|format_currency }}{% if txn.txn_total and txn.txn_total != txn.amount %} ({{ (txn.txn_total / 100)|format_currency }} Total){% endif %} + {{ txn.added|datetime_local('%-I:%M%p %A, %b %-e') }}{{ txn.cancelled|datetime_local('%-I:%M%p %A, %b %-e') if txn.cancelled else "N/A" }}{{ txn.who }} + {% set stripe_url = txn.stripe_url %} + {% if stripe_url %} + View in {{ processors[txn.method] }} + {% endif %} +
+ {% else %} +
+ Use the search box above to filter transactions or select a page to browse all transactions. +
+ {% endif %} +{% if receipt_txns %} +{% set start_txn_num = (page * 100) - 100 + 1 %} +
Showing {{ start_txn_num }} to {{ start_txn_num + receipt_txns|length - 1 }} out of {{ search_count }} transactions
+{% endif %} +{% if receipt_txns|length > 15 %} + +{% endif %} +
+ +{% endblock table %} +{% endblock content %} diff --git a/uber/templates/reg_admin/escalation_tickets.html b/uber/templates/reg_admin/escalation_tickets.html new file mode 100644 index 000000000..e0ae34ca5 --- /dev/null +++ b/uber/templates/reg_admin/escalation_tickets.html @@ -0,0 +1,150 @@ +{% extends "base.html" %}{% set admin_area=True %} +{% set title_text = "Escalation Tickets" %} +{% block content %} +

Escalation Tickets

+ + + +
+
+ {{ total_count }} total {% if not closed %}open {% endif %} ticket{{ total_count|pluralize }} +
+
+ {% if closed %} + Hide Resolved Tickets + {% else %} + Show Resolved Tickets + {% endif %} +
+
+ +{% block table %} +
+ + + + {% block tableheadings %} + + + + + + + {% endblock tableheadings %} + + + + {% for ticket in tickets %} + + {% block tablerows scoped %} + + + + + + + {% endblock tablerows %} + + {% endfor %} + +
Ticket IDCreatedAttendeesProblemAdmin Notes
{{ ticket.ticket_id }} + {{ ticket.created|time_day_local }} + + {% for attendee in ticket.attendees %} + {{ attendee|form_link(true) }}{% if not loop.last %} / {% endif %} + {% endfor %} + {{ ticket.description }} + + +
+ +
+ {% if ticket.resolved %}Resolved {{ ticket.resolved|time_day_local }}{% endif %}  + + + + +
+
+ +{% endblock table %} +{% endblock content %} diff --git a/uber/templates/reg_admin/manage_workstations.html b/uber/templates/reg_admin/manage_workstations.html index afb8cfaff..a4f870241 100644 --- a/uber/templates/reg_admin/manage_workstations.html +++ b/uber/templates/reg_admin/manage_workstations.html @@ -175,7 +175,7 @@

Manage Workstations and Terminals

{% for timestamp, admin, workstation_num, terminal_id in settlements['in_progress'] %} - {{ timestamp|timestamp_to_dt|datetime_local }} + {{ timestamp|timestamp_to_dt|datetime_local }} {{ admin }} {{ workstation_num }} {{ terminal_id|tpn_to_terminal_id }} diff --git a/uber/templates/reg_admin/receipt_items.html b/uber/templates/reg_admin/receipt_items.html index 040456f40..9e10f827e 100644 --- a/uber/templates/reg_admin/receipt_items.html +++ b/uber/templates/reg_admin/receipt_items.html @@ -317,11 +317,11 @@ var fullRefund = function (receiptId) { let message = ''; - {% if group_leader_receipt and not receipt %} + {% if group_leader_receipt_id and not receipt %} message = "This will refund this attendee's group leader the cost of one badge."; {% else %} let extra_message = ''; - {% if group_leader_receipt %} + {% if group_leader_receipt_id %} extra_message = " for this attendee, plus the cost of one badge for their group leader"; {% endif %} message = "This will refund ALL existing {{ processors_str }} transactions that have not already been refunded" + @@ -355,11 +355,11 @@ var mostlyFullRefund = function (receiptId, fee_str, group_leader_fee_str = '') { let message = ''; - {% if group_leader_receipt and not receipt %} + {% if group_leader_receipt_id and not receipt %} message = "This will refund this attendee's group leader the cost of one badge minus " + fee_str + " in processing fees. It will also"; {% else %} let extra_message = ''; - {% if group_leader_receipt %} + {% if group_leader_receipt_id %} extra_message = " It will also refund their group leader the cost of one badge minus " + group_leader_fee_str + " in processing fees, and it will"; {% else %} extra_message = " It will also" @@ -500,11 +500,11 @@
-
+
Description
{{ item.desc }}
-
+
Quantity/Amount
{{ (item.amount / 100)|format_currency(true) }} @@ -513,6 +513,10 @@
+
+
Admin Notes
+
{{ item.admin_notes }}
+
@@ -581,12 +585,88 @@
@@ -791,7 +877,7 @@

{% for item in items %} - + {{ item.added|datetime_local("%b %-d %-H:%M (%-I:%M%p)") }} {{ item.who }} @@ -859,7 +945,7 @@

{% elif item.cancelled %} Cancelled {{ item.cancelled|datetime_local }} {% elif item.closed %} - {{ "Paid" if item.paid else "Refunded" }} {{ item.closed|datetime_local }} + {{ item.closed_type }} {{ item.closed|datetime_local }} {% endif %} {% if item.refunded %} {{ (item.refunded / 100)|format_currency }} Refunded{% if item.refundable and item.amount_left %} ({{ (item.amount_left / 100)|format_currency }} left){% endif %} @@ -908,7 +994,7 @@

{{ tracked.model }} {{ tracked.action_label }} {{ tracked.when|full_datetime_local }} - {{ tracked.who }} + {{ tracked.who_repr }} {{ tracked.data }} {% endfor %} @@ -934,14 +1020,14 @@

Current Receipt

{% if not c.AUTHORIZENET_LOGIN_ID %} OR - + {% endif %} {% else %} There are no active receipts for this {{ model_str }}.
Create Default Receipt Create Blank Receipt - {% if group_leader_receipt and attendee and attendee.badge_status != c.REFUNDED_STATUS %} - - +
diff --git a/uber/templates/reg_reports/checkins_by_hour.html b/uber/templates/reg_reports/checkins_by_hour.html new file mode 100644 index 000000000..ff2751f54 --- /dev/null +++ b/uber/templates/reg_reports/checkins_by_hour.html @@ -0,0 +1,52 @@ +{% extends "base.html" %}{% set admin_area=True %} +{% set title_text = "Checkins By Hour" %} +{% block content %} +

Checkins By Hour -- {{ now()|datetime_local("%m/%d/%Y, %-I:%M%p") }}

+ +
+
+

+ {% for day, count in daily_checkins.items() %} + Checkins on {{ day }}: {{ count }}{% if not loop.last %}
{% endif %} + {% endfor %} +

+
+ +
+ +{% block table %} +
+ + + + {% block tableheadings %} + + + + + {% endblock tableheadings %} + + + + {% for time, count in checkins.items() %} + + {% block tablerows scoped %} + + + + + {% endblock tablerows %} + + {% endfor %} + +
Event DayDateTime# Checkins
+ {{ time|datetime('%A') if time not in outside_event_checkins else "N/A" }} + {{ time|datetime('%Y-%m-%d') }}{{ time|datetime('%-I:%M%p') }}-{{ time|timedelta(hours=1)|datetime('%-I:%M%p') }}{{ count }}
+
+ +{% endblock table %} +{% endblock content %} diff --git a/uber/templates/registration/attendee_watchlist.html b/uber/templates/registration/attendee_watchlist.html index c84212eaf..8bf0aa95d 100644 --- a/uber/templates/registration/attendee_watchlist.html +++ b/uber/templates/registration/attendee_watchlist.html @@ -1,5 +1,29 @@ {% set admin_area=True %}

{% if not attendee.watchlist_id %}Possible Watchlist Entries{% else %}Watchlist Entry{% endif %} for {{ attendee.full_name }} ({{ attendee.badge }})

+
{% if attendee.badge_status == c.WATCHED_STATUS %} -
{{ csrf_token() }}
Sets attendee to "Completed" status, allowing them to check in without altering any watchlist entries.
-
{% else %}

{{ attendee.full_name }} currently has a badge status of {{ attendee.badge_status_label }} and is{% if attendee.cannot_check_in_reason %} NOT{% endif %} ready to check in.

{% endif %} {% if attendee.watchlist_id %} - Below is the confirmed watchlist entry for this attendee. All other possible watchlist entries are ignored. +

Below is the confirmed watchlist entry for this attendee. All other possible watchlist entries are ignored.
Reason: {{ attendee.watch_list.reason }}
Action: {{ attendee.watch_list.action }} -
+

{{ csrf_token() }} @@ -83,11 +81,13 @@

{% if not attendee.watchlist_id %}Possible Watchlist Entries{% else %}Watchl Watchlist Entry +

{% else %} +
{% for list in active_entries, inactive_entries %} {% set active = loop.cycle(True, False) %} -
-
+ {% if list %} +

{{ loop.cycle('Active', 'Inactive') }} Entries

{% if active %} @@ -106,7 +106,7 @@

{% if not attendee.watchlist_id %}Possible Watchlist Entries{% else %}Watchl Watchlist DOB Watchlist Reason Watchlist Action - {{ loop.cycle('Deactivate', 'Activate') }} + {{ loop.cycle('Deactivate Entry', 'Activate Entry') }} Confirm as Correct Match {% for entry in list %} @@ -129,12 +129,12 @@

{% if not attendee.watchlist_id %}Possible Watchlist Entries{% else %}Watchl -
+ {{ csrf_token() }} -
@@ -143,6 +143,7 @@

{% if not attendee.watchlist_id %}Possible Watchlist Entries{% else %}Watchl

+ {% endif %} {% endfor %} {% endif %} diff --git a/uber/templates/registration/check_in_form_base.html b/uber/templates/registration/check_in_form_base.html index fbdf7c680..d6390e3de 100644 --- a/uber/templates/registration/check_in_form_base.html +++ b/uber/templates/registration/check_in_form_base.html @@ -6,7 +6,7 @@