Skip to content

Commit

Permalink
Add anonymous badge transfers
Browse files Browse the repository at this point in the history
Allows attendees to transfer badges using codes, including a way to check the transferer's code against the system.
  • Loading branch information
kitsuta committed Jan 6, 2025
1 parent af1a018 commit 62a6a75
Show file tree
Hide file tree
Showing 12 changed files with 410 additions and 18 deletions.
59 changes: 59 additions & 0 deletions alembic/versions/300b55d2f88c_add_transfer_code_to_attendees.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Add transfer code to attendees
Revision ID: 300b55d2f88c
Revises: f6dc67fe7eea
Create Date: 2025-01-05 17:24:22.176562
"""


# revision identifiers, used by Alembic.
revision = '300b55d2f88c'
down_revision = 'f6dc67fe7eea'
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('attendee', sa.Column('transfer_code', sa.Unicode(), server_default='', nullable=False))


def downgrade():
op.drop_column('attendee', 'transfer_code')
2 changes: 1 addition & 1 deletion uber/configspec.ini
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ transferable_badge_types = string_list(default=list('attendee_badge'))
# other events with custom fields can add fields.
#
# This list is also used for repurchasing badges or purchasing importing badges
untransferable_attrs = string_list(default=list('first_name','last_name','legal_name','email','birthdate','zip_code','international','ec_name','ec_phone','onsite_contact','no_onsite_contact','cellphone','interests','age_group','staffing','requested_depts'))
untransferable_attrs = string_list(default=list('first_name','last_name','legal_name','email','birthdate','zip_code','international','ec_name','ec_phone','onsite_contact','no_onsite_contact','cellphone','interests','age_group','staffing','requested_depts','transfer_code'))

# A list of attributes that get applied to the dealer prereg form if a dealer
# chooses to reapply with an imported group
Expand Down
14 changes: 12 additions & 2 deletions uber/models/attendee.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from uber.models.types import default_relationship as relationship, utcnow, Choice, DefaultColumn as Column, \
MultiChoice, TakesPaymentMixin
from uber.utils import add_opt, get_age_from_birthday, get_age_conf_from_birthday, hour_day_format, \
localized_now, mask_string, normalize_email, normalize_email_legacy, remove_opt
localized_now, mask_string, normalize_email, normalize_email_legacy, remove_opt, RegistrationCode


__all__ = ['Attendee', 'AttendeeAccount', 'BadgePickupGroup', 'FoodRestrictions']
Expand Down Expand Up @@ -192,6 +192,8 @@ class Attendee(MagModel, TakesPaymentMixin):
backref=backref('used_by', cascade='merge,refresh-expire,expunge'),
foreign_keys=promo_code_id,
cascade='merge,refresh-expire,expunge')

transfer_code = Column(UnicodeText)

placeholder = Column(Boolean, default=False, admin_only=True, index=True)
first_name = Column(UnicodeText)
Expand Down Expand Up @@ -791,7 +793,15 @@ def admin_write_access(self):
from uber.models import Session
with Session() as session:
return session.admin_attendee_max_access(self, read_only=False)


@hybrid_property
def normalized_transfer_code(self):
return RegistrationCode.normalize_code(self.transfer_code)

@normalized_transfer_code.expression
def normalized_transfer_code(cls):
return RegistrationCode.sql_normalized_code(cls.transfer_code)

@property
def cannot_edit_badge_status_reason(self):
full_reg_admin = False
Expand Down
158 changes: 148 additions & 10 deletions uber/site_sections/preregistration.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
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, \
valid_password, SignNowRequest, validate_model, create_new_hash, get_age_conf_from_birthday
valid_password, SignNowRequest, validate_model, create_new_hash, get_age_conf_from_birthday, RegistrationCode
from uber.payments import PreregCart, TransactionRequest, ReceiptManager, SpinTerminalRequest


Expand Down Expand Up @@ -1545,6 +1545,139 @@ def dealer_signed_document(self, session, id):
message += ' Please pay your application fee below.'
raise HTTPRedirect(f'group_members?id={id}&message={message}')

def start_badge_transfer(self, session, message='', **params):
transfer_code = params.get('code', '')
transfer_badge = None

if transfer_code:
transfer_badges = session.query(Attendee).filter(
Attendee.normalized_transfer_code == RegistrationCode.normalize_code(transfer_code))
if transfer_badges.count() == 1:
transfer_badge = transfer_badges.first()
elif transfer_badges.count() > 1:
log.error(f"ERROR: {transfer_badges.count()} attendees have transfer code {transfer_code}!")
transfer_badge = transfer_badges.filter(Attendee.has_badge == True).first()

attendee = Attendee()
form_list = ['PersonalInfo', 'OtherInfo', 'StaffingInfo', 'Consents']
forms = load_forms(params, attendee, form_list)

if cherrypy.request.method == 'POST' and params.get('first_name', None):
for form in forms.values():
if hasattr(form, 'same_legal_name') and params.get('same_legal_name'):
form['legal_name'].data = ''
form.populate_obj(attendee)

if attendee.banned and not params.get('ban_bypass', None):
return {
'message': message,
'transfer_badge': transfer_badge,
'attendee': attendee,
'forms': forms,
'code': transfer_code,
'ban_bypass': True,
}

if not params.get('duplicate_bypass', None):
duplicate = session.attendees_with_badges().filter_by(first_name=attendee.first_name,
last_name=attendee.last_name,
email=attendee.email).first()
if duplicate:
return {
'message': message,
'transfer_badge': transfer_badge,
'attendee': attendee,
'forms': forms,
'code': transfer_code,
'ban_bypass': params.get('ban_bypass', None),
'duplicate_bypass': True,
}

if not message:
session.add(attendee)
attendee.badge_status = c.PENDING_STATUS
attendee.paid = c.PENDING
attendee.transfer_code = RegistrationCode.generate_random_code(Attendee.transfer_code)
raise HTTPRedirect('confirm?id={}&message={}', attendee.id,
f"Success! Your pending badge's transfer code is {attendee.transfer_code}.")

return {
'message': message,
'transfer_badge': transfer_badge,
'attendee': attendee,
'forms': forms,
'code': transfer_code,
}

def complete_badge_transfer(self, session, id, code, message='', **params):
if cherrypy.request.method != 'POST':
raise HTTPRedirect('transfer_badge?id={}&message={}', id, "Please submit the form to transfer your badge.")

old = session.attendee(id)
transfer_badges = session.query(Attendee).filter(
Attendee.normalized_transfer_code == RegistrationCode.normalize_code(code))

if transfer_badges.count() == 1:
transfer_badge = transfer_badges.first()
elif transfer_badges.count() > 1:
log.error(f"ERROR: {transfer_badges.count()} attendees have transfer code {code}!")
transfer_badge = transfer_badges.filter(Attendee.badge_status == c.PENDING_STATUS).first()
else:
transfer_badge = None

if not transfer_badge or transfer_badge.badge_status != c.PENDING_STATUS:
raise HTTPRedirect('transfer_badge?id={}&message={}', id,
f"Could not find a badge to transfer to with transfer code {code}.")

old_attendee_dict = old.to_dict()
del old_attendee_dict['id']
for attr in old_attendee_dict:
if attr not in c.UNTRANSFERABLE_ATTRS:
setattr(transfer_badge, attr, old_attendee_dict[attr])
receipt = session.get_receipt_by_model(old)

old.badge_status = c.INVALID_STATUS
old.append_admin_note(f"Automatic transfer to attendee {transfer_badge.id}.")
transfer_badge.badge_status = c.NEW_STATUS
transfer_badge.append_admin_note(f"Automatic transfer from attendee {old.id}.")

subject = c.EVENT_NAME + ' Registration Transferred'
new_body = render('emails/reg_workflow/badge_transferee.txt',
{'attendee': transfer_badge, 'transferee_code': transfer_badge.transfer_code,
'transferer_code': old.transfer_code}, encoding=None)
old_body = render('emails/reg_workflow/badge_transferer.txt',
{'attendee': old, 'transferee_code': transfer_badge.transfer_code,
'transferer_code': old.transfer_code}, encoding=None)

try:
send_email.delay(
c.REGDESK_EMAIL,
[transfer_badge.email_to_address, c.REGDESK_EMAIL],
subject,
new_body,
model=transfer_badge.to_dict('id'))
send_email.delay(
c.REGDESK_EMAIL,
[old.email_to_address],
subject,
old_body,
model=old.to_dict('id'))
except Exception:
log.error('Unable to send badge change email', exc_info=True)

session.add(transfer_badge)
transfer_badge.transfer_code = ''
session.commit()
if receipt:
session.add(receipt)
receipt.owner_id = transfer_badge.id
session.commit()

if c.ATTENDEE_ACCOUNTS_ENABLED:
raise HTTPRedirect('../preregistration/homepage?message={}', "Badge transferred.")
else:
raise HTTPRedirect('../landing/index?message={}', "Badge transferred.")

@id_required(Attendee)
@requires_account(Attendee)
@log_pageview
Expand All @@ -1553,9 +1686,13 @@ def transfer_badge(self, session, message='', **params):

if not old.is_transferable:
raise HTTPRedirect('../landing/index?message={}', 'This badge is not transferable.')
if not old.is_valid:
if not old.has_badge:
raise HTTPRedirect('../landing/index?message={}',
'This badge is no longer valid. It may have already been transferred.')

if not old.transfer_code:
old.transfer_code = RegistrationCode.generate_random_code(Attendee.transfer_code)
session.commit()

old_attendee_dict = old.to_dict()
del old_attendee_dict['id']
Expand Down Expand Up @@ -1936,14 +2073,15 @@ def confirm(self, session, message='', return_to='confirm', undoing_extra='', **
message = 'Your information has been updated'

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 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
raise HTTPRedirect('new_badge_payment?id={}&message={}&return_to={}', attendee.id, message, return_to)
if attendee.is_valid:
if not receipt:
receipt = session.get_receipt_by_model(attendee, create_if_none="DEFAULT")
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
raise HTTPRedirect('new_badge_payment?id={}&message={}&return_to={}', attendee.id, message, return_to)

session.refresh_receipt_and_model(attendee)
session.commit()
Expand Down
1 change: 1 addition & 0 deletions uber/tasks/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ def email_pending_attendees():
pending_badges = session.query(Attendee).filter(
Attendee.paid == c.PENDING,
Attendee.badge_status == c.PENDING_STATUS,
Attendee.transfer_code == '',
Attendee.registered < datetime.now(pytz.UTC) - timedelta(hours=24)).order_by(Attendee.registered)
for badge in pending_badges:
# Update `compare_date` to prevent early deletion of badges registered before a certain date
Expand Down
16 changes: 16 additions & 0 deletions uber/templates/emails/reg_workflow/badge_transferee.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{{ attendee.first_name }},

Your pending {{ c.EVENT_NAME }} badge with the transfer code {{ transferee_code }} has now been activated using transfer code {{ transferer_code }}. This means you now have a valid registration for {{ c.EVENT_NAME }}!

You can use this link to view or update your badge: {{ c.URL_BASE }}/preregistration/confirm?id={{ attendee.id }}

Badges are not mailed out before the event, so your badge will be available for pickup at the registration desk when you arrive at {{ c.EVENT_NAME }}. Simply bring a photo ID to the registration desk, where you'll be provided with your badge. If you ordered any merchandise, you can pick those up at our merchandise booth. The location and hours of the registration desk and merchandise booth will be emailed prior to the event. {% if c.CONSENT_FORM_URL and attendee.age_group_conf['consent_form'] %}

Our records indicate that you are under the age of 18, and as such, you will need a signed parental consent form. If a parent/guardian will be present at {{ c.EVENT_NAME }}, then they can sign the consent form when you pick up your badge at the registration desk. If a parent/guardian will not be at the event, the form may be brought pre-signed, however it MUST be notarized. We will not accept pre-signed forms that are not notarized. You may find the form at {{ c.CONSENT_FORM_URL }}.

If you are actually over 18, you can update your age in our database at {{ c.URL_BASE }}/preregistration/confirm?id={{ attendee.id }} before {{ c.UBER_TAKEDOWN|datetime_local }}.
{% endif %}

If this has happened in error, please contact {{ c.SECURITY_EMAIL|safe }}. Otherwise we look forward to seeing you on {{ event_dates() }}.

{{ c.REGDESK_EMAIL_SIGNATURE }}
7 changes: 7 additions & 0 deletions uber/templates/emails/reg_workflow/badge_transferer.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{ attendee.first_name }},

You have successfully transferred your {{ c.EVENT_NAME }} {{ attendee.badge_type_label }} registration to badge code {{ transferee_code }} using your transfer code {{ transferer_code }}. This means you no longer have a paid registration for {{ c.EVENT_NAME }}.

If this has happened in error, please contact {{ c.SECURITY_EMAIL|safe }}. Otherwise, we hope to see you next year!

{{ c.REGDESK_EMAIL_SIGNATURE }}
2 changes: 1 addition & 1 deletion uber/templates/forms/attendee/other_info.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@


{% block promo_code %}
{% if not badge_flags and not old and c.BADGE_PROMO_CODES_ENABLED and (not attendee.active_receipt or admin_area) and not is_prereg_dealer %}
{% if not badge_flags and not old and not transfer_badge and c.BADGE_PROMO_CODES_ENABLED and (not attendee.active_receipt or admin_area) and not is_prereg_dealer %}
{% set promo_code_admin_text %}
{% if c.HAS_REG_ADMIN_ACCESS and attendee.promo_code_code %}
<a href="" id="remove_promo_code" onClick="removePromoCode(event)">Remove Promo Code</a>
Expand Down
5 changes: 3 additions & 2 deletions uber/templates/forms/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@
#}

{% set is_preview = 'landing' in c.PAGE_PATH %}
{% set read_only = locked_fields|default(None, true) and target_field.name in locked_fields and not upgrade_modal %}
{% set label_required = kwargs['required'] if 'required' in kwargs else target_field.flags.required %}
{% set field_id = target_field_id or target_field.id %}
{% set show_desc = not admin_area and not is_preview and (help_text or target_field.description) %}
Expand All @@ -223,7 +224,7 @@
<div class="card {{ target_field.name }}_card {{ text_class }}{% if disabled_card %} disabled-card bg-secondary text-white{% endif %}" style="max-width: 18rem;">
<div class="card-header" style="transform: rotate(0);">
<label for="{{ field_id }}-{{ opt.value }}" class="h5 card-title mb-0 text-nowrap">
{% if disabled_card or is_preview %}
{% if disabled_card or is_preview or read_only %}
{{ opt.name }}{% if opt.price and opt.price|int %}: {{ opt.price|format_currency }}{% endif %}
{% else %}
<a href="#" class="card-link stretched-link text-reset text-decoration-none {{ target_field.name }}_card_link"
Expand All @@ -236,7 +237,7 @@
{% if opt.icon %}
<img src="{{ opt.icon }}" class="card-img mb-3" alt="{{ opt.name }} Icon">
{% endif %}
{% if disabled_card or is_preview %}
{% if disabled_card or is_preview or read_only %}
{{ opt.desc|safe }}
{% if disabled_card %}
<div class="disabled-overlay position-absolute top-0 start-0 h-100 w-100" style="background: rgba(0,0,0,0.66)">
Expand Down
9 changes: 8 additions & 1 deletion uber/templates/preregistration/confirm.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@
{% include 'confirm_tabs.html' with context %}
{% endif %}

{% if not attendee.is_valid %}
{% if attendee.badge_status == c.PENDING_STATUS and attendee.paid == c.PENDING and attendee.transfer_code %}
<div class="alert alert-warning" role="alert">
<p><strong>You do not yet have a valid registration for {{ c.EVENT_NAME }}.</strong></p>
<p>Someone with a valid badge must use your transfer code (<strong>{{ attendee.transfer_code }}</strong>) to transfer their badge to you
before you are able to pick up a badge.</p>
You can review and update your pending badge's information below.
</div>
{% elif not attendee.is_valid %}
<div class="alert alert-danger" role="alert">
Your registration is currently marked as <strong>{{ attendee.badge_status_label }}</strong>
and is not considered a valid registration. Please contact us at {{ c.REGDESK_EMAIL|email_only|email_to_link }} if you have any questions.
Expand Down
Loading

0 comments on commit 62a6a75

Please sign in to comment.