From 131ec44c07029a8746526d00654c65a27e55950e Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Sat, 5 Aug 2023 16:58:53 -0400 Subject: [PATCH 01/16] Update account/attendee/group importing Fixes a couple bugs around importing models from last year. Also skips importing any account with a domain matching the SSO email domain, and skips importing all staff. --- uber/api.py | 2 +- uber/site_sections/reg_admin.py | 6 +++- uber/tasks/registration.py | 4 +-- .../templates/reg_admin/import_attendees.html | 2 +- uber/utils.py | 34 ++++++++++++------- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/uber/api.py b/uber/api.py index 2e0865460..1b1e78c3a 100644 --- a/uber/api.py +++ b/uber/api.py @@ -744,7 +744,7 @@ def export_attendees(self, id, full=False, include_group=False): if not account: raise HTTPError(404, 'No attendee account found with this ID') - attendees_to_export = account.valid_attendees if include_group else [a for a in account.attendees if not a.group] + attendees_to_export = account.valid_attendees if include_group else [a for a in account.valid_attendees if not a.group] attendees = _prepare_attendees_export(attendees_to_export, include_apps=full) return { diff --git a/uber/site_sections/reg_admin.py b/uber/site_sections/reg_admin.py index 73145ada4..a35ee05d4 100644 --- a/uber/site_sections/reg_admin.py +++ b/uber/site_sections/reg_admin.py @@ -625,7 +625,11 @@ def import_attendees(self, session, target_server='', api_token='', query='', me href_base = '{}/registration/form?id={}' elif which_import == 'groups': if params.get('dealers', ''): - results = service.group.dealers(status=params.get('status', None)) + status = c.DEALER_STATUS.get(int(params.get('dealer_status', 0)), None) + if not status: + message = "Invalid group status." + else: + results = service.group.dealers(status=status) else: results = service.group.export(query=query) results_name = 'groups' diff --git a/uber/tasks/registration.py b/uber/tasks/registration.py index 08b552854..626b72e14 100644 --- a/uber/tasks/registration.py +++ b/uber/tasks/registration.py @@ -174,12 +174,12 @@ def check_missed_stripe_payments(): def process_api_queue(): known_job_names = ['attendee_account_import', 'attendee_import', 'group_import'] completed_jobs = {} - safety_limit = 1000 + safety_limit = 500 jobs_processed = 0 with Session() as session: for job_name in known_job_names: - jobs_to_run = session.query(ApiJob).filter(ApiJob.job_name == job_name, ApiJob.queued == None).limit(1000) + jobs_to_run = session.query(ApiJob).filter(ApiJob.job_name == job_name, ApiJob.queued == None).limit(safety_limit) completed_jobs[job_name] = 0 for job in jobs_to_run: diff --git a/uber/templates/reg_admin/import_attendees.html b/uber/templates/reg_admin/import_attendees.html index 54b8f4698..e7116f5b2 100644 --- a/uber/templates/reg_admin/import_attendees.html +++ b/uber/templates/reg_admin/import_attendees.html @@ -107,7 +107,7 @@

Attendee, Account, and Group Importer

- {{ options(c.DEALER_STATUS_OPTS) }} diff --git a/uber/utils.py b/uber/utils.py index af2550da2..11ff1defb 100644 --- a/uber/utils.py +++ b/uber/utils.py @@ -1403,6 +1403,13 @@ def attendee_account_import(import_job): import_job.errors += "; {}".format("; ".join(str(ex))) if import_job.errors else "; ".join(str(ex)) session.commit() return + + if c.SSO_EMAIL_DOMAINS: + local, domain = normalize_email(account_to_import['email'], split_address=True) + if domain in c.SSO_EMAIL_DOMAINS: + log.debug("Skipping account import for {} as it matches the SSO email domain.".format(account_to_import['email'])) + import_job.completed = datetime.now() + return account = session.query(AttendeeAccount).filter(AttendeeAccount.email == normalize_email(account_to_import['email'])).first() if not account: @@ -1427,19 +1434,22 @@ def attendee_account_import(import_job): pass for attendee in account_attendees: - # Try to match staff to their existing badge, which would be newer than the one we're importing if attendee.get('badge_num', 0) in range(c.BADGE_RANGES[c.STAFF_BADGE][0], c.BADGE_RANGES[c.STAFF_BADGE][1]): - old_badge_num = attendee['badge_num'] - existing_staff = session.query(Attendee).filter_by(badge_num=old_badge_num).first() - if existing_staff: - existing_staff.managers.append(account) - session.add(existing_staff) - else: - new_staff = TaskUtils.basic_attendee_import(attendee) - new_staff.badge_num = old_badge_num - new_staff.managers.append(account) - session.add(new_staff) - else: + if not c.SSO_EMAIL_DOMAINS: + # Try to match staff to their existing badge, which would be newer than the one we're importing + old_badge_num = attendee['badge_num'] + existing_staff = session.query(Attendee).filter_by(badge_num=old_badge_num).first() + if existing_staff: + existing_staff.managers.append(account) + session.add(existing_staff) + else: + new_staff = TaskUtils.basic_attendee_import(attendee) + new_staff.badge_num = old_badge_num + new_staff.managers.append(account) + session.add(new_staff) + # If SSO is used for attendee accounts, we don't import staff at all + elif attendee['badge_status'] not in [c.PENDING_STATUS, c.INVALID_STATUS, + c.IMPORTED_STATUS, c.INVALID_GROUP_STATUS]: # Workaround for a bug in the export, we can remove this check next year new_attendee = TaskUtils.basic_attendee_import(attendee) new_attendee.paid = c.NOT_PAID From 75bb85b94436657554e3f7359acb76875aace430 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Sat, 5 Aug 2023 19:37:59 -0400 Subject: [PATCH 02/16] Add protection against SAML replay attacks Using redis as a global session store, we now record processed SAML transactions to reject replay attacks. --- uber/config.py | 4 ++++ uber/configspec.ini | 5 +++++ uber/models/__init__.py | 6 +----- uber/site_sections/saml.py | 25 +++++++++++++++++++------ uber/tasks/__init__.py | 3 ++- uber/tasks/redis.py | 34 ++++++++++++++++++++++++++++++++++ uber/tasks/registration.py | 4 ++-- 7 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 uber/tasks/redis.py diff --git a/uber/config.py b/uber/config.py index 2acf82550..31a225d51 100644 --- a/uber/config.py +++ b/uber/config.py @@ -5,6 +5,7 @@ import os import pytz import re +import redis import uuid from collections import defaultdict, OrderedDict from datetime import date, datetime, time, timedelta @@ -987,6 +988,9 @@ def _unrepr(d): _unrepr(_config['appconf']) c.APPCONF = _config['appconf'].dict() c.SENTRY = _config['sentry'].dict() +c.REDISCONF = _config['redis'].dict() + +c.REDIS_STORE = redis.Redis(host=c.REDISCONF['host'], port=c.REDISCONF['port'], db=c.REDISCONF['db'], decode_responses=True) c.BADGE_PRICES = _config['badge_prices'] for _opt, _val in chain(_config.items(), c.BADGE_PRICES.items()): diff --git a/uber/configspec.ini b/uber/configspec.ini index bd3ee9578..560cf8a03 100644 --- a/uber/configspec.ini +++ b/uber/configspec.ini @@ -1837,6 +1837,11 @@ environment = string(default="production") sample_rate = integer(default=100) release = string(default="") +[redis] +host = string(default="localhost") +port = integer(default=6379) +db = integer(default=0) + [appconf] # This is all CherryPy configuration. diff --git a/uber/models/__init__.py b/uber/models/__init__.py index 2ee1ff9c5..20c85009a 100644 --- a/uber/models/__init__.py +++ b/uber/models/__init__.py @@ -1056,7 +1056,7 @@ def create_admin_account(self, attendee, password='', generate_pwd=True, **param self.add(new_account) return new_account - def create_attendee_account(self, email=None, normalized_email=None, password=None, match_existing_attendees=False): + def create_attendee_account(self, email=None, normalized_email=None, password=None): from uber.models import Attendee, AttendeeAccount from uber.utils import normalize_email_legacy @@ -1066,10 +1066,6 @@ def create_attendee_account(self, email=None, normalized_email=None, password=No new_account = AttendeeAccount(email=normalized_email, hashed=bcrypt.hashpw(password, bcrypt.gensalt()) if password else '') self.add(new_account) - if match_existing_attendees: - matching_attendees = self.query(Attendee).filter_by(normalized_email=normalize_email_legacy(email)) - for attendee in matching_attendees: - self.add_attendee_to_account(attendee, new_account) return new_account def add_attendee_to_account(self, attendee, account): diff --git a/uber/site_sections/saml.py b/uber/site_sections/saml.py index ec70fb169..7a51f26db 100644 --- a/uber/site_sections/saml.py +++ b/uber/site_sections/saml.py @@ -37,13 +37,23 @@ def acs(self, session, **params): req = prepare_saml_request(cherrypy.request) auth = OneLogin_Saml2_Auth(req, c.SAML_SETTINGS) auth.process_response() + + assertion_id = auth.get_last_assertion_id() + + if c.REDIS_STORE.hget('processed_saml_assertions', assertion_id): + log.error("Existing SAML assertion was replayed: {}. This is either an attack or a programming error.".format(assertion_id)) + raise HTTPRedirect("../landing/index?message={}", "Authentication unsuccessful.") + errors = auth.get_errors() if not errors: + c.REDIS_STORE.hset('processed_saml_assertions', assertion_id, auth.get_last_assertion_not_on_or_after()) + if auth.is_authenticated(): account_email = auth.get_nameid() admin_account = None account = None matching_attendee = session.query(Attendee).filter_by(is_valid=True, normalized_email=normalize_email_legacy(account_email)).first() + message = "We could not find any accounts from the email {}. Please contact your administrator.".format(account_email) try: admin_account = session.get_admin_account_by_email(account_email) @@ -71,15 +81,19 @@ def acs(self, session, **params): try: account = session.get_attendee_account_by_email(account_email) except NoResultFound: - account = session.create_attendee_account(account_email, match_existing_attendees=True) + all_matching_attendees = session.query(Attendee).filter_by(normalized_email=normalize_email_legacy(account_email)).all() + if all_matching_attendees: + account = session.create_attendee_account(account_email) + for attendee in all_matching_attendees: + session.add_attendee_to_account(attendee, account) + else: + message = "We could not find any registrations matching the email {}.".format(account_email) if account: cherrypy.session['attendee_account_id'] = account.id if not admin_account and not account: - raise HTTPRedirect("../landing/index?message={}", - "We could not find create any accounts from the email {}. \ - Please contact your administrator.".format(account_email)) + raise HTTPRedirect("../landing/index?message={}", message) if admin_account: attendee_to_update = admin_account.attendee @@ -95,7 +109,6 @@ def acs(self, session, **params): session.commit() redirect_url = req['post_data'].get('RelayState', '') - log.debug(redirect_url) if redirect_url: if OneLogin_Saml2_Utils.get_self_url(req) != redirect_url: @@ -109,7 +122,7 @@ def acs(self, session, **params): redirect_url = None if not redirect_url: - if not admin_account: + if not admin_account or not c.AT_THE_CON: redirect_url = "../preregistration/homepage" elif not account: redirect_url = "../accounts/homepage" diff --git a/uber/tasks/__init__.py b/uber/tasks/__init__.py index 2eff204e1..6276edeb9 100644 --- a/uber/tasks/__init__.py +++ b/uber/tasks/__init__.py @@ -65,8 +65,9 @@ def configure_celery_logger(loglevel, logfile, format, colorize, **kwargs): from uber.tasks import attractions # noqa: F401 from uber.tasks import email # noqa: F401 from uber.tasks import health # noqa: F401 -from uber.tasks import panels # noqa: F401 from uber.tasks import mivs # noqa: F401 +from uber.tasks import panels # noqa: F401 +from uber.tasks import redis # noqa: F401 from uber.tasks import registration # noqa: F401 from uber.tasks import sms # noqa: F401 from uber.tasks import tabletop # noqa: F401 diff --git a/uber/tasks/redis.py b/uber/tasks/redis.py new file mode 100644 index 000000000..317af028d --- /dev/null +++ b/uber/tasks/redis.py @@ -0,0 +1,34 @@ +from collections import defaultdict +from datetime import datetime, timedelta + +import stripe +import time +from celery.schedules import crontab +from pockets.autolog import log +from sqlalchemy import not_, or_ +from sqlalchemy.orm import joinedload + +from uber.config import c +from uber.decorators import render +from uber.models import ApiJob, Attendee, Email, Session, ReceiptTransaction +from uber.tasks.email import send_email +from uber.tasks import celery +from uber.utils import localized_now, TaskUtils +from uber.payments import ReceiptManager + + +__all__ = ['expire_processed_saml_assertions'] + + +@celery.schedule(timedelta(minutes=30)) +def expire_processed_saml_assertions(): + if not c.SAML_SETTINGS: + return + + rsession = c.REDIS_STORE.pipeline() + + for key, val in c.REDIS_STORE.hscan('processed_saml_assertions')[1].items(): + if int(val) < datetime.utcnow().timestamp(): + rsession.hdel('processed_saml_assertions', key) + + rsession.execute() \ No newline at end of file diff --git a/uber/tasks/registration.py b/uber/tasks/registration.py index 626b72e14..95998cbab 100644 --- a/uber/tasks/registration.py +++ b/uber/tasks/registration.py @@ -17,8 +17,8 @@ from uber.payments import ReceiptManager -__all__ = ['check_duplicate_registrations', 'check_placeholder_registrations', 'check_unassigned_volunteers', - 'check_missed_stripe_payments'] +__all__ = ['check_duplicate_registrations', 'check_placeholder_registrations', 'check_pending_badges', + 'check_unassigned_volunteers', 'check_near_cap', 'check_missed_stripe_payments', 'process_api_queue'] @celery.schedule(crontab(minute=0, hour='*/6')) From ffc9399f529ca844ec69c0d961eee8a905d95767 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Sat, 5 Aug 2023 19:44:13 -0400 Subject: [PATCH 03/16] Update SSO login workflow We decided to remove the "Admin Login" link on the landing page, and after much discussion, we are getting rid of login_select. If you have an admin account and at-con mode is on, you get dumped into the admin area; otherwise, you get taken to your homepage. I've also added extra links between the different sections to make traversal easier. --- uber/site_sections/accounts.py | 5 ++++- uber/site_sections/landing.py | 8 -------- uber/site_sections/saml.py | 6 ++---- uber/site_sections/staffing.py | 2 +- uber/templates/landing/index.html | 2 ++ uber/templates/landing/login_select.html | 10 ---------- uber/templates/preregistration/preregbase.html | 2 ++ uber/templates/preregistration/update_account.html | 1 + 8 files changed, 12 insertions(+), 24 deletions(-) delete mode 100644 uber/templates/landing/login_select.html diff --git a/uber/site_sections/accounts.py b/uber/site_sections/accounts.py index 45691a70c..3df62f9d1 100644 --- a/uber/site_sections/accounts.py +++ b/uber/site_sections/accounts.py @@ -167,7 +167,7 @@ def delete_access_group(self, session, id): @public def login(self, session, message='', original_location=None, **params): - original_location = create_valid_user_supplied_redirect_url(original_location, default_url='homepage') + original_location = create_valid_user_supplied_redirect_url(original_location, default_url='/accounts/homepage') if 'email' in params: try: @@ -188,6 +188,9 @@ def login(self, session, message='', original_location=None, **params): req = prepare_saml_request(cherrypy.request) auth = OneLogin_Saml2_Auth(req, c.SAML_SETTINGS) + login_redirect = auth.login(return_to=c.URL_ROOT + original_location) + log.debug(auth.get_last_request_id()) + log.debug(auth.get_last_assertion_id()) raise HTTPRedirect(auth.login(return_to=c.URL_ROOT + original_location)) diff --git a/uber/site_sections/landing.py b/uber/site_sections/landing.py index d7a251e88..68bc9e188 100644 --- a/uber/site_sections/landing.py +++ b/uber/site_sections/landing.py @@ -16,14 +16,6 @@ def index(self, session, **params): 'logged_in_account': session.current_attendee_account(), 'kiosk_mode': cherrypy.session.get('kiosk_mode'), } - - @requires_account() - def login_select(self, session, **params): - - return { - 'message': params.get('message') - } - def invalid(self, **params): return {'message': params.get('message')} diff --git a/uber/site_sections/saml.py b/uber/site_sections/saml.py index 7a51f26db..5eb3ece38 100644 --- a/uber/site_sections/saml.py +++ b/uber/site_sections/saml.py @@ -122,12 +122,10 @@ def acs(self, session, **params): redirect_url = None if not redirect_url: - if not admin_account or not c.AT_THE_CON: - redirect_url = "../preregistration/homepage" - elif not account: + if c.AT_THE_CON and admin_account: redirect_url = "../accounts/homepage" else: - redirect_url = "../landing/login_select" + redirect_url = "../preregistration/homepage" raise HTTPRedirect(redirect_url) else: diff --git a/uber/site_sections/staffing.py b/uber/site_sections/staffing.py index 1a1c219f9..eec4753a0 100644 --- a/uber/site_sections/staffing.py +++ b/uber/site_sections/staffing.py @@ -264,7 +264,7 @@ def drop(self, session, job_id, all=False): @public def login(self, session, message='', first_name='', last_name='', email='', zip_code='', original_location=None): - original_location = create_valid_user_supplied_redirect_url(original_location, default_url='index') + original_location = create_valid_user_supplied_redirect_url(original_location, default_url='/staffing/index') if first_name or last_name or email or zip_code: try: diff --git a/uber/templates/landing/index.html b/uber/templates/landing/index.html index f33344a1b..ff0decd77 100644 --- a/uber/templates/landing/index.html +++ b/uber/templates/landing/index.html @@ -47,11 +47,13 @@

Log in

{% endif %} + {% if not c.SSO_EMAIL_DOMAINS %}

Admin Login

+ {% endif %} {% if not c.ATTENDEE_ACCOUNTS_ENABLED %}

diff --git a/uber/templates/landing/login_select.html b/uber/templates/landing/login_select.html deleted file mode 100644 index 37c1962dc..000000000 --- a/uber/templates/landing/login_select.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ c.EVENT_NAME }} Registration{% endblock %} -{% block content %} -

-{% endblock %} \ No newline at end of file diff --git a/uber/templates/preregistration/preregbase.html b/uber/templates/preregistration/preregbase.html index ea7efd739..359e6299f 100644 --- a/uber/templates/preregistration/preregbase.html +++ b/uber/templates/preregistration/preregbase.html @@ -6,6 +6,8 @@ Admin Form {% elif c.HAS_GROUP_ADMIN_ACCESS and group and not group.is_new %} Admin Form + {% elif c.CURRENT_ADMIN %} + Admin Area {% endif %} {% if c.ATTENDEE_ACCOUNTS_ENABLED and logged_in_account and not account and c.PAGE_PATH != '/preregistration/homepage' %} Homepage diff --git a/uber/templates/preregistration/update_account.html b/uber/templates/preregistration/update_account.html index 3179bb779..c2cfbb01e 100644 --- a/uber/templates/preregistration/update_account.html +++ b/uber/templates/preregistration/update_account.html @@ -8,6 +8,7 @@ Log Out +
From 20a4fff7353f0c02884db8bb6f80096c2df321c1 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Sat, 5 Aug 2023 20:01:56 -0400 Subject: [PATCH 04/16] Fix SAML login redirects It turns out there was a bug that was masking a different bug, and I fixed the first bug in a prior commit, so this now fixes the second bug. --- uber/site_sections/accounts.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/uber/site_sections/accounts.py b/uber/site_sections/accounts.py index 3df62f9d1..7f727ab45 100644 --- a/uber/site_sections/accounts.py +++ b/uber/site_sections/accounts.py @@ -167,8 +167,20 @@ def delete_access_group(self, session, id): @public def login(self, session, message='', original_location=None, **params): - original_location = create_valid_user_supplied_redirect_url(original_location, default_url='/accounts/homepage') + if c.SAML_SETTINGS: + from uber.utils import prepare_saml_request + from onelogin.saml2.auth import OneLogin_Saml2_Auth + if original_location: + redirect_url = c.URL_ROOT + create_valid_user_supplied_redirect_url(original_location, default_url='') + else: + redirect_url = '' + + req = prepare_saml_request(cherrypy.request) + auth = OneLogin_Saml2_Auth(req, c.SAML_SETTINGS) + raise HTTPRedirect(auth.login(return_to=redirect_url)) + + original_location = create_valid_user_supplied_redirect_url(original_location, default_url='/accounts/homepage') if 'email' in params: try: account = session.get_admin_account_by_email(params['email']) @@ -182,18 +194,6 @@ def login(self, session, message='', original_location=None, **params): ensure_csrf_token_exists() raise HTTPRedirect(original_location) - if c.SAML_SETTINGS: - from uber.utils import prepare_saml_request - from onelogin.saml2.auth import OneLogin_Saml2_Auth - - req = prepare_saml_request(cherrypy.request) - auth = OneLogin_Saml2_Auth(req, c.SAML_SETTINGS) - login_redirect = auth.login(return_to=c.URL_ROOT + original_location) - log.debug(auth.get_last_request_id()) - log.debug(auth.get_last_assertion_id()) - - raise HTTPRedirect(auth.login(return_to=c.URL_ROOT + original_location)) - return { 'message': message, 'email': params.get('email', ''), From ba53672c610ea35095da59f7a551811ce03fcdc3 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Sat, 5 Aug 2023 20:19:08 -0400 Subject: [PATCH 05/16] Re-validate attendees during payment Before we show attendees the payment form, we need to re-check to make sure they pass validations, in particular to catch any sold-out extras they are trying to buy. --- uber/site_sections/preregistration.py | 14 +++++++++++--- uber/utils.py | 11 +++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index 97f22ed3e..121c5de74 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -25,7 +25,7 @@ ReceiptTransaction, SignedDocument, Tracking from uber.tasks.email import send_email from uber.utils import add_opt, check, check_pii_consent, localized_now, normalize_email, genpasswd, valid_email, \ - valid_password, SignNowDocument, validate_model + valid_password, SignNowDocument, validate_model, post_form_validate_model from uber.payments import PreregCart, TransactionRequest, ReceiptManager import uber.validations as validations @@ -599,8 +599,16 @@ def prereg_payment(self, session, message='', **params): for attendee in cart.attendees: if not message and attendee.promo_code_id: message = check_prereg_promo_code(session, attendee) - - # TODO: Add validations back!! + if not message: + form_list = ['PersonalInfo', 'BadgeExtras', 'OtherInfo', 'Consents'] + forms = load_forms({}, attendee, attendee_forms, form_list) + + all_errors = validate_model(forms, attendee, validations.attendee) + if all_errors: + # Flatten the errors as we don't have fields on this page + message = ' '.join([' '.join(val) for val in all_errors['error'].values()]) + if message: + break if not message: receipts = [] diff --git a/uber/utils.py b/uber/utils.py index 11ff1defb..a8dce46b3 100644 --- a/uber/utils.py +++ b/uber/utils.py @@ -527,14 +527,17 @@ def check_pii_consent(params, attendee=None): return '' -def validate_model(forms, model, preview_model, extra_validators_module=None, is_admin=False): +def validate_model(forms, model, preview_model=None, extra_validators_module=None, is_admin=False): from wtforms import validators from wtforms.validators import ValidationError, StopValidation all_errors = defaultdict(list) - - for module in forms.values(): - module.populate_obj(preview_model) # We need a populated model BEFORE we get its optional fields below + + if not preview_model: + preview_model = model + else: + for module in forms.values(): + module.populate_obj(preview_model) # We need a populated model BEFORE we get its optional fields below for module in forms.values(): extra_validators = defaultdict(list) From b0faf75aaee286ccc930de7ede792d5ac79182aa Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Sun, 6 Aug 2023 11:45:24 -0400 Subject: [PATCH 06/16] Fix import error Not really sure how this got past me! --- uber/site_sections/preregistration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index 121c5de74..8035d488d 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -25,7 +25,7 @@ ReceiptTransaction, SignedDocument, Tracking from uber.tasks.email import send_email from uber.utils import add_opt, check, check_pii_consent, localized_now, normalize_email, genpasswd, valid_email, \ - valid_password, SignNowDocument, validate_model, post_form_validate_model + valid_password, SignNowDocument, validate_model from uber.payments import PreregCart, TransactionRequest, ReceiptManager import uber.validations as validations From 12ccb5b64dfe87eca7ee5cb3e607598488b78160 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Mon, 7 Aug 2023 10:07:03 -0400 Subject: [PATCH 07/16] Fix dealer application editing bug Found this while testing our dealer reg for MAGFest -- you could never update your badges while editing an application because it would always just grab the number of badges from the session. We're not merging MAGFest code back in for a bit so I'm also making the change here. Also fixes a JS bug that, oddly, doesn't seem to affect MFF... but I figured I should do it just in case. --- uber/site_sections/preregistration.py | 7 ++++--- uber/static/js/window-hash-tabload.js | 1 - 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index 8035d488d..cce4957f9 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -272,17 +272,18 @@ def dealer_registration(self, session, message='', edit_id=None, **params): params['id'] = 'None' # security! group = Group() - badges = params.get('badges', 0) if edit_id is not None: group = self._get_unsaved(edit_id, PreregCart.pending_dealers) - badges = getattr(group, 'badge_count', 0) + params['badges'] = params.get('badges', getattr(group, 'badge_count', 0)) if params.get('old_group_id'): old_group = session.group(params['old_group_id']) old_group_dict = session.group(params['old_group_id']).to_dict(c.GROUP_REAPPLY_ATTRS) group.apply(old_group_dict, ignore_csrf=True, restricted=True) - badges = old_group.badges_purchased + params['badges'] = params.get('badges', old_group.badges_purchased) + + badges = params.get('badges', 0) forms = load_forms(params, group, group_forms, ['ContactInfo', 'TableInfo']) for form in forms.values(): diff --git a/uber/static/js/window-hash-tabload.js b/uber/static/js/window-hash-tabload.js index 0ec0ed4b6..264555a9c 100644 --- a/uber/static/js/window-hash-tabload.js +++ b/uber/static/js/window-hash-tabload.js @@ -7,7 +7,6 @@ $().ready(function() { var tab = $(tabID + '-tab'); } catch(error) { new bootstrap.Tab($('.nav-tabs button').first()).show(); - return false; } if(tab && tab.length) { new bootstrap.Tab(tab).show(); From 2c3abc372cb998aee78da2e9a892f29951449952 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Wed, 9 Aug 2023 00:15:03 -0400 Subject: [PATCH 08/16] Move README to /forms --- uber/{templates => }/forms/README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename uber/{templates => }/forms/README.md (100%) diff --git a/uber/templates/forms/README.md b/uber/forms/README.md similarity index 100% rename from uber/templates/forms/README.md rename to uber/forms/README.md From 775c5cf656a48131797346f92cf521f633c92857 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Wed, 9 Aug 2023 11:06:10 -0400 Subject: [PATCH 09/16] Fix prereg_payment validations Fixes a couple 500 errors with prereg_payment running validations. --- uber/site_sections/preregistration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index cce4957f9..d2511aa45 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -604,10 +604,10 @@ def prereg_payment(self, session, message='', **params): form_list = ['PersonalInfo', 'BadgeExtras', 'OtherInfo', 'Consents'] forms = load_forms({}, attendee, attendee_forms, form_list) - all_errors = validate_model(forms, attendee, validations.attendee) + all_errors = validate_model(forms, attendee, extra_validators_module=validations.attendee) if all_errors: # Flatten the errors as we don't have fields on this page - message = ' '.join([' '.join(val) for val in all_errors['error'].values()]) + message = ' '.join([' '.join(val) for val in all_errors['error']]) if message: break From 74ffc36dcc50525288b86bac8077abba54cb7e53 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Wed, 9 Aug 2023 18:24:20 -0400 Subject: [PATCH 10/16] Move card desc to under cards This matches other types of fields anyway. --- uber/templates/forms/macros.html | 28 +++---- uber/templates/landing/index.html | 131 +++++++++++++++--------------- 2 files changed, 80 insertions(+), 79 deletions(-) diff --git a/uber/templates/forms/macros.html b/uber/templates/forms/macros.html index 36db4b152..11e7216c2 100644 --- a/uber/templates/forms/macros.html +++ b/uber/templates/forms/macros.html @@ -159,12 +159,13 @@ #} {% set label_required = kwargs['required'] if 'required' in kwargs else None %} +{% set show_desc = not admin_area and help_text or target_field.description %}
- {{ target_field.label.text }} - {% if target_field.flags.required %} *{% endif %} - {% if not admin_area and (help_text or target_field.description) %}  {{ help_text or target_field.description }}{% endif %} -
+ + {{ target_field.label.text }}{% if target_field.flags.required %} *{% endif %} + +
{% for opt in opts %} {% set disabled_card = opt.value in disabled_opts %}
@@ -182,20 +183,18 @@ {% if opt.icon %} {{ opt.name }} Icon {% endif %} - {% if disabled_card %} + {% if disabled_card %} {{ opt.desc|safe }} - {% if disabled_card %} -
- - {{ disabled_hover_text }} - -
- {% endif %} - {% else %} +
+ + {{ disabled_hover_text }} + +
+ {% else %} {{ opt.desc|safe }} - {% endif %} + {% endif %}
{% if opt.link %} {% endfor %}
+{% if show_desc %}
{{ help_text or target_field.description }}
{% endif %}
-
-
+
+

{{ c.EVENT_NAME }} Art Show Application

{% if c.AFTER_ART_SHOW_DEADLINE and not c.HAS_ART_SHOW_ACCESS %} Unfortunately, the deadline for art show applications has passed and we are no longer accepting applications. From d73821115a02676e4c3fbc7963902d31ab27b547 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Thu, 10 Aug 2023 14:39:49 -0400 Subject: [PATCH 12/16] Manually port fixes from MAGFest We're in something similar to a code freeze, but these bugs were a major blocker so we're reimplementing them here. --- uber/config.py | 8 ++++---- uber/site_sections/accounts.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/uber/config.py b/uber/config.py index 31a225d51..81a1029d9 100644 --- a/uber/config.py +++ b/uber/config.py @@ -556,7 +556,7 @@ def PREREG_AGE_GROUP_OPTS(self): @property def NOW_OR_AT_CON(self): - return c.EPOCH.date() if date.today().date() <= c.EPOCH.date() else uber.utils.localized_now().date() + return c.EPOCH.date() if date.today() <= c.EPOCH.date() else uber.utils.localized_now().date() @property def AT_OR_POST_CON(self): @@ -1024,13 +1024,13 @@ def _unrepr(d): for _opt, _val in c.BADGE_PRICES['attendee'].items(): try: if ' ' in _opt: - date = c.EVENT_TIMEZONE.localize(datetime.strptime(_opt, '%Y-%m-%d %H%M')) + price_date = c.EVENT_TIMEZONE.localize(datetime.strptime(_opt, '%Y-%m-%d %H%M')) else: - date = c.EVENT_TIMEZONE.localize(datetime.strptime(_opt, '%Y-%m-%d')) + price_date = c.EVENT_TIMEZONE.localize(datetime.strptime(_opt, '%Y-%m-%d')) except ValueError: c.PRICE_LIMITS[int(_opt)] = _val else: - c.PRICE_BUMPS[date] = _val + c.PRICE_BUMPS[price_date] = _val c.ORDERED_PRICE_LIMITS = sorted([val for key, val in c.PRICE_LIMITS.items()]) diff --git a/uber/site_sections/accounts.py b/uber/site_sections/accounts.py index 7f727ab45..162debb8d 100644 --- a/uber/site_sections/accounts.py +++ b/uber/site_sections/accounts.py @@ -51,8 +51,7 @@ def index(self, session, message=''): def update(self, session, password='', message='', **params): if not params.get('attendee_id', '') and params.get('id', 'None') == 'None': message = "Please select an attendee to create an admin account for." - attendee = session.attendee(params['attendee_id']) - + if not message: account = session.admin_account(params) @@ -72,6 +71,7 @@ def update(self, session, password='', message='', **params): message = message or check(account) if not message: message = 'Account settings uploaded' + attendee = session.attendee(account.attendee_id) # dumb temporary hack, will fix later with tests account.attendee = attendee session.add(account) if account.is_new and not c.AT_OR_POST_CON: From 7ed69877d55b4826b9387fa970c902fef568fcf1 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Thu, 10 Aug 2023 14:40:22 -0400 Subject: [PATCH 13/16] Don't change already invalid attendees' statuses If an attendee is marked Refunded, we don't want them to change to invalid group status just because their group is invalid. --- uber/models/attendee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uber/models/attendee.py b/uber/models/attendee.py index 0c410f925..dedf98d41 100644 --- a/uber/models/attendee.py +++ b/uber/models/attendee.py @@ -504,7 +504,7 @@ def _misc_adjustments(self): @presave_adjustment def _status_adjustments(self): - if self.group and self.paid == c.PAID_BY_GROUP: + if self.group and self.paid == c.PAID_BY_GROUP and self.has_or_will_have_badge: if not self.group.is_valid: self.badge_status = c.INVALID_GROUP_STATUS elif self.group.is_dealer and self.group.status != c.APPROVED: From 70a7a8982ba4162505264fcab8183c7d542f7793 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Fri, 11 Aug 2023 01:54:25 -0400 Subject: [PATCH 14/16] Refactor form validations This will stop requiring extra modules to be thrown around and centralizes all form-level validations under the actual form classes. --- uber/decorators.py | 21 --- uber/forms/README.md | 68 ++++++++- uber/forms/__init__.py | 27 ++++ uber/forms/attendee.py | 101 ++++++++++-- uber/forms/group.py | 14 +- uber/model_checks.py | 156 ++++++++++++++++++- uber/site_sections/group_admin.py | 11 +- uber/site_sections/preregistration.py | 6 +- uber/utils.py | 31 ++-- uber/validations/__init__.py | 0 uber/validations/attendee.py | 212 -------------------------- uber/validations/group.py | 33 ---- 12 files changed, 360 insertions(+), 320 deletions(-) delete mode 100644 uber/validations/__init__.py delete mode 100644 uber/validations/attendee.py delete mode 100644 uber/validations/group.py diff --git a/uber/decorators.py b/uber/decorators.py index 7d14dd57e..5b9ff163d 100644 --- a/uber/decorators.py +++ b/uber/decorators.py @@ -726,27 +726,6 @@ def wrapper(func): validation, prereg_validation = Validation(), Validation() -class WTFormValidation: - def __init__(self): - self.validations = defaultdict(OrderedDict) - - def __getattr__(self, field_name): - def wrapper(func): - self.validations[field_name][func.__name__] = func - return func - return wrapper - - def get_validations_by_field(self, field_name): - field_validations = self.validations.get(field_name) - return list(field_validations.values()) if field_validations else [] - - def get_validation_dict(self): - all_validations = {} - for key, dict in self.validations.items(): - all_validations[key] = list(dict.values()) - return all_validations - - class ReceiptItemConfig: def __init__(self): self.items = defaultdict(OrderedDict) diff --git a/uber/forms/README.md b/uber/forms/README.md index 247e4e4d7..37a228542 100644 --- a/uber/forms/README.md +++ b/uber/forms/README.md @@ -1,19 +1,75 @@ # Form Guide for Ubersystem/RAMS +NOTE: this guide is currently being built in Notion, below is a rough draft. + Forms represent the vast majority of attendees' (and many admins'!) interaction with Ubersystem. As such, they are highly dependent on business logic and are often complex. A single field may be required under some conditions but not others, change its labeling in some contexts, show help text to attendees but not admins, etc. This guide is to help you understand, edit, and override forms without creating a giant mess. Hopefully. ## Forms Are a WIP -Up until the writing of this guide, all forms in Ubersystem were driven entirely by Jinja2 macros, jQuery, and HTML, with form handling done largely inside individual page handlers (with the exception of validations -- more info on those below). As of early 2023, **attendee** and **group** forms are the only forms that use the technologies and conventions described below, unless otherwise noted. Attendee and group forms are by far the most complex forms in the app and were in the most need of an overhaul, but we do intend to update other forms in future years. +Up until the writing of this guide, all forms in Ubersystem were driven entirely by Jinja2 macros, jQuery, and HTML, with form handling done largely inside individual page handlers (with the exception of validations, which were all in **model_checks.py**). As of 2023, *attendee* and *group* forms are the only forms that use the technologies and conventions described below, unless otherwise noted. Conversion of other forms is ongoing, and help with those conversions is extremely welcome. ## How Forms Are Built We rely on the following frameworks and modules for our forms: - [WTForms](https://wtforms.readthedocs.io/en/3.0.x/) defines our forms as declarative data, along with many of their static properties. Each set of forms is organized in one file per type of entity, similar to our **models** folder, and they are found in **uber/forms/**. Inherited classes and other WTForms customizations are in **uber/forms/__init__.py**. -- [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/templates/) provides **macros** that render the scaffolding for fields (these macros call WTForms to render the fields themselves) and **blocks** that define sections of forms for appending fields or overriding. +- [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/templates/) provides *macros* that render the scaffolding for fields (these macros call WTForms to render the fields themselves) and *blocks* that define sections of forms for appending fields or overriding. - Form macros are found in **uber/template/forms/macros.html** -- always use these macros rather than writing your own scaffolding. -- [Bootstrap 5](https://getbootstrap.com/docs/5.0/) provides the styling and responsive layout for forms. Always use the grid layout ("col" divs contained inside "row g-sm-3" divs) when adding fields. - -### Form Validations +- [Bootstrap 5](https://getbootstrap.com/docs/5.0/) provides the styling and responsive layout for forms. Always use the grid layout ("col" divs contained inside "row" divs) when adding fields. + +## Form Validations +There are three broad categories of validations: +- Field-level validations: simple data validations, often [WTForm built-in validations](https://wtforms.readthedocs.io/en/3.0.x/validators/), which are passed to field constructors. These validations only have access to their form's data. Additional field validations are implemented using the `CustomValidation()` +- Custom validations: validations that require knowledge of the model or use complex calculations, and in some cases don't even correspond to any fields. For example, we check and make sure attendees don't have so many badge extras that their total is above $9,999, as that is Stripe's limit. +- "New or changed" validations: these are custom validations that only run if a specific field's data changed or if a model has not been saved to the database yet. For example, we check that a selected preordered merch package is still available, but only if the preordered merch field changed or the attendee is new. + +### Some caveats +WTForms has a way to add custom validations to any field by adding a `validate_fieldname` function to a form. We avoid using this because it can only return one error, which is poor UX, and because it is difficult to override these validations in event plugins. + +Field-level validations have an added bonus of rendering matching clientside validations where possible. However, we actually skip running clientside validations on our forms, again because they only show only one error at a time. Since we use AJAX for server validations and display all errors at once, clientside validations are rendered moot. + +### Field-Level Validations +Adding a field-level validation involves simply passing the validator class(es) in a list through the field constructor's `validators` parameter. Let's take this `email` field as an example: +``` +email = EmailField('Email Address', validators=[ + validators.InputRequired("Please enter an email address."), + validators.Length(max=255, message="Email addresses cannot be longer than 255 characters."), + validators.Email(granular_message=True), + ], + render_kw={'placeholder': 'test@example.com'}) +``` +This field has three field validations: a validator that requires the field not be empty, a validator that limits the email address to 255 characters, and a validator that checks the email address using the [email_validator package](https://pypi.org/project/email-validator/) and passes through the exact error message if the email fails validation. + +For more about what validators are available, see [WTForms' documentation on built-in validators](https://wtforms.readthedocs.io/en/3.0.x/validators/). We may also at some point add our own field-level validators -- if so, they should be in **forms/validations.py**. + +### Selectively Required Fields and `get_optional_fields` +Almost all required fields should have the `InputRequired` validator passed to their constructor rather than a custom validator to check their data. However, many fields are optional in certain circumstances, usually based on the model's current state. The special function `get_optional_fields` bridges this gap. + +Let's look at a simple example of this function for a group. +``` +class TableInfo(GroupInfo): + name = StringField('Table Name', validators=[ + validators.InputRequired("Please enter a table name."), + validators.Length(max=40, message="Table names cannot be longer than 40 characters.") + ]) + description = StringField('Description', validators=[ + validators.InputRequired("Please provide a brief description of your business.") + ], description="Please keep to one sentence.") + // other fields + + def get_optional_fields(self, group, is_admin=False): + optional_list = super().get_optional_fields(group, is_admin) + if not group.is_dealer: + optional_list.extend( + ['description', 'website', 'wares', 'categories', 'address1', 'city', 'region', 'zip_code', 'country']) + return optional_list +``` +This function gets any optional fields from its parent class. Then, if the group's `is_dealer` property is false, it adds all of the form's dealer-related fields to the list of optional fields. Finally, it returns the list of optional fields. Based on this, a non-dealer group would be required to have a `name` but not a `description`. + +The parameters are: +- `group`: a model object, e.g., `Attendee` or `Group`. This model will always be a "preview model" that has already had any form updates applied to it. For this reason, do _not_ check `group.is_new`, as the preview model is always "new". +- `is_admin`: a boolean that is True if the model is being viewed in the admin area; you'll almost never need it, but there are a few cases where fields are optional for admins when they would not be optional for attendees. + +**NOTE**: If a field is returned by `get_optional_fields`, _all_ validations are skipped _only if_ the field is empty. + +### Custom and New-Or-Changed Validations -### Form Fields and Permissions ## Adding Fields First, figure out if you want to add fields to an existing form or if you want to add a new form. Multiple forms can be combined and processed seamlessly on a single page, so it is good to group like fields together into their own 'forms.' Pay particular attention to which fields represent personal identifying information (PII) and group them separately from fields that don't. diff --git a/uber/forms/__init__.py b/uber/forms/__init__.py index 678231207..7fc451bd2 100644 --- a/uber/forms/__init__.py +++ b/uber/forms/__init__.py @@ -1,6 +1,7 @@ import re import cherrypy +from collections import defaultdict, OrderedDict from importlib import import_module from markupsafe import Markup from wtforms import Form, StringField, SelectField, IntegerField, BooleanField, validators @@ -81,8 +82,33 @@ def load_forms(params, model, module, form_list, prefix_dict={}, get_optional=Tr return form_dict +class CustomValidation: + def __init__(self): + self.validations = defaultdict(OrderedDict) + + def __bool__(self): + return bool(self.validations) + + def __getattr__(self, field_name): + def wrapper(func): + self.validations[field_name][func.__name__] = func + return func + return wrapper + + def get_validations_by_field(self, field_name): + field_validations = self.validations.get(field_name) + return list(field_validations.values()) if field_validations else [] + + def get_validation_dict(self): + all_validations = {} + for key, dict in self.validations.items(): + all_validations[key] = list(dict.values()) + return all_validations + + class MagForm(Form): field_aliases = {} + field_validation, new_or_changed_validation = CustomValidation(), CustomValidation() def get_optional_fields(self, model, is_admin=False): return [] @@ -342,3 +368,4 @@ def getlist(self, arg): from uber.forms.attendee import * # noqa: F401,E402,F403 +from uber.forms.group import * # noqa: F401,E402,F403 diff --git a/uber/forms/attendee.py b/uber/forms/attendee.py index 541990463..527affeaa 100644 --- a/uber/forms/attendee.py +++ b/uber/forms/attendee.py @@ -10,14 +10,28 @@ from wtforms.validators import ValidationError, StopValidation from uber.config import c -from uber.forms import AddressForm, MultiCheckbox, MagForm, SwitchInput, DollarInput, HiddenIntField +from uber.forms import AddressForm, MultiCheckbox, MagForm, SwitchInput, DollarInput, HiddenIntField, CustomValidation from uber.custom_tags import popup_link from uber.model_checks import invalid_phone_number -from uber.validations import attendee as attendee_validators -__all__ = ['AdminInfo', 'BadgeExtras', 'PersonalInfo', 'OtherInfo', 'Consents'] +from uber.badge_funcs import get_real_badge_type +from uber.custom_tags import format_currency +from uber.models import Attendee, Session, PromoCode, PromoCodeGroup +from uber.model_checks import invalid_zip_code, invalid_phone_number +from uber.utils import get_age_from_birthday, get_age_conf_from_birthday + +__all__ = ['AdminInfo', 'BadgeExtras', 'PersonalInfo', 'PreregOtherInfo', 'OtherInfo', 'Consents'] + +def attendee_age_checks(form, field): + age_group_conf = get_age_conf_from_birthday(field.data, c.NOW_OR_AT_CON) \ + if (hasattr(form, "birthdate") and form.birthdate.data) else field.data + if age_group_conf and not age_group_conf['can_register']: + raise ValidationError('Attendees {} years of age do not need to register, ' \ + 'but MUST be accompanied by a parent at all times!'.format(age_group_conf['desc'].lower())) class PersonalInfo(AddressForm, MagForm): + field_validation = CustomValidation() + first_name = StringField('First Name', validators=[ validators.InputRequired("Please provide your first name.") ], render_kw={'autocomplete': "fname"}) @@ -39,7 +53,7 @@ class PersonalInfo(AddressForm, MagForm): ], render_kw={'placeholder': 'A phone number we can use to contact you during the event'}) birthdate = DateField('Date of Birth', validators=[ validators.InputRequired("Please enter your date of birth.") if c.COLLECT_EXACT_BIRTHDATE else validators.Optional(), - attendee_validators.attendee_age_checks + attendee_age_checks ]) age_group = SelectField('Age Group', validators=[ validators.InputRequired("Please select your age group.") if not c.COLLECT_EXACT_BIRTHDATE else validators.Optional() @@ -73,7 +87,7 @@ def get_optional_fields(self, attendee, is_admin=False): return ['first_name', 'last_name', 'legal_name', 'email', 'birthdate', 'age_group', 'ec_name', 'ec_phone', 'address1', 'city', 'region', 'region_us', 'region_canada', 'zip_code', 'country', 'onsite_contact'] - optional_list = super().get_optional_fields(attendee) + optional_list = super().get_optional_fields(attendee, is_admin) if self.same_legal_name.data: optional_list.append('legal_name') @@ -101,27 +115,33 @@ def get_non_admin_locked_fields(self, attendee): return locked_fields + ['first_name', 'last_name', 'legal_name', 'same_legal_name'] - def validate_onsite_contact(form, field): + @field_validation.onsite_contact + def require_onsite_contact(form, field): if not field.data and not form.no_onsite_contact.data: raise ValidationError('Please enter contact information for at least one trusted friend onsite, \ or indicate that we should use your emergency contact information instead.') - def validate_birthdate(form, field): + @field_validation.birthdate + def birthdate_format(form, field): # TODO: Make WTForms use this message instead of the generic DateField invalid value message if field.data and not isinstance(field.data, date): raise StopValidation('Please use the format YYYY-MM-DD for your date of birth.') elif field.data and field.data > date.today(): raise ValidationError('You cannot be born in the future.') - def validate_cellphone(form, field): + @field_validation.cellphone + def valid_cellphone(form, field): if field.data and invalid_phone_number(field.data): raise ValidationError('Your phone number was not a valid 10-digit US phone number. ' \ 'Please include a country code (e.g. +44) for international numbers.') + @field_validation.cellphone + def not_same_cellphone_ec(form, field): if field.data and field.data == form.ec_phone.data: raise ValidationError("Your phone number cannot be the same as your emergency contact number.") - - def validate_ec_phone(form, field): + + @field_validation.ec_phone + def valid_ec_phone(form, field): if not form.international.data and invalid_phone_number(field.data): if c.COLLECT_FULL_ADDRESS: raise ValidationError('Please enter a 10-digit US phone number or include a ' \ @@ -131,6 +151,7 @@ def validate_ec_phone(form, field): class BadgeExtras(MagForm): + field_validation, new_or_changed_validation = CustomValidation(), CustomValidation() field_aliases = {'badge_type': ['upgrade_badge_type']} badge_type = HiddenIntField('Badge Type') @@ -164,18 +185,53 @@ def get_non_admin_locked_fields(self, attendee): locked_fields.append('badge_type') return locked_fields - - def validate_shirt(form, field): - if (form.amount_extra.data > 0 or form.badge_type.data in c.BADGE_TYPE_PRICES) and field.data == c.NO_SHIRT: - raise ValidationError("Please select a shirt size.") def get_optional_fields(self, attendee, is_admin=False): - optional_list = super().get_optional_fields(attendee) + optional_list = super().get_optional_fields(attendee, is_admin) if attendee.badge_type not in c.PREASSIGNED_BADGE_TYPES: optional_list.append('badge_printed_name') return optional_list + + @field_validation.shirt + def require_shirt(form, field): + if (form.amount_extra.data > 0 or form.badge_type.data in c.BADGE_TYPE_PRICES) and field.data == c.NO_SHIRT: + raise ValidationError("Please select a shirt size.") + + @new_or_changed_validation.amount_extra + def upgrade_sold_out(form, field): + currently_available_upgrades = [tier['value'] for tier in c.PREREG_DONATION_DESCRIPTIONS] + if field.data and field.data not in currently_available_upgrades: + raise ValidationError("The upgrade you have selected is sold out.") + + @new_or_changed_validation.badge_type + def no_more_custom_badges(form, field): + if field.data in c.PREASSIGNED_BADGE_TYPES and c.AFTER_PRINTED_BADGE_DEADLINE: + with Session() as session: + admin = session.current_admin_account() + if admin.is_super_admin: + return + raise ValidationError('Custom badges have already been ordered, please choose a different badge type.') + + @new_or_changed_validation.badge_type + def out_of_badge_type(form, field): + badge_type = get_real_badge_type(field.data) + with Session() as session: + try: + session.get_next_badge_num(badge_type) + except AssertionError: + raise ValidationError('We are sold out of {} badges.'.format(c.BADGES[badge_type])) + + @new_or_changed_validation.badge_printed_name + def past_printed_deadline(form, field): + if field.data in c.PREASSIGNED_BADGE_TYPES and c.PRINTED_BADGE_DEADLINE and c.AFTER_PRINTED_BADGE_DEADLINE: + with Session() as session: + admin = session.current_admin_account() + if admin.is_super_admin: + return + raise ValidationError('{} badges have already been ordered, so you cannot change your printed badge name.'.format( + c.BADGES[field.data])) class OtherInfo(MagForm): @@ -241,5 +297,18 @@ def pii_consent_label(self): class AdminInfo(MagForm): + field_validation, new_or_changed_validation = CustomValidation(), CustomValidation() placeholder = BooleanField('Placeholder') - group_id = StringField('Group') \ No newline at end of file + group_id = StringField('Group') + + @new_or_changed_validation.badge_num + def dupe_badge_num(session, 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): + 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)) \ No newline at end of file diff --git a/uber/forms/group.py b/uber/forms/group.py index d419359bb..fd9ced7d6 100644 --- a/uber/forms/group.py +++ b/uber/forms/group.py @@ -5,7 +5,7 @@ from wtforms.validators import ValidationError, StopValidation from uber.config import c -from uber.forms import AddressForm, MultiCheckbox, MagForm, IntSelect, SwitchInput, DollarInput, HiddenIntField +from uber.forms import AddressForm, CustomValidation, MultiCheckbox, MagForm, IntSelect, SwitchInput, DollarInput, HiddenIntField from uber.custom_tags import popup_link, format_currency, pluralize, table_prices from uber.model_checks import invalid_phone_number @@ -35,7 +35,9 @@ class AdminGroupInfo(GroupInfo): guest_group_type = SelectField('Checklist Type', default="", choices=[('', 'N/A')] + c.GROUP_TYPE_OPTS, coerce=int) can_add = BooleanField('This group may purchase additional badges.') new_badge_type = SelectField('Badge Type', choices=c.BADGE_OPTS, coerce=int) - cost = IntegerField('Total Group Price', widget=DollarInput()) + cost = IntegerField('Total Group Price', validators=[ + validators.NumberRange(min=0, message="Total Group Price must be a number that is 0 or higher.") + ], widget=DollarInput()) auto_recalc = BooleanField('Automatically recalculate this number.') amount_paid_repr = StringField('Amount Paid', render_kw={'disabled': "disabled"}) amount_refunded_repr = StringField('Amount Refunded', render_kw={'disabled': "disabled"}) @@ -86,10 +88,12 @@ class TableInfo(GroupInfo): special_needs = TextAreaField('Special Needs', description="No guarantees that we can accommodate any requests.") def get_optional_fields(self, group, is_admin=False): + optional_list = super().get_optional_fields(group, is_admin) if not group.is_dealer: - return ['description', 'website', 'wares', 'categories', - 'address1', 'city', 'region', 'zip_code', 'country'] - return [] + optional_list.extend( + ['description', 'website', 'wares', 'categories', + 'address1', 'city', 'region', 'zip_code', 'country']) + return optional_list def get_non_admin_locked_fields(self, group): if group.is_new or group.status in c.DEALER_EDITABLE_STATUSES: diff --git a/uber/model_checks.py b/uber/model_checks.py index 5b8bf78e7..ffefe3cd3 100644 --- a/uber/model_checks.py +++ b/uber/model_checks.py @@ -20,6 +20,7 @@ import phonenumbers from pockets.autolog import log +from uber.badge_funcs import get_real_badge_type from uber.config import c from uber.custom_tags import format_currency from uber.decorators import prereg_validation, validation @@ -28,7 +29,7 @@ GuestDetailedTravelPlan, IndieDeveloper, IndieGame, IndieGameCode, IndieJudge, IndieStudio, Job, MarketplaceApplication, \ MITSApplicant, MITSDocument, MITSGame, MITSPicture, MITSTeam, PanelApplicant, PanelApplication, \ PromoCode, PromoCodeGroup, Sale, Session, WatchList -from uber.utils import localized_now, valid_email +from uber.utils import localized_now, valid_email, get_age_from_birthday from uber.payments import PreregCart @@ -1007,8 +1008,126 @@ def media_max_length(piece): if len(piece.media) > 15: return "The description of the piece's media must be 15 characters or fewer." +### New validations, which return a tuple with the field name (or an empty string) and the message @prereg_validation.Attendee +def reasonable_total_cost(attendee): + if attendee.total_cost >= 999999: + return ('', 'We cannot charge {}. Please reduce extras so the total is below $9,999.'.format( + format_currency(attendee.total_cost))) + + +@prereg_validation.Attendee +def allowed_to_register(attendee): + if not attendee.age_group_conf['can_register']: + return ('', 'Attendees {} years of age do not need to register, ' \ + 'but MUST be accompanied by a parent at all times!'.format(attendee.age_group_conf['desc'].lower())) + + +@prereg_validation.Attendee +def child_group_leaders(attendee): + if attendee.badge_type == c.PSEUDO_GROUP_BADGE and get_age_from_birthday(attendee.birthdate, c.NOW_OR_AT_CON) < 13: + return ('badge_type', "Children under 13 cannot be group leaders.") + + +@prereg_validation.Attendee +def no_more_child_badges(attendee): + if c.CHILD_BADGE in c.PREREG_BADGE_TYPES and get_age_from_birthday(attendee.birthdate, c.NOW_OR_AT_CON) < 18 \ + and not c.CHILD_BADGE_AVAILABLE: + return ('badge_type', "Unfortunately, we are sold out of badges for attendees under 18.") + + +@prereg_validation.Attendee +def child_badge_over_13(attendee): + if c.CHILD_BADGE in c.PREREG_BADGE_TYPES and attendee.badge_type == c.CHILD_BADGE \ + and get_age_from_birthday(attendee.birthdate, c.NOW_OR_AT_CON) >= 13: + return ('badge_type', "If you will be 13 or older at the start of {}, " \ + "please select an Attendee badge instead of a 12 and Under badge.".format(c.EVENT_NAME)) + + +@prereg_validation.Attendee +def attendee_badge_under_13(attendee): + if c.CHILD_BADGE in c.PREREG_BADGE_TYPES and attendee.badge_type == c.ATTENDEE_BADGE \ + and get_age_from_birthday(attendee.birthdate, c.NOW_OR_AT_CON) < 13: + return ('badge_type', "If you will be 12 or younger at the start of {}, " \ + "please select the 12 and Under badge instead of an Attendee badge.".format(c.EVENT_NAME)) + + +@prereg_validation.Attendee +def age_discount_after_paid(attendee): + if (attendee.total_cost * 100) < attendee.amount_paid: + if (not attendee.orig_value_of('birthdate') or attendee.orig_value_of('birthdate') < attendee.birthdate) \ + and attendee.age_group_conf['discount'] > 0: + return ('birthdate', 'The date of birth you entered incurs a discount; \ + please email {} to change your badge and receive a refund'.format(c.REGDESK_EMAIL)) + + +@validation.Attendee +def volunteers_cellphone_or_checkbox(attendee): + if not attendee.no_cellphone and attendee.staffing_or_will_be and not attendee.cellphone: + return ('cellphone', "Volunteers and staffers must provide a cellphone number or indicate they do not have a cellphone.") + + +@prereg_validation.Attendee +def promo_code_is_useful(attendee): + if attendee.promo_code: + with Session() as session: + if session.lookup_agent_code(attendee.promo_code.code): + return + code = session.lookup_promo_or_group_code(attendee.promo_code.code, PromoCode) + group = code.group if code and code.group else session.lookup_promo_or_group_code(attendee.promo_code.code, PromoCodeGroup) + if group and group.total_cost == 0: + return + + if attendee.is_new and attendee.promo_code: + if not attendee.is_unpaid: + return ('promo_code', "You can't apply a promo code after you've paid or if you're in a group.") + elif attendee.is_dealer: + return ('promo_code', "You can't apply a promo code to a {}.".format(c.DEALER_REG_TERM)) + elif attendee.age_discount != 0: + return ('promo_code', "You are already receiving an age based discount, you can't use a promo code on top of that.") + elif attendee.badge_type == c.ONE_DAY_BADGE or attendee.is_presold_oneday: + return ('promo_code', "You can't apply a promo code to a one day badge.") + elif attendee.overridden_price: + return ('promo_code', "You already have a special badge price, you can't use a promo code on top of that.") + elif attendee.default_badge_cost >= attendee.badge_cost_without_promo_code: + return ('promo_code', "That promo code doesn't make your badge any cheaper. You may already have other discounts.") + + +@prereg_validation.Attendee +def promo_code_not_is_expired(attendee): + if attendee.is_new and attendee.promo_code and attendee.promo_code.is_expired: + return ('promo_code', 'That promo code is expired.') + + +@validation.Attendee +def promo_code_has_uses_remaining(attendee): + from uber.payments import PreregCart + if attendee.is_new and attendee.promo_code and not attendee.promo_code.is_unlimited: + unpaid_uses_count = PreregCart.get_unpaid_promo_code_uses_count( + attendee.promo_code.id, attendee.id) + if (attendee.promo_code.uses_remaining - unpaid_uses_count) < 0: + return ('promo_code', 'That promo code has been used too many times.') + + +@validation.Attendee +def allowed_to_volunteer(attendee): + if attendee.staffing_or_will_be \ + and not attendee.age_group_conf['can_volunteer'] \ + and attendee.badge_type not in [c.STAFF_BADGE, c.CONTRACTOR_BADGE] \ + and c.PRE_CON: + return ('staffing', 'Your interest is appreciated, but ' + c.EVENT_NAME + ' volunteers must be 18 or older.') + + +@validation.Attendee +def banned_volunteer(attendee): + if attendee.staffing_or_will_be and attendee.full_name in c.BANNED_STAFFERS: + return ('staffing', "We've declined to invite {} back as a volunteer, ".format(attendee.full_name) + ( + 'talk to STOPS to override if necessary' if c.AT_THE_CON else + 'Please contact us via {} if you believe this is in error'.format(c.CONTACT_URL))) + + +@validation.Attendee def agent_code_already_used(attendee): if attendee.promo_code: with Session() as session: @@ -1017,4 +1136,37 @@ def agent_code_already_used(attendee): for app in apps_with_code: if not app.agent_id or app.agent_id == attendee.id: return - return "That agent code has already been used." + return ('promo_code', "That agent code has already been used.") + + +@validation.Attendee +def not_in_range(attendee): + if not attendee.badge_num: + return + + badge_type = get_real_badge_type(attendee.badge_type) + lower_bound, upper_bound = c.BADGE_RANGES[badge_type] + if not (lower_bound <= attendee.badge_num <= upper_bound): + return ('badge_num', 'Badge number {} is out of range for badge type {} ({} - {})'.format(attendee.badge_num, + c.BADGES[attendee.badge_type], + lower_bound, + upper_bound)) + +@validation.Attendee +def dealer_needs_group(attendee): + if attendee.is_dealer and not attendee.badge_type == c.PSEUDO_DEALER_BADGE and not attendee.group_id: + return ('group_id', '{}s must be associated with a group'.format(c.DEALER_TERM)) + + +@validation.Attendee +def group_leadership(attendee): + if attendee.session and not attendee.group_id: + orig_group_id = attendee.orig_value_of('group_id') + if orig_group_id and attendee.id == attendee.session.group(orig_group_id).leader_id: + return ('group_id', 'You cannot remove the leader of a group from that group; make someone else the leader first') + + +@prereg_validation.Group +def edit_only_correct_statuses(group): + if group.status not in c.DEALER_EDITABLE_STATUSES: + return "You cannot change your {} after it has been {}.".format(c.DEALER_APP_TERM, group.status_label) \ No newline at end of file diff --git a/uber/site_sections/group_admin.py b/uber/site_sections/group_admin.py index bdbd79c1e..762d52a64 100644 --- a/uber/site_sections/group_admin.py +++ b/uber/site_sections/group_admin.py @@ -9,7 +9,7 @@ from uber.config import c from uber.decorators import ajax, all_renderable, csrf_protected, log_pageview, site_mappable from uber.errors import HTTPRedirect -from uber.forms import group as group_forms, load_forms +from uber.forms import attendee as attendee_forms, group as group_forms, load_forms from uber.models import Attendee, Email, Event, Group, GuestGroup, GuestMerch, PageViewTracking, Tracking, SignedDocument from uber.utils import check, convert_to_absolute_url, validate_model from uber.payments import ReceiptManager @@ -136,14 +136,13 @@ def form(self, session, new_dealer='', message='', **params): if not message and group.is_new and new_with_leader: session.commit() leader = group.leader = group.attendees[0] - leader.first_name = params.get('first_name') - leader.last_name = params.get('last_name') - leader.email = params.get('email') leader.placeholder = True - message = check(leader) - if message: + forms = load_forms(params, leader, attendee_forms, ['PersonalInfo']) + all_errors = validate_model(forms, leader, Attendee(**leader.to_dict()), is_admin=True) + if all_errors: session.delete(group) session.commit() + message = " ".join(list(zip(*[all_errors]))[1]) if not message: if params.get('guest_group_type'): diff --git a/uber/site_sections/preregistration.py b/uber/site_sections/preregistration.py index 657ee9191..0c798661e 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -614,7 +614,7 @@ def prereg_payment(self, session, message='', **params): all_errors = validate_model(forms, attendee, extra_validators_module=validations.attendee) if all_errors: # Flatten the errors as we don't have fields on this page - message = ' '.join([' '.join(val) for val in all_errors['error']]) + message = " ".join(list(zip(*[all_errors]))[1]) if message: break @@ -1465,7 +1465,7 @@ def validate_dealer(self, session, form_list=[], **params): form_list = [form_list] forms = load_forms(params, group, group_forms, form_list, get_optional=False) - all_errors = validate_model(forms, group, Group(**group.to_dict()), validations.group) + all_errors = validate_model(forms, group, Group(**group.to_dict())) if all_errors: return {"error": all_errors} @@ -1491,7 +1491,7 @@ def validate_attendee(self, session, form_list=[], **params): forms = load_forms(params, attendee, attendee_forms, form_list, get_optional=False) - all_errors = validate_model(forms, attendee, Attendee(**attendee.to_dict()), validations.attendee) + all_errors = validate_model(forms, attendee, Attendee(**attendee.to_dict())) if all_errors: return {"error": all_errors} diff --git a/uber/utils.py b/uber/utils.py index a8dce46b3..29ddfffd5 100644 --- a/uber/utils.py +++ b/uber/utils.py @@ -489,6 +489,8 @@ def check(model, *, prereg=False): for validator in v[model.__class__.__name__].values(): message = validator(model) if message: + if isinstance(message, tuple): + message = message[1] errors.append(message) return "ERROR: " + "
".join(errors) if errors else None @@ -527,9 +529,8 @@ def check_pii_consent(params, attendee=None): return '' -def validate_model(forms, model, preview_model=None, extra_validators_module=None, is_admin=False): +def validate_model(forms, model, preview_model=None, is_admin=False): from wtforms import validators - from wtforms.validators import ValidationError, StopValidation all_errors = defaultdict(list) @@ -546,26 +547,24 @@ def validate_model(forms, model, preview_model=None, extra_validators_module=Non if field: field.validators = [validators.Optional()] - if extra_validators_module: - for key, field in module.field_list: - extra_validators[key].extend(extra_validators_module.form_validation.get_validations_by_field(key)) - if field and (model.is_new or getattr(model, key, None) != field.data): - extra_validators[key].extend(extra_validators_module.new_or_changed_validation.get_validations_by_field(key)) + # TODO: Do we need to check for custom validations or is this code performant enough to skip that? + for key, field in module.field_list: + extra_validators[key].extend(module.field_validation.get_validations_by_field(key)) + if field and (model.is_new or getattr(model, key, None) != field.data): + extra_validators[key].extend(module.new_or_changed_validation.get_validations_by_field(key)) valid = module.validate(extra_validators=extra_validators) if not valid: for key, val in module.errors.items(): all_errors[key].extend(map(str, val)) - if extra_validators_module: - for key, val in extra_validators_module.post_form_validation.get_validation_dict().items(): - for func in val: - try: - func(preview_model) - except (ValidationError, StopValidation) as e: - all_errors[key].append(str(e)) - if isinstance(e, StopValidation): - break + validations = [uber.model_checks.validation.validations] + prereg_validations = [uber.model_checks.prereg_validation.validations] if not is_admin else [] + for v in validations + prereg_validations: + for validator in v[model.__class__.__name__].values(): + error_tuple = validator(preview_model) + if error_tuple: + all_errors[error_tuple[0]] = error_tuple[1] if all_errors: return all_errors diff --git a/uber/validations/__init__.py b/uber/validations/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/uber/validations/attendee.py b/uber/validations/attendee.py deleted file mode 100644 index 5b79ec009..000000000 --- a/uber/validations/attendee.py +++ /dev/null @@ -1,212 +0,0 @@ -import re - -from datetime import date -from pockets import classproperty -from wtforms import validators -from wtforms.validators import ValidationError, StopValidation - -from uber.badge_funcs import get_real_badge_type -from uber.config import c -from uber.custom_tags import format_currency -from uber.models import Attendee, Session, PromoCode, PromoCodeGroup -from uber.model_checks import invalid_zip_code, invalid_phone_number -from uber.utils import get_age_from_birthday, get_age_conf_from_birthday -from uber.decorators import WTFormValidation - -form_validation, new_or_changed_validation, post_form_validation = WTFormValidation(), WTFormValidation(), WTFormValidation() - -""" -These should probably be rewritten as automatic changes with a message attached -@post_form_validation.badge_type -def child_badge_over_13(attendee): - if c.CHILD_BADGE in c.PREREG_BADGE_TYPES and attendee.badge_type == c.CHILD_BADGE \ - and get_age_from_birthday(attendee.birthdate, c.NOW_OR_AT_CON) >= 13: - raise ValidationError("If you will be 13 or older at the start of {}, " \ - "please select an Attendee badge instead of a 12 and Under badge.".format(c.EVENT_NAME)) - -@post_form_validation.badge_type -def attendee_badge_under_13(attendee): - if c.CHILD_BADGE in c.PREREG_BADGE_TYPES and attendee.badge_type == c.ATTENDEE_BADGE \ - and get_age_from_birthday(attendee.birthdate, c.NOW_OR_AT_CON) < 13: - raise ValidationError("If you will be 12 or younger at the start of {}, " \ - "please select the 12 and Under badge instead of an Attendee badge.".format(c.EVENT_NAME)) -""" - -###### Attendee-Facing Validations ###### -def attendee_age_checks(form, field): - age_group_conf = get_age_conf_from_birthday(field.data, c.NOW_OR_AT_CON) \ - if (hasattr(form, "birthdate") and form.birthdate.data) else field.data - if age_group_conf and not age_group_conf['can_register']: - raise ValidationError('Attendees {} years of age do not need to register, ' \ - 'but MUST be accompanied by a parent at all times!'.format(age_group_conf['desc'].lower())) - -@post_form_validation.none -def reasonable_total_cost(attendee): - if attendee.total_cost >= 999999: - raise ValidationError('We cannot charge {}. Please reduce extras so the total is below $9,999.'.format( - format_currency(attendee.total_cost))) - -@post_form_validation.none -def allowed_to_register(attendee): - if not attendee.age_group_conf['can_register']: - raise ValidationError('Attendees {} years of age do not need to register, ' \ - 'but MUST be accompanied by a parent at all times!'.format(attendee.age_group_conf['desc'].lower())) - -@new_or_changed_validation.amount_extra -def upgrade_sold_out(form, field): - currently_available_upgrades = [tier['value'] for tier in c.PREREG_DONATION_DESCRIPTIONS] - if field.data and field.data not in currently_available_upgrades: - raise ValidationError("The upgrade you have selected is sold out.") - -@post_form_validation.badge_type -def child_group_leaders(attendee): - if attendee.badge_type == c.PSEUDO_GROUP_BADGE and get_age_from_birthday(attendee.birthdate, c.NOW_OR_AT_CON) < 13: - raise ValidationError("Children under 13 cannot be group leaders.") -""" -@post_form_validation.badge_type -def no_more_child_badges(attendee): - # TODO: Review business logic here - if c.CHILD_BADGE in c.PREREG_BADGE_TYPES and get_age_from_birthday(attendee.birthdate, c.NOW_OR_AT_CON) < 18 \ - and not c.CHILD_BADGE_AVAILABLE: - raise ValidationError("Unfortunately, we are sold out of badges for attendees under 18.") -""" - -@new_or_changed_validation.badge_type -def no_more_custom_badges(form, field): - if field.data in c.PREASSIGNED_BADGE_TYPES and c.AFTER_PRINTED_BADGE_DEADLINE: - with Session() as session: - admin = session.current_admin_account() - if admin.is_super_admin: - return - raise ValidationError('Custom badges have already been ordered, please choose a different badge type.') - -@new_or_changed_validation.badge_type -def out_of_badge_type(form, field): - badge_type = get_real_badge_type(field.data) - with Session() as session: - try: - session.get_next_badge_num(badge_type) - except AssertionError: - raise ValidationError('We are sold out of {} badges.'.format(c.BADGES[badge_type])) - -@new_or_changed_validation.badge_printed_name -def past_printed_deadline(form, field): - if field.data in c.PREASSIGNED_BADGE_TYPES and c.PRINTED_BADGE_DEADLINE and c.AFTER_PRINTED_BADGE_DEADLINE: - with Session() as session: - admin = session.current_admin_account() - if admin.is_super_admin: - return - raise ValidationError('{} badges have already been ordered, so you cannot change your printed badge name.'.format( - c.BADGES[field.data])) - -@post_form_validation.birthdate -def age_discount_after_paid(attendee): - if (attendee.total_cost * 100) < attendee.amount_paid: - if (not attendee.orig_value_of('birthdate') or attendee.orig_value_of('birthdate') < attendee.birthdate) \ - and attendee.age_group_conf['discount'] > 0: - raise ValidationError('The date of birth you entered incurs a discount; \ - please email {} to change your badge and receive a refund'.format(c.REGDESK_EMAIL)) - -@post_form_validation.cellphone -def volunteers_cellphone_or_checkbox(attendee): - if not attendee.no_cellphone and attendee.staffing_or_will_be and not attendee.cellphone: - raise ValidationError("Volunteers and staffers must provide a cellphone number or indicate they do not have a cellphone.") - - -@post_form_validation.promo_code -def promo_code_is_useful(attendee): - if attendee.promo_code: - with Session() as session: - if session.lookup_agent_code(attendee.promo_code.code): - return - code = session.lookup_promo_or_group_code(attendee.promo_code.code, PromoCode) - group = code.group if code and code.group else session.lookup_promo_or_group_code(attendee.promo_code.code, PromoCodeGroup) - if group and group.total_cost == 0: - return - - if attendee.is_new and attendee.promo_code: - if not attendee.is_unpaid: - raise ValidationError("You can't apply a promo code after you've paid or if you're in a group.") - elif attendee.is_dealer: - raise ValidationError("You can't apply a promo code to a {}.".format(c.DEALER_REG_TERM)) - elif attendee.age_discount != 0: - raise ValidationError("You are already receiving an age based discount, you can't use a promo code on top of that.") - elif attendee.badge_type == c.ONE_DAY_BADGE or attendee.is_presold_oneday: - raise ValidationError("You can't apply a promo code to a one day badge.") - elif attendee.overridden_price: - raise ValidationError("You already have a special badge price, you can't use a promo code on top of that.") - elif attendee.default_badge_cost >= attendee.badge_cost_without_promo_code: - raise ValidationError("That promo code doesn't make your badge any cheaper. You may already have other discounts.") - - -@post_form_validation.promo_code -def promo_code_not_is_expired(attendee): - if attendee.is_new and attendee.promo_code and attendee.promo_code.is_expired: - raise ValidationError('That promo code is expired.') - - -@post_form_validation.promo_code -def promo_code_has_uses_remaining(attendee): - from uber.payments import PreregCart - if attendee.is_new and attendee.promo_code and not attendee.promo_code.is_unlimited: - unpaid_uses_count = PreregCart.get_unpaid_promo_code_uses_count( - attendee.promo_code.id, attendee.id) - if (attendee.promo_code.uses_remaining - unpaid_uses_count) < 0: - raise ValidationError('That promo code has been used too many times.') - - -@post_form_validation.staffing -def allowed_to_volunteer(attendee): - if attendee.staffing_or_will_be \ - and not attendee.age_group_conf['can_volunteer'] \ - and attendee.badge_type not in [c.STAFF_BADGE, c.CONTRACTOR_BADGE] \ - and c.PRE_CON: - raise ValidationError('Your interest is appreciated, but ' + c.EVENT_NAME + ' volunteers must be 18 or older.') - - -@post_form_validation.staffing -def banned_volunteer(attendee): - if attendee.staffing_or_will_be and attendee.full_name in c.BANNED_STAFFERS: - raise ValidationError("We've declined to invite {} back as a volunteer, ".format(attendee.full_name) + ( - 'talk to STOPS to override if necessary' if c.AT_THE_CON else - 'Please contact us via {} if you believe this is in error'.format(c.CONTACT_URL))) - - -###### Admin-Only Validations ###### -@post_form_validation.badge_num -def not_in_range(attendee): - if not attendee.badge_num: - return - - badge_type = get_real_badge_type(attendee.badge_type) - lower_bound, upper_bound = c.BADGE_RANGES[badge_type] - if not (lower_bound <= attendee.badge_num <= upper_bound): - raise ValidationError('Badge number {} is out of range for badge type {} ({} - {})'.format(attendee.badge_num, - c.BADGES[attendee.badge_type], - lower_bound, - upper_bound)) - -@form_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)) - -@post_form_validation.group_id -def dealer_needs_group(attendee): - if attendee.is_dealer and not attendee.badge_type == c.PSEUDO_DEALER_BADGE and not attendee.group_id: - raise ValidationError('{}s must be associated with a group'.format(c.DEALER_TERM)) - -@post_form_validation.group_id -def group_leadership(attendee): - if attendee.session and not attendee.group_id: - orig_group_id = attendee.orig_value_of('group_id') - if orig_group_id and attendee.id == attendee.session.group(orig_group_id).leader_id: - raise ValidationError('You cannot remove the leader of a group from that group; make someone else the leader first') diff --git a/uber/validations/group.py b/uber/validations/group.py deleted file mode 100644 index 5eed80730..000000000 --- a/uber/validations/group.py +++ /dev/null @@ -1,33 +0,0 @@ -import re - -from datetime import date -from pockets import classproperty -from wtforms import validators -from wtforms.validators import ValidationError, StopValidation - -from uber.badge_funcs import get_real_badge_type -from uber.config import c -from uber.custom_tags import format_currency -from uber.models import Attendee, Session -from uber.model_checks import invalid_zip_code, invalid_phone_number -from uber.utils import get_age_from_birthday, get_age_conf_from_birthday -from uber.decorators import WTFormValidation - -form_validation, new_or_changed_validation, post_form_validation = WTFormValidation(), WTFormValidation(), WTFormValidation() - -###### Attendee-Facing Validations ###### -@post_form_validation.none -def edit_only_correct_statuses(group): - if group.status not in c.DEALER_EDITABLE_STATUSES: - return "You cannot change your {} after it has been {}.".format(c.DEALER_APP_TERM, group.status_label) - -###### Admin-Only Validations ###### - -def group_money(form, field): - if not form.auto_recalc.data: - try: - cost = int(float(field.data if field.data else 0)) - if cost < 0: - return 'Total Group Price must be a number that is 0 or higher.' - except Exception: - return "What you entered for Total Group Price ({}) isn't even a number".format(field.data) \ No newline at end of file From a6c9aee46ff476fce6477bcf0baa41982d2ab73e Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Fri, 11 Aug 2023 02:06:24 -0400 Subject: [PATCH 15/16] is_dealer already checks for PSEUDO_DEALER_BADGE --- uber/model_checks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uber/model_checks.py b/uber/model_checks.py index ffefe3cd3..33f4de797 100644 --- a/uber/model_checks.py +++ b/uber/model_checks.py @@ -1154,7 +1154,7 @@ def not_in_range(attendee): @validation.Attendee def dealer_needs_group(attendee): - if attendee.is_dealer and not attendee.badge_type == c.PSEUDO_DEALER_BADGE and not attendee.group_id: + if attendee.is_dealer and not attendee.group_id: return ('group_id', '{}s must be associated with a group'.format(c.DEALER_TERM)) From 051c2949913eeedec9fc60efe1c913e05b7c65b6 Mon Sep 17 00:00:00 2001 From: Victoria Earl Date: Mon, 14 Aug 2023 19:10:38 -0400 Subject: [PATCH 16/16] Move attendee and group forms into folders --- uber/templates/forms/{ => attendee}/badge_extras.html | 0 uber/templates/forms/{ => attendee}/consents.html | 0 uber/templates/forms/{ => attendee}/contact_info.html | 0 uber/templates/forms/{ => attendee}/other_info.html | 0 uber/templates/forms/{ => attendee}/personal_info.html | 0 uber/templates/forms/{ => group}/group_info.html | 0 uber/templates/forms/{ => group}/table_info.html | 0 uber/templates/forms/preregistration.html | 0 uber/templates/group_admin/form.html | 6 +++--- uber/templates/preregistration/additional_info.html | 2 +- uber/templates/preregistration/confirm.html | 8 ++++---- .../templates/preregistration/dealer_registration.html | 4 ++-- uber/templates/preregistration/form.html | 10 +++++----- uber/templates/preregistration/group_members.html | 6 +++--- .../preregistration/register_group_member.html | 8 ++++---- uber/templates/preregistration/upgrade_modal.html | 2 +- 16 files changed, 23 insertions(+), 23 deletions(-) rename uber/templates/forms/{ => attendee}/badge_extras.html (100%) rename uber/templates/forms/{ => attendee}/consents.html (100%) rename uber/templates/forms/{ => attendee}/contact_info.html (100%) rename uber/templates/forms/{ => attendee}/other_info.html (100%) rename uber/templates/forms/{ => attendee}/personal_info.html (100%) rename uber/templates/forms/{ => group}/group_info.html (100%) rename uber/templates/forms/{ => group}/table_info.html (100%) delete mode 100644 uber/templates/forms/preregistration.html diff --git a/uber/templates/forms/badge_extras.html b/uber/templates/forms/attendee/badge_extras.html similarity index 100% rename from uber/templates/forms/badge_extras.html rename to uber/templates/forms/attendee/badge_extras.html diff --git a/uber/templates/forms/consents.html b/uber/templates/forms/attendee/consents.html similarity index 100% rename from uber/templates/forms/consents.html rename to uber/templates/forms/attendee/consents.html diff --git a/uber/templates/forms/contact_info.html b/uber/templates/forms/attendee/contact_info.html similarity index 100% rename from uber/templates/forms/contact_info.html rename to uber/templates/forms/attendee/contact_info.html diff --git a/uber/templates/forms/other_info.html b/uber/templates/forms/attendee/other_info.html similarity index 100% rename from uber/templates/forms/other_info.html rename to uber/templates/forms/attendee/other_info.html diff --git a/uber/templates/forms/personal_info.html b/uber/templates/forms/attendee/personal_info.html similarity index 100% rename from uber/templates/forms/personal_info.html rename to uber/templates/forms/attendee/personal_info.html diff --git a/uber/templates/forms/group_info.html b/uber/templates/forms/group/group_info.html similarity index 100% rename from uber/templates/forms/group_info.html rename to uber/templates/forms/group/group_info.html diff --git a/uber/templates/forms/table_info.html b/uber/templates/forms/group/table_info.html similarity index 100% rename from uber/templates/forms/table_info.html rename to uber/templates/forms/group/table_info.html diff --git a/uber/templates/forms/preregistration.html b/uber/templates/forms/preregistration.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/uber/templates/group_admin/form.html b/uber/templates/group_admin/form.html index 0c3bf4bf2..e43c9d3a1 100644 --- a/uber/templates/group_admin/form.html +++ b/uber/templates/group_admin/form.html @@ -55,13 +55,13 @@

Group Info

{% if forms and 'group_info' in forms %} - {% include "forms/group_info.html" %} + {% include "forms/group/group_info.html" %} {% elif forms and 'table_info' in forms %} - {% include "forms/table_info.html" %} + {% include "forms/group/table_info.html" %}
Contact Info
- {% include "forms/contact_info.html" %} + {% include "forms/attendee/contact_info.html" %} {% endif %} {# old fields, kept for reference as we migrate to new fields diff --git a/uber/templates/preregistration/additional_info.html b/uber/templates/preregistration/additional_info.html index bcf93a8a2..fdbffbd0f 100644 --- a/uber/templates/preregistration/additional_info.html +++ b/uber/templates/preregistration/additional_info.html @@ -30,7 +30,7 @@ {% endif %} {{ csrf_token() }} - {% include "forms/other_info.html" %} + {% include "forms/attendee/other_info.html" %}   {% if editing %} diff --git a/uber/templates/preregistration/confirm.html b/uber/templates/preregistration/confirm.html index 0436a3340..06a22842a 100644 --- a/uber/templates/preregistration/confirm.html +++ b/uber/templates/preregistration/confirm.html @@ -108,10 +108,10 @@ - {% include "forms/badge_extras.html" %} - {% include "forms/personal_info.html" %} - {% include "forms/other_info.html" %} - {% include "forms/consents.html" %} + {% include "forms/attendee/badge_extras.html" %} + {% include "forms/attendee/personal_info.html" %} + {% include "forms/attendee/other_info.html" %} + {% include "forms/attendee/consents.html" %} {# Deprecated form included for backwards compatibility with old plugins #} {% include "regform.html" %} diff --git a/uber/templates/preregistration/dealer_registration.html b/uber/templates/preregistration/dealer_registration.html index 4660958ea..91eeb966b 100644 --- a/uber/templates/preregistration/dealer_registration.html +++ b/uber/templates/preregistration/dealer_registration.html @@ -20,11 +20,11 @@

{{ c.DEALER_APP_TERM|title }} Info

- {% include "forms/table_info.html" %} + {% include "forms/group/table_info.html" %}

{{ c.DEALER_TERM|title }} Contact Info

{% include "forms/dealer_contact_intro.html" %} - {% include "forms/contact_info.html" %} + {% include "forms/attendee/contact_info.html" %} {# Deprecated forms included for backwards compatibility with old plugins #} {% include "groupform.html" %} diff --git a/uber/templates/preregistration/form.html b/uber/templates/preregistration/form.html index b0dfae9ec..850b93f5d 100644 --- a/uber/templates/preregistration/form.html +++ b/uber/templates/preregistration/form.html @@ -28,18 +28,18 @@ {{ csrf_token() }} {% if forms and 'group_info' in forms %} - {% include "forms/group_info.html" %} + {% include "forms/group/group_info.html" %} {% elif attendee.badge_type == c.PSEUDO_DEALER_BADGE %}

{{ c.DEALER_TERM|title }} Personal Info

{{ forms['badge_extras'].badge_type }} {% else %} - {% include "forms/badge_extras.html" %} + {% include "forms/attendee/badge_extras.html" %} {% endif %} - {% include "forms/personal_info.html" %} - {% include "forms/consents.html" %} + {% include "forms/attendee/personal_info.html" %} + {% include "forms/attendee/consents.html" %} {# old fields, kept for reference as we migrate to new fields - {% include "forms/other_info.html" %} + {% include "forms/attendee/other_info.html" %} {{ group_fields.group_name }} {{ group_fields.badges_dropdown }} {% if is_prereg_dealer %} diff --git a/uber/templates/preregistration/group_members.html b/uber/templates/preregistration/group_members.html index 03a2fff52..ef2f3e0e4 100644 --- a/uber/templates/preregistration/group_members.html +++ b/uber/templates/preregistration/group_members.html @@ -68,10 +68,10 @@

"{{ group.name }}" Information

{% if forms and 'group_info' in forms %} - {% include "forms/group_info.html" %} + {% include "forms/group/group_info.html" %} {% elif forms and 'table_info' in forms %} - {% include "forms/table_info.html" %} - {% include "forms/contact_info.html" %} + {% include "forms/group/table_info.html" %} + {% include "forms/attendee/contact_info.html" %} {% endif %} {% include "groupextra.html" %} {% if not page_ro %} diff --git a/uber/templates/preregistration/register_group_member.html b/uber/templates/preregistration/register_group_member.html index 6f0e8152a..d66d1a020 100644 --- a/uber/templates/preregistration/register_group_member.html +++ b/uber/templates/preregistration/register_group_member.html @@ -12,10 +12,10 @@ - {% include "forms/badge_extras.html" %} - {% include "forms/personal_info.html" %} - {% include "forms/other_info.html" %} - {% include "forms/consents.html" %} + {% include "forms/attendee/badge_extras.html" %} + {% include "forms/attendee/personal_info.html" %} + {% include "forms/attendee/other_info.html" %} + {% include "forms/attendee/consents.html" %} {# Deprecated form included for backwards compatibility with old plugins #} {% include "regform.html" %} diff --git a/uber/templates/preregistration/upgrade_modal.html b/uber/templates/preregistration/upgrade_modal.html index 7af6befd1..92d1595d3 100644 --- a/uber/templates/preregistration/upgrade_modal.html +++ b/uber/templates/preregistration/upgrade_modal.html @@ -13,7 +13,7 @@ {% set upgrade_modal = true %} - {% include "forms/badge_extras.html" %} + {% include "forms/attendee/badge_extras.html" %} {% set upgrade_modal = false %}