diff --git a/app/api/helpers/validations.py b/app/api/helpers/validations.py new file mode 100644 index 0000000000..1648ca5b49 --- /dev/null +++ b/app/api/helpers/validations.py @@ -0,0 +1,16 @@ +from app import get_settings +from app.api.helpers.exceptions import UnprocessableEntity + + +def validate_complex_fields_json(self, data, original_data): + if data.get('complex_field_values'): + if any(((not isinstance(i, (str, bool, int, float))) and i is not None) + for i in data['complex_field_values'].values()): + raise UnprocessableEntity({'pointer': '/data/attributes/complex_field_values'}, + "Only flattened JSON of form {key: value} where value is a string, " + "integer, float, bool or null is permitted for this field") + + if len(data['complex_field_values']) > get_settings()['max_complex_custom_fields']: + raise UnprocessableEntity({'pointer': '/data/attributes/complex_field_values'}, + "A maximum of {} complex custom form fields are currently supported" + .format(get_settings()['max_complex_custom_fields'])) diff --git a/app/api/schema/attendees.py b/app/api/schema/attendees.py index 43ace66ff7..5c1ba3db63 100644 --- a/app/api/schema/attendees.py +++ b/app/api/schema/attendees.py @@ -3,6 +3,8 @@ from app.api.helpers.utilities import dasherize from app.api.schema.base import SoftDeletionSchema +from app.api.helpers.validations import validate_complex_fields_json +from marshmallow import validates_schema class AttendeeSchemaPublic(SoftDeletionSchema): @@ -19,6 +21,10 @@ class Meta: self_view_kwargs = {'id': ''} inflect = dasherize + @validates_schema(pass_original=True) + def validate_json(self, data, original_data): + validate_complex_fields_json(self, data, original_data) + id = fields.Str(dump_only=True) firstname = fields.Str(required=True) lastname = fields.Str(required=True) @@ -52,6 +58,7 @@ class Meta: attendee_notes = fields.Str(allow_none=True) is_checked_out = fields.Boolean() pdf_url = fields.Url(dump_only=True) + complex_field_values = fields.Dict(allow_none=True) event = Relationship(attribute='event', self_view='v1.attendee_event', self_view_kwargs={'id': ''}, diff --git a/app/api/schema/sessions.py b/app/api/schema/sessions.py index 3d35bb0506..4f803b291c 100644 --- a/app/api/schema/sessions.py +++ b/app/api/schema/sessions.py @@ -11,6 +11,7 @@ from app.api.schema.base import SoftDeletionSchema from app.models.session import Session from utils.common import use_defaults +from app.api.helpers.validations import validate_complex_fields_json @use_defaults() @@ -29,7 +30,7 @@ class Meta: inflect = dasherize @validates_schema(pass_original=True) - def validate_date(self, data, original_data): + def validate_fields(self, data, original_data): if 'id' in original_data['data']: try: session = Session.query.filter_by(id=original_data['data']['id']).one() @@ -66,6 +67,8 @@ def validate_date(self, data, original_data): if not has_access('is_coorganizer', event_id=data['event']): return ForbiddenException({'source': ''}, 'Co-organizer access is required.') + validate_complex_fields_json(self, data, original_data) + id = fields.Str(dump_only=True) title = fields.Str(required=True) subtitle = fields.Str(allow_none=True) @@ -90,6 +93,7 @@ def validate_date(self, data, original_data): last_modified_at = fields.DateTime(dump_only=True) send_email = fields.Boolean(load_only=True, allow_none=True) average_rating = fields.Float(dump_only=True) + complex_field_values = fields.Dict(allow_none=True) microlocation = Relationship(attribute='microlocation', self_view='v1.session_microlocation', self_view_kwargs={'id': ''}, diff --git a/app/api/schema/settings.py b/app/api/schema/settings.py index 2c0cd3460b..20b5fc226f 100644 --- a/app/api/schema/settings.py +++ b/app/api/schema/settings.py @@ -30,6 +30,9 @@ class Meta: # Order Expiry Time order_expiry_time = fields.Integer(allow_none=False, default=15, validate=lambda n: 1 <= n <= 60) + # Maximum number of complex custom fields allowed for a given form + max_complex_custom_fields = fields.Integer(allow_none=False, default=30, validate=lambda n: 1 <= n <= 30) + # Google Analytics analytics_key = fields.Str(allow_none=True) @@ -156,7 +159,7 @@ class Meta: in_client_secret = fields.Str(allow_none=True) # - # Payment Gateway + # Payment Gateways # # Stripe Credantials diff --git a/app/api/schema/speakers.py b/app/api/schema/speakers.py index 2bd0c05fde..fee488735b 100644 --- a/app/api/schema/speakers.py +++ b/app/api/schema/speakers.py @@ -1,9 +1,10 @@ from marshmallow_jsonapi import fields from marshmallow_jsonapi.flask import Relationship - +from marshmallow import validates_schema from app.api.helpers.utilities import dasherize from app.api.schema.base import SoftDeletionSchema from utils.common import use_defaults +from app.api.helpers.validations import validate_complex_fields_json @use_defaults() @@ -12,6 +13,10 @@ class SpeakerSchema(SoftDeletionSchema): Speaker Schema based on Speaker Model """ + @validates_schema(pass_original=True) + def validate_json(self, data, original_data): + validate_complex_fields_json(self, data, original_data) + class Meta: """ Meta class for speaker schema @@ -46,6 +51,7 @@ class Meta: gender = fields.Str(allow_none=True) heard_from = fields.Str(allow_none=True) sponsorship_required = fields.Str(allow_none=True) + complex_field_values = fields.Dict(allow_none=True) event = Relationship(attribute='event', self_view='v1.speaker_event', self_view_kwargs={'id': ''}, diff --git a/app/models/session.py b/app/models/session.py index 6134ccd1ca..723f156ec9 100644 --- a/app/models/session.py +++ b/app/models/session.py @@ -54,6 +54,7 @@ class Session(SoftDeletionModel): last_modified_at = db.Column(db.DateTime(timezone=True), default=datetime.datetime.utcnow) send_email = db.Column(db.Boolean, nullable=True) is_locked = db.Column(db.Boolean, default=False, nullable=False) + complex_field_values = db.Column(db.JSON) def __init__(self, title=None, @@ -83,7 +84,9 @@ def __init__(self, submitted_at=None, last_modified_at=None, send_email=None, - is_locked=False): + is_locked=False, + complex_field_values=None + ): if speakers is None: speakers = [] @@ -116,6 +119,7 @@ def __init__(self, self.last_modified_at = datetime.datetime.now(pytz.utc) self.send_email = send_email self.is_locked = is_locked + self.complex_field_values = complex_field_values @staticmethod def get_service_name(): diff --git a/app/models/setting.py b/app/models/setting.py index ac2a587018..a99b2420a1 100644 --- a/app/models/setting.py +++ b/app/models/setting.py @@ -33,6 +33,9 @@ class Setting(db.Model): # Order Expiry Time in Minutes order_expiry_time = db.Column(db.Integer, default=15, nullable=False) + # Maximum number of complex custom fields allowed for a given form + max_complex_custom_fields = db.Column(db.Integer, default=30, nullable=False) + # # STORAGE # @@ -250,7 +253,9 @@ def __init__(self, admin_billing_state=None, admin_billing_zip=None, admin_billing_additional_info=None, - order_expiry_time=None): + order_expiry_time=None, + max_complex_custom_fields=30 + ): self.app_environment = app_environment self.aws_key = aws_key self.aws_secret = aws_secret @@ -313,7 +318,6 @@ def __init__(self, self.paypal_sandbox_client = paypal_sandbox_client self.paypal_sandbox_secret = paypal_sandbox_secret - # Omise Credentials self.omise_mode = omise_mode self.omise_test_public = omise_test_public @@ -352,6 +356,8 @@ def __init__(self, # Order Expiry Time in Minutes self.order_expiry_time = order_expiry_time + self.max_complex_custom_fields = max_complex_custom_fields + @hybrid_property def is_paypal_activated(self): if self.paypal_mode == 'sandbox' and self.paypal_sandbox_client and self.paypal_sandbox_secret: diff --git a/app/models/speaker.py b/app/models/speaker.py index 1bba4bd138..09590fda38 100644 --- a/app/models/speaker.py +++ b/app/models/speaker.py @@ -31,6 +31,7 @@ class Speaker(SoftDeletionModel): gender = db.Column(db.String) heard_from = db.Column(db.String) sponsorship_required = db.Column(db.Text) + complex_field_values = db.Column(db.JSON) event_id = db.Column(db.Integer, db.ForeignKey('events.id', ondelete='CASCADE')) user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL')) @@ -61,7 +62,8 @@ def __init__(self, sponsorship_required=None, event_id=None, user_id=None, - deleted_at=None): + deleted_at=None, + complex_field_values=None): self.name = name self.photo_url = photo_url self.thumbnail_image_url = thumbnail_image_url @@ -88,7 +90,8 @@ def __init__(self, self.sponsorship_required = sponsorship_required self.event_id = event_id self.user_id = user_id - self.deleted_at = deleted_at + self.deleted_at = deleted_at, + self.complex_field_values = complex_field_values @staticmethod def get_service_name(): diff --git a/app/models/ticket_holder.py b/app/models/ticket_holder.py index 9f1576248e..54c4fec48d 100644 --- a/app/models/ticket_holder.py +++ b/app/models/ticket_holder.py @@ -46,6 +46,7 @@ class TicketHolder(SoftDeletionModel): checkout_times = db.Column(db.String) attendee_notes = db.Column(db.String) event_id = db.Column(db.Integer, db.ForeignKey('events.id', ondelete='CASCADE')) + complex_field_values = db.Column(db.JSON) user = db.relationship('User', foreign_keys=[email], primaryjoin='User.email == TicketHolder.email', viewonly=True, backref='attendees') @@ -83,7 +84,8 @@ def __init__(self, order_id=None, pdf_url=None, event_id=None, - deleted_at=None): + deleted_at=None, + complex_field_values=None): self.firstname = firstname self.lastname = lastname self.email = email @@ -118,6 +120,7 @@ def __init__(self, self.pdf_url = pdf_url self.event_id = event_id self.deleted_at = deleted_at + self.complex_field_values = complex_field_values def __repr__(self): return '' % self.id diff --git a/migrations/versions/rev-2019-09-02-09:34:18-7c32ba647a18_.py b/migrations/versions/rev-2019-09-02-09:34:18-7c32ba647a18_.py new file mode 100644 index 0000000000..4df1725010 --- /dev/null +++ b/migrations/versions/rev-2019-09-02-09:34:18-7c32ba647a18_.py @@ -0,0 +1,38 @@ +"""empty message + +Revision ID: 7c32ba647a18 +Revises: ebfe89366d48 +Create Date: 2019-09-02 09:34:18.949897 + +""" + +from alembic import op +import sqlalchemy as sa +import sqlalchemy_utils + + +# revision identifiers, used by Alembic. +revision = '7c32ba647a18' +down_revision = 'ebfe89366d48' + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('sessions', sa.Column('complex_field_values', sa.JSON(), nullable=True)) + op.add_column('sessions_version', sa.Column('complex_field_values', sa.JSON(), autoincrement=False, nullable=True)) + op.add_column('speaker', sa.Column('complex_field_values', sa.JSON(), nullable=True)) + op.add_column('ticket_holders', sa.Column('complex_field_values', sa.JSON(), nullable=True)) + op.add_column('settings', sa.Column('max_complex_custom_fields', sa.Integer(), server_default='30', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('ticket_holders', 'complex_field_values') + op.drop_column('speaker', 'complex_field_values') + op.drop_column('sessions', 'complex_field_values') + op.drop_column('sessions_version', 'complex_field_values') + op.drop_column('settings', 'max_complex_custom_fields') + + + # ### end Alembic commands ### diff --git a/tests/all/integration/api/validation/test_sessions.py b/tests/all/integration/api/validation/test_sessions.py index e9daacb5fa..e779f37f3f 100644 --- a/tests/all/integration/api/validation/test_sessions.py +++ b/tests/all/integration/api/validation/test_sessions.py @@ -29,7 +29,7 @@ def test_date_pass(self): 'starts_at': datetime(2099, 8, 4, 12, 30, 45).replace(tzinfo=timezone('UTC')), 'ends_at': datetime(2099, 9, 4, 12, 30, 45).replace(tzinfo=timezone('UTC')) } - SessionSchema.validate_date(schema, data, original_data) + SessionSchema.validate_fields(schema, data, original_data) def test_date_start_gt_end(self): """ @@ -45,7 +45,7 @@ def test_date_start_gt_end(self): 'ends_at': datetime(2099, 8, 4, 12, 30, 45).replace(tzinfo=timezone('UTC')) } with self.assertRaises(UnprocessableEntity): - SessionSchema.validate_date(schema, data, original_data) + SessionSchema.validate_fields(schema, data, original_data) def test_date_db_populate(self): """ @@ -63,7 +63,7 @@ def test_date_db_populate(self): } } data = {} - SessionSchema.validate_date(schema, data, original_data) + SessionSchema.validate_fields(schema, data, original_data) if __name__ == '__main__':