Skip to content
This repository has been archived by the owner on Sep 5, 2019. It is now read-only.

Commit

Permalink
Adds the ability to define registration windows for forms
Browse files Browse the repository at this point in the history
  • Loading branch information
Denis Krienbühl committed Mar 1, 2018
1 parent f0655d1 commit d8b4a85
Show file tree
Hide file tree
Showing 9 changed files with 459 additions and 4 deletions.
4 changes: 4 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
Changelog
---------

- Adds the ability to define registration windows for forms.
[href]

0.32.0 (2018-02-21)
~~~~~~~~~~~~~~~~~~~

Expand Down
2 changes: 2 additions & 0 deletions onegov/form/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
FormDefinition,
FormFile,
FormSubmission,
FormRegistrationWindow,
PendingFormSubmission,
CompleteFormSubmission
)
Expand All @@ -48,6 +49,7 @@
'FormDefinitionCollection',
'FormExtension',
'FormFile',
'FormRegistrationWindow',
'FormSubmission',
'FormSubmissionCollection',
'merge_forms',
Expand Down
37 changes: 36 additions & 1 deletion onegov/form/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions onegov/form/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
54 changes: 54 additions & 0 deletions onegov/form/models/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
151 changes: 151 additions & 0 deletions onegov/form/models/registration_window.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion onegov/form/models/submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit d8b4a85

Please sign in to comment.