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 @@
}
+
+