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/config.py b/uber/config.py index b18b9352c..222b69e19 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 @@ -1000,6 +1001,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 d4456c0ff..6c33c5da3 100644 --- a/uber/configspec.ini +++ b/uber/configspec.ini @@ -1850,6 +1850,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/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 new file mode 100644 index 000000000..37a228542 --- /dev/null +++ b/uber/forms/README.md @@ -0,0 +1,122 @@ +# 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, 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. + - 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" 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 + + +## 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. + +To declare a new form, [TODO]. To add fields to an existing form, [TODO]. + +https://wtforms.readthedocs.io/en/3.0.x/fields/ + +### Field Labels and Descriptions +By default, labels and descriptions for fields are simple strings with automatic escaping for HTML/XML. Since this is not always desirable, here are a few ways to write more complex labels: + +- To include basic HTML (e.g., bolding or italicizing text), wrap the string in a Markup() object from the **markupsafe** library, e.g., `field_name = StringField(Markup('Bold text'))` +- For complex display logic (e.g., building a label using multiple 'if' statements) add a function onto your form class named `field_name_label` or `field_name_desc`, e.g.: + ``` + def pii_consent_label(self): + label = '' + # add complex display logic that modifies 'label' + return label + ``` + + +### Field Types +Below is a map of what column types exist in Ubersystem models and what fields you might want to (or ought to) use when declaring the corresponding form fields. +| Column Type | Suggested Field Type(s) | +| --- | --- | +| UnicodeText | StringField, TextAreaField, EmailField, TelField, PasswordField, URLField | +| Integer | IntegerField | +| Date | DateField | +| Choice | SelectField, RadioField | +| MultiChoice | MultiSelectField | +| Boolean | BooleanField | +| UTCDateTime | DateTimeField, DateTimeLocalField | +| UUID | [TODO] | +| MutableDict | [TODO] | + + +## Editing and Overriding Fields + +### Blocks + +### Change Field Name + +### Change Field Help Text + +### Troubleshooting/Dev Notes +Deleting or adding template files requires a restart of the server. + +It is not currently possible to layer two plugins' block override. In other words, if you have a {% block consents %} in other_info.html in one plugin, and another {% block consents %} in other_info.html in another plugin, the last plugin loaded will override the first plugin's consents block. This is considered an edge case and fixing it is not currently a priority. + +There are some weird behaviors if you apply the Markup() class to a description with a popup link inside it. If you're encountering this, try to apply Markup() to the rest of the text, then append the popup link -- that should work. \ No newline at end of file diff --git a/uber/forms/__init__.py b/uber/forms/__init__.py index 31cfbf591..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,4 +368,4 @@ def getlist(self, arg): from uber.forms.attendee import * # noqa: F401,E402,F403 -from uber.forms.group import * # noqa: F401,E402,F403 \ No newline at end of file +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 f358e641e..b4c5d9892 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 Requests', 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 dc1241c14..bf94ff290 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 @@ -1019,8 +1020,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: @@ -1029,4 +1148,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.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/models/__init__.py b/uber/models/__init__.py index de3c06bff..0f9748b03 100644 --- a/uber/models/__init__.py +++ b/uber/models/__init__.py @@ -1039,7 +1039,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 @@ -1049,10 +1049,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/models/attendee.py b/uber/models/attendee.py index 6a671402e..7b3e3ba28 100644 --- a/uber/models/attendee.py +++ b/uber/models/attendee.py @@ -511,7 +511,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: diff --git a/uber/site_sections/accounts.py b/uber/site_sections/accounts.py index d499991bc..162debb8d 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='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,15 +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) - - raise HTTPRedirect(auth.login(return_to=c.URL_ROOT + original_location)) - return { 'message': message, 'email': params.get('email', ''), diff --git a/uber/site_sections/group_admin.py b/uber/site_sections/group_admin.py index 5ba3672bb..0953b6301 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 @@ -137,14 +137,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/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/preregistration.py b/uber/site_sections/preregistration.py index b768cbba4..a0f06ed60 100644 --- a/uber/site_sections/preregistration.py +++ b/uber/site_sections/preregistration.py @@ -626,8 +626,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, extra_validators_module=validations.attendee) + if all_errors: + # Flatten the errors as we don't have fields on this page + message = " ".join(list(zip(*[all_errors]))[1]) + if message: + break if not message: receipts = [] @@ -1476,7 +1484,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} @@ -1502,7 +1510,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/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/site_sections/saml.py b/uber/site_sections/saml.py index ec70fb169..5eb3ece38 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,12 +122,10 @@ def acs(self, session, **params): redirect_url = None if not redirect_url: - if not admin_account: - 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/tasks/__init__.py b/uber/tasks/__init__.py index f23966620..ba00cf776 100644 --- a/uber/tasks/__init__.py +++ b/uber/tasks/__init__.py @@ -68,8 +68,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 08b552854..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')) @@ -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/art_show_applications/confirmation.html b/uber/templates/art_show_applications/confirmation.html index d21ab7b85..3caf20c37 100644 --- a/uber/templates/art_show_applications/confirmation.html +++ b/uber/templates/art_show_applications/confirmation.html @@ -2,9 +2,7 @@ {% block title %}Art Show Application Received{% endblock %} {% block backlink %}{% endblock %} {% block content %} -