diff --git a/HISTORY.rst b/HISTORY.rst index cbfd100..93b6f4d 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,5 +1,9 @@ Changelog --------- + +- Adds the ability to define registration windows for forms. + [href] + 0.32.0 (2018-02-21) ~~~~~~~~~~~~~~~~~~~ diff --git a/onegov/form/__init__.py b/onegov/form/__init__.py index f5e7811..1a2fd60 100644 --- a/onegov/form/__init__.py +++ b/onegov/form/__init__.py @@ -23,6 +23,7 @@ FormDefinition, FormFile, FormSubmission, + FormRegistrationWindow, PendingFormSubmission, CompleteFormSubmission ) @@ -48,6 +49,7 @@ 'FormDefinitionCollection', 'FormExtension', 'FormFile', + 'FormRegistrationWindow', 'FormSubmission', 'FormSubmissionCollection', 'merge_forms', diff --git a/onegov/form/collection.py b/onegov/form/collection.py index 9d45f64..636085f 100644 --- a/onegov/form/collection.py +++ b/onegov/form/collection.py @@ -3,12 +3,14 @@ from datetime import datetime, timedelta from onegov.core.crypto import random_token from onegov.core.utils import normalize_for_url +from onegov.core.collection import GenericCollection from onegov.file.utils import as_fileintent from onegov.form.errors import UnableToComplete from onegov.form.fields import UploadField from onegov.form.models import ( FormDefinition, FormSubmission, + FormRegistrationWindow, FormFile ) from sedate import replace_timezone, utcnow @@ -30,6 +32,10 @@ def definitions(self): def submissions(self): return FormSubmissionCollection(self.session) + @property + def registration_windows(self): + return FormRegistrationWindowCollection(self.session) + def scoped_submissions(self, name, ensure_existance=True): if not ensure_existance or self.definitions.by_name(name): return FormSubmissionCollection(self.session, name) @@ -134,7 +140,7 @@ def query(self): return query def add(self, name, form, state, id=None, payment_method=None, - meta=None, email=None): + meta=None, email=None, spots=None): """ Takes a filled-out form instance and stores the submission in the database. The form instance is expected to have a ``_source`` parameter, which contains the source used to build the form (as only @@ -161,6 +167,14 @@ def add(self, name, form, state, id=None, payment_method=None, self.session.query(FormDefinition) .filter_by(name=name).one()) + if definition is None: + registration_window = None + else: + registration_window = definition.current_registration_window + + if registration_window: + assert registration_window.accepts_submissions + # look up the right class depending on the type submission_class = FormSubmission.get_polymorphic_class( state, FormSubmission @@ -172,6 +186,8 @@ def add(self, name, form, state, id=None, payment_method=None, submission.state = state submission.meta = meta or {} submission.email = email + submission.registration_window = registration_window + submission.spots = spots submission.payment_method = ( payment_method or definition and definition.payment_method or @@ -371,3 +387,22 @@ def delete(self, submission): """ Deletes the given submission and all the files belonging to it. """ self.session.delete(submission) self.session.flush() + + +class FormRegistrationWindowCollection(GenericCollection): + + def __init__(self, session, name=None): + super().__init__(session) + self.name = name + + @property + def model_class(self): + return FormRegistrationWindow + + def query(self): + query = super().query() + + if self.name: + query = query.filter_by(FormRegistrationWindow.name == self.name) + + return query diff --git a/onegov/form/models/__init__.py b/onegov/form/models/__init__.py index d3b56c3..864e2d9 100644 --- a/onegov/form/models/__init__.py +++ b/onegov/form/models/__init__.py @@ -3,9 +3,11 @@ from onegov.form.models.submission import PendingFormSubmission from onegov.form.models.submission import CompleteFormSubmission from onegov.form.models.submission import FormFile +from onegov.form.models.registration_window import FormRegistrationWindow __all__ = ( 'FormDefinition', + 'FormRegistrationWindow', 'FormSubmission', 'PendingFormSubmission', 'CompleteFormSubmission', diff --git a/onegov/form/models/definition.py b/onegov/form/models/definition.py index 8a32aae..9887b3e 100644 --- a/onegov/form/models/definition.py +++ b/onegov/form/models/definition.py @@ -3,6 +3,7 @@ from onegov.core.orm.mixins import meta_property, content_property from onegov.core.utils import normalize_for_url from onegov.form.models.submission import FormSubmission +from onegov.form.models.registration_window import FormRegistrationWindow from onegov.form.parser import parse_form from onegov.form.utils import hash_definition from onegov.form.extensions import Extendable @@ -62,6 +63,48 @@ class FormDefinition(Base, ContentMixin, TimestampMixin, SearchableDefinition, #: link between forms and submissions submissions = relationship('FormSubmission', backref='form') + #: link between forms and registration windows + registration_windows = relationship( + 'FormRegistrationWindow', + backref='form', + order_by='FormRegistrationWindow.start') + + #: the currently active registration window + #: + #: this sorts the registration windows by the smaller k-nearest neighbour + #: result of both start and end in relation to the current date + #: + #: the result is the *nearest* date range in relation to today: + #: * during an active registration window, it's that active window + #: * outside of active windows, it's last window half way until + #: the next window starts + #: + #: this could of course be done more conventionally, but this is cooler 😅 + current_registration_window = relationship( + 'FormRegistrationWindow', viewonly=True, uselist=False, + primaryjoin="""and_( + FormRegistrationWindow.name == FormDefinition.name, + FormRegistrationWindow.id == select(( + FormRegistrationWindow.id, + )).where( + FormRegistrationWindow.name == FormDefinition.name + ).order_by( + func.least( + cast( + func.now().op('AT TIME ZONE')( + FormRegistrationWindow.timezone + ), Date + ).op('<->')(FormRegistrationWindow.start), + cast( + func.now().op('AT TIME ZONE')( + FormRegistrationWindow.timezone + ), Date + ).op('<->')(FormRegistrationWindow.end) + ) + ).limit(1) + )""" + ) + #: lead text describing the form lead = meta_property() @@ -99,3 +142,14 @@ def has_submissions(self, with_state=None): query = query.filter(FormSubmission.state == with_state) return query.first() and True or False + + def add_registration_window(self, start, end, **options): + window = FormRegistrationWindow( + start=start, + end=end, + **options + ) + + self.registration_windows.append(window) + + return window diff --git a/onegov/form/models/registration_window.py b/onegov/form/models/registration_window.py new file mode 100644 index 0000000..2c3cb03 --- /dev/null +++ b/onegov/form/models/registration_window.py @@ -0,0 +1,151 @@ +import sedate + +from onegov.core.orm import Base +from onegov.core.orm.mixins import TimestampMixin +from onegov.core.orm.types import UUID +from sqlalchemy import Boolean, Column, Date, ForeignKey, Integer, Text, text +from sqlalchemy.dialects.postgresql import ExcludeConstraint +from sqlalchemy.schema import CheckConstraint +from sqlalchemy.sql.elements import quoted_name +from sqlalchemy.orm import object_session, relationship +from uuid import uuid4 + + +daterange = Column(quoted_name('DATERANGE("start", "end")', quote=False)) + + +class FormRegistrationWindow(Base, TimestampMixin): + """ Defines a registration window during which a form definition + may be used to create submissions. + + Submissions created thusly are attached to the currently active + registration window. + + Registration windows may not overlap. + + """ + + __tablename__ = 'registration_windows' + + #: the public id of the registraiton window + id = Column(UUID, primary_key=True, default=uuid4) + + #: the name of the form to which this registration window belongs + name = Column(Text, ForeignKey("forms.name"), nullable=False) + + #: true if the registration window is enabled + enabled = Column(Boolean, nullable=False, default=True) + + #: the start date of the window + start = Column(Date, nullable=False) + + #: the end date of the window + end = Column(Date, nullable=False) + + #: the timezone of the window + timezone = Column(Text, nullable=False, default='Europe/Zurich') + + #: the number of spots (None => unlimited) + limit = Column(Integer, nullable=True) + + #: enable an overflow of submissions + overflow = Column(Boolean, nullable=False, default=True) + + #: submissions linked to this + submissions = relationship('FormSubmission', backref='registration_window') + + __table_args__ = ( + + # ensures that there are no overlapping date ranges within one form + ExcludeConstraint( + (name, '='), (daterange, '&&'), + name='no_overlapping_registration_windows', + using='gist' + ), + + # ensures that there are no adjacent date ranges + # (end on the same day as next start) + ExcludeConstraint( + (name, '='), (daterange, '-|-'), + name='no_adjacent_registration_windows', + using='gist' + ), + + # ensures that start <= end + CheckConstraint( + '"start" <= "end"', + name='start_smaller_than_end' + ), + ) + + @property + def localized_start(self): + return sedate.align_date_to_day( + sedate.standardize_date( + sedate.as_datetime(self.start), self.timezone + ), self.timezone, 'down' + ) + + @property + def localized_end(self): + return sedate.align_date_to_day( + sedate.standardize_date( + sedate.as_datetime(self.end), self.timezone + ), self.timezone, 'up' + ) + + @property + def accepts_submissions(self): + if not self.enabled: + return False + + if not self.in_the_present: + return False + + if self.overflow: + return True + + if self.limit is None: + return True + + return self.available_spots > 0 + + def disassociate(self): + """ Disassociates all records linked to this window. """ + + for submission in self.submissions: + submission.registration_window_id = None + + @property + def available_spots(self): + return self.limit - self.claimed_spots - self.requested_spots + + @property + def claimed_spots(self): + return object_session(self).execute(text(""" + SELECT SUM(COALESCE(claimed, 0)) + FROM submissions + WHERE registration_window_id = :id + AND submissions.state = 'complete' + """), {'id': self.id}).scalar() or 0 + + @property + def requested_spots(self): + return object_session(self).execute(text(""" + SELECT SUM(GREATEST(COALESCE(spots, 0) - COALESCE(claimed, 0), 0)) + FROM submissions + WHERE registration_window_id = :id + AND submissions.state = 'complete' + """), {'id': self.id}).scalar() or 0 + + @property + def in_the_future(self): + return sedate.utcnow() <= self.localized_start + + @property + def in_the_past(self): + return self.localized_end <= sedate.utcnow() + + @property + def in_the_present(self): + return self.localized_start <= sedate.utcnow() <= self.localized_end diff --git a/onegov/form/models/submission.py b/onegov/form/models/submission.py index dc24ba3..e82238f 100644 --- a/onegov/form/models/submission.py +++ b/onegov/form/models/submission.py @@ -12,7 +12,7 @@ from onegov.pay import Payable from onegov.pay import process_payment from sedate import utcnow -from sqlalchemy import Column, Enum, ForeignKey, Text +from sqlalchemy import Column, Enum, ForeignKey, Integer, Text from sqlalchemy_utils import observes from uuid import uuid4 from wtforms import StringField, TextAreaField @@ -63,6 +63,20 @@ class FormSubmission(Base, TimestampMixin, Payable, AssociatedFiles, nullable=False ) + #: the number of spots this submission wants to claim + #: (only relevant if there's a registration window) + spots = Column(Integer, nullable=False, default=0) + + #: the number of spots this submission has actually received + #: None => the decision if spots should be given is still open + #: 0 => the decision was negative, no spots were given + #: 1-x => the decision was positive, at least some spots were given + claimed = Column(Integer, nullable=True, default=None) + + #: the registration window linked with this submission + registration_window_id = Column( + UUID, ForeignKey("registration_windows.id"), nullable=True) + #: payment options -> copied from the dfinition at the moment of #: submission. This is stored alongside the submission as the original #: form setting may change later. diff --git a/onegov/form/tests/test_model.py b/onegov/form/tests/test_model.py index 376f951..65f1b7a 100644 --- a/onegov/form/tests/test_model.py +++ b/onegov/form/tests/test_model.py @@ -1,10 +1,16 @@ import pytest +from datetime import date, timedelta from onegov.form import FormCollection, FormExtension +from sqlalchemy.exc import IntegrityError from webob.multidict import MultiDict from wtforms import ValidationError +def days(d): + return timedelta(days=d) + + def test_has_submissions(session): collection = FormCollection(session) form = collection.definitions.add('Newsletter', definition="E-Mail = @@@") @@ -77,3 +83,171 @@ class ExtendedForm(self.form_class, CorporateOnly): submission = collection.submissions.add( 'members', form, state='complete') assert issubclass(submission.form_class, CorporateOnly) + + +def test_registration_window_adjacent(session): + forms = FormCollection(session) + + summer = forms.definitions.add('Summercamp', definition="E-Mail = @@@") + winter = forms.definitions.add('Witnercamp', definition="E-Mail = @@@") + + summer.add_registration_window(date(2017, 4, 1), date(2017, 6, 30)) + summer.add_registration_window(date(2018, 4, 1), date(2018, 6, 30)) + + assert len(summer.registration_windows) == 2 + + winter.add_registration_window(date(2017, 4, 1), date(2017, 6, 30)) + winter.add_registration_window(date(2018, 4, 1), date(2018, 6, 30)) + + assert len(winter.registration_windows) == 2 + + # no overlap -> different forms + session.flush() + + # adjacent, fails + summer.add_registration_window(date(2017, 1, 1), date(2017, 4, 1)) + + with pytest.raises(IntegrityError) as e: + session.flush() + + assert 'no_adjacent_registration_windows' in str(e) + + +def test_registration_window_overlaps(session): + forms = FormCollection(session) + + summer = forms.definitions.add('Summercamp', definition="E-Mail = @@@") + summer.add_registration_window(date(2017, 4, 1), date(2017, 6, 30)) + + assert len(summer.registration_windows) == 1 + + # no overlap -> different forms + session.flush() + + # overlapping, fails + summer.add_registration_window(date(2017, 1, 1), date(2017, 4, 2)) + + with pytest.raises(IntegrityError) as e: + session.flush() + + assert 'no_overlapping_registration_windows' in str(e) + + +def test_registration_window_end_before_start(session): + camp = FormCollection(session).definitions.add( + 'Camp', definition="E-Mail = @@@") + + camp.add_registration_window(date(2018, 1, 1), date(2017, 1, 1)) + + with pytest.raises(IntegrityError) as e: + session.flush() + + assert 'start_smaller_than_end' in str(e) + + +def test_current_registration_window_bound_to_form(session): + forms = FormCollection(session) + today = date.today() + + winter = forms.definitions.add('Witnercamp', definition="E-Mail = @@@") + winter.add_registration_window(today - days(1), today + days(1)) + + summer = forms.definitions.add('Summercamp', definition="E-Mail = @@@") + summer.add_registration_window(today - days(100), today - days(10)) + + assert winter.current_registration_window.start == today - days(1) + assert summer.current_registration_window.start == today - days(100) + + +def test_current_registration_window_end_date(session): + forms = FormCollection(session) + today = date.today() + + summer = forms.definitions.add('Summercamp', definition="E-Mail = @@@") + + # the first window is closer, though the start is further away + summer.add_registration_window(today - days(10), today - days(1)) + summer.add_registration_window(today + days(5), today + days(10)) + + assert summer.current_registration_window.start == today - days(10) + + +def test_registration_window_spots(session): + forms = FormCollection(session) + today = date.today() + + summer = forms.definitions.add('Summercamp', definition="E-Mail = @@@") + window = summer.add_registration_window(today - days(5), today + days(5)) + + session.flush() + + window.enabled = False + assert not window.accepts_submissions + + window.enabled = True + window.end = today - days(1) + assert not window.accepts_submissions + + window.start = today + days(1) + window.end = today + days(5) + assert not window.accepts_submissions + + window.start = today - days(5) + window.overflow = True + assert window.accepts_submissions + + window.overflow = False + window.limit = None + assert window.accepts_submissions + assert window.claimed_spots == 0 + assert window.requested_spots == 0 + + window.limit = 2 + assert window.accepts_submissions + assert window.claimed_spots == 0 + assert window.requested_spots == 0 + + s1 = forms.submissions.add( + name='summercamp', + form=summer.form_class(data={'e_mail': 'info@example.org'}), + state='complete', + spots=1 + ) + + assert window.accepts_submissions + assert window.claimed_spots == 0 + assert window.requested_spots == 1 + + s2 = forms.submissions.add( + name='summercamp', + form=summer.form_class(data={'e_mail': 'info@example.org'}), + state='complete', + spots=1 + ) + + assert not window.accepts_submissions + assert window.claimed_spots == 0 + assert window.requested_spots == 2 + + window.overflow = True + assert window.accepts_submissions + + s1.claimed = 1 + session.flush() + + assert window.accepts_submissions + assert window.claimed_spots == 1 + assert window.requested_spots == 1 + + window.overflow = False + assert not window.accepts_submissions + + s2.claimed = 1 + session.flush() + + assert not window.accepts_submissions + assert window.claimed_spots == 2 + assert window.requested_spots == 0 + + window.overflow = True + assert window.accepts_submissions diff --git a/onegov/form/upgrade.py b/onegov/form/upgrade.py index b64493c..b8ccfe1 100644 --- a/onegov/form/upgrade.py +++ b/onegov/form/upgrade.py @@ -5,14 +5,14 @@ from depot.io.utils import FileIntent from io import BytesIO from onegov.core.crypto import random_token -from onegov.core.orm.types import JSON +from onegov.core.orm.types import JSON, UUID from onegov.core.upgrade import upgrade_task from onegov.core.utils import dictionary_to_binary from onegov.core.utils import normalize_for_url from onegov.form import FormDefinitionCollection from onegov.form import FormFile from onegov.form import FormSubmission -from sqlalchemy import Column, Text +from sqlalchemy import Column, Integer, Text @upgrade_task('Enable external form submissions') @@ -123,3 +123,22 @@ def add_group_order_to_form_definitions(context): column=Column('order', Text, nullable=False, index=True), default=lambda form: normalize_for_url(form.title) ) + + +@upgrade_task('Add registration window columns') +def add_registration_window_columns(context): + context.operations.add_column( + 'submissions', + Column('claimed', Integer, nullable=True) + ) + + context.operations.add_column( + 'submissions', + Column('registration_window_id', UUID, nullable=True) + ) + + context.add_column_with_defaults( + table='submissions', + column=Column('spots', Integer, nullable=False), + default=0 + )