From caa0f8c430e3d7c9f6f4e8be947cf8575f7149ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Krienb=C3=BChl?= Date: Wed, 16 Jan 2019 16:30:45 +0100 Subject: [PATCH] Adds the ability to define rules by which allocations are created --- HISTORY.rst | 3 + onegov/org/cronjobs.py | 9 + onegov/org/forms/__init__.py | 2 + onegov/org/forms/allocation.py | 105 +++++- onegov/org/layout.py | 35 ++ .../locale/de_CH/LC_MESSAGES/onegov.org.po | 77 ++++- .../locale/fr_CH/LC_MESSAGES/onegov.org.po | 80 ++++- onegov/org/templates/allocation_rules.pt | 23 ++ onegov/org/tests/test_views.py | 205 ++++++++++++ onegov/org/theme/styles/org.scss | 86 +++++ onegov/org/views/allocation.py | 301 +++++++++++++++++- 11 files changed, 906 insertions(+), 20 deletions(-) create mode 100644 onegov/org/templates/allocation_rules.pt diff --git a/HISTORY.rst b/HISTORY.rst index a5d3ee4..5be021e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,9 @@ Changelog --------- +- Adds the ability to define rules by which allocations are created. + [href] + - Adds the ability to skip allocations on holidays. [href] diff --git a/onegov/org/cronjobs.py b/onegov/org/cronjobs.py index 28dc600..7b9c45b 100644 --- a/onegov/org/cronjobs.py +++ b/onegov/org/cronjobs.py @@ -12,6 +12,7 @@ from onegov.reservation import Reservation, Resource, ResourceCollection from onegov.ticket import Ticket, TicketCollection from onegov.user import User, UserCollection +from onegov.org.views.allocation import handle_rules_cronjob from sedate import replace_timezone, to_timezone, utcnow, align_date_to_day from sqlalchemy import and_ from sqlalchemy.orm import undefer @@ -59,6 +60,14 @@ def publish_files(request): FileCollection(request.session).publish_files() +@OrgApp.cronjob(hour=23, minute=45, timezone='Europe/Zurich') +def process_resource_rules(request): + resources = ResourceCollection(request.app.libres_context) + + for resource in resources.query(): + handle_rules_cronjob(resources.bind(resource), request) + + @OrgApp.cronjob(hour=8, minute=30, timezone='Europe/Zurich') def send_daily_ticket_statistics(request): diff --git a/onegov/org/forms/__init__.py b/onegov/org/forms/__init__.py index eda6a98..eca8c8a 100644 --- a/onegov/org/forms/__init__.py +++ b/onegov/org/forms/__init__.py @@ -1,3 +1,4 @@ +from onegov.org.forms.allocation import AllocationRuleForm from onegov.org.forms.allocation import DaypassAllocationEditForm from onegov.org.forms.allocation import DaypassAllocationForm from onegov.org.forms.allocation import RoomAllocationEditForm @@ -34,6 +35,7 @@ __all__ = [ + 'AllocationRuleForm', 'AnalyticsSettingsForm', 'DateRangeForm', 'DaypassAllocationEditForm', diff --git a/onegov/org/forms/allocation.py b/onegov/org/forms/allocation.py index 11caae6..7fc7515 100644 --- a/onegov/org/forms/allocation.py +++ b/onegov/org/forms/allocation.py @@ -2,11 +2,13 @@ from cached_property import cached_property from datetime import datetime, timedelta +from dateutil.relativedelta import relativedelta from dateutil.rrule import rrule, DAILY, MO, TU, WE, TH, FR, SA, SU from onegov.form import Form from onegov.form.fields import MultiCheckboxField from onegov.org import _ -from wtforms.fields import RadioField +from uuid import uuid4 +from wtforms.fields import StringField, RadioField from wtforms.fields.html5 import DateField, IntegerField from wtforms.validators import DataRequired, NumberRange, InputRequired from wtforms_components import TimeField @@ -85,6 +87,101 @@ def is_excluded(self, date): return False +class AllocationRuleForm(Form): + """ Base form form allocation rules. """ + + title = StringField( + label=_("Title"), + description=_("General availability"), + validators=[InputRequired()], + fieldset=_("Rule")) + + extend = RadioField( + label=_("Extend"), + validators=[InputRequired()], + fieldset=_("Rule"), + default='daily', + choices=( + ('daily', _("Extend by one day at midnight")), + ('monthly', _("Extend by one month at the end of the month")), + ('yearly', _("Extend by one year at the end of the year")) + )) + + @cached_property + def rule_id(self): + return uuid4().hex + + @cached_property + def iteration(self): + return 0 + + @cached_property + def last_run(self): + None + + @property + def rule(self): + return { + 'id': self.rule_id, + 'title': self.title.data, + 'extend': self.extend.data, + 'options': self.options, + 'iteration': self.iteration, + 'last_run': self.last_run, + } + + @rule.setter + def rule(self, value): + self.__dict__['rule_id'] = value['id'] + self.__dict__['iteration'] = value['iteration'] + self.__dict__['last_run'] = value['last_run'] + + self.title.data = value['title'] + self.extend.data = value['extend'] + + for k, v in value['options'].items(): + if hasattr(self, k): + getattr(self, k).data = v + + @property + def options(self): + return { + k: getattr(self, k).data for k in self._fields + if k not in ('title', 'extend', 'csrf_token') + } + + def apply(self, resource): + if self.iteration == 0: + dates = self.dates + else: + unit = { + 'daily': 'days', + 'monthly': 'months', + 'yearly': 'years' + }[self.extend.data] + + start = self.end.data + timedelta(days=1) + end = self.end.data + relativedelta(**{unit: self.iteration}) + + dates = self.generate_dates( + start, + end, + weekdays=self.weekdays + ) + + data = {**(self.data or {}), 'rule': self.rule_id} + + return len(resource.scheduler.allocate( + dates=dates, + whole_day=self.whole_day, + quota=self.quota, + quota_limit=self.quota_limit, + data=data, + partly_available=self.partly_available, + skip_overlapping=True + )) + + class AllocationForm(Form, AllocationFormHelpers): """ Baseform for all allocation forms. Allocation forms are expected to implement the methods above (which contain a NotImplementedException). @@ -129,6 +226,12 @@ def on_request(self): if not self.request.app.org.holidays: self.delete_field('on_holidays') + def ensure_start_before_end(self): + if self.start.data and self.end.data: + if self.start.data > self.end.data: + self.start.errors.append(_("Start date before end date")) + return False + @property def weekdays(self): """ The rrule weekdays derived from the except_for field. """ diff --git a/onegov/org/layout.py b/onegov/org/layout.py index ef928b3..599c66d 100644 --- a/onegov/org/layout.py +++ b/onegov/org/layout.py @@ -1223,6 +1223,11 @@ def editbar_links(self): text=_("Subscribe"), url=self.request.link(self.model, 'subscribe'), attrs={'class': 'subscribe-link'} + ), + Link( + text=_("Rules"), + url=self.request.link(self.model, 'rules'), + attrs={'class': 'rule-link'} ) ] @@ -1231,6 +1236,36 @@ class ReservationLayout(ResourceLayout): editbar_links = None +class AllocationRulesLayout(ResourceLayout): + + @cached_property + def breadcrumbs(self): + return [ + Link(_("Homepage"), self.homepage_url), + Link(_("Reservations"), self.request.link(self.collection)), + Link(_(self.model.title), self.request.link(self.model)), + Link(_("Rules"), '#') + ] + + @cached_property + def editbar_links(self): + return [ + LinkGroup( + title=_("Add"), + links=[ + Link( + text=_("Rule"), + url=self.request.link( + self.model, + name='new-rule' + ), + attrs={'class': 'new-link'} + ) + ] + ), + ] + + class AllocationEditFormLayout(DefaultLayout): """ Same as the resource layout, but with different editbar links, because there's not really an allocation view, but there are allocation forms. diff --git a/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po b/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po index dc4d45f..71b127c 100644 --- a/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po +++ b/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2019-01-14 16:22+0100\n" +"POT-Creation-Date: 2019-01-16 14:27+0100\n" "PO-Revision-Date: 2018-12-08 11:01+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" @@ -123,6 +123,9 @@ msgstr "Reservationen" msgid "Notifications" msgstr "Mitteilungen" +msgid "Rules" +msgstr "Regeln" + msgid "Edit allocation" msgstr "Einteilung bearbeiten" @@ -160,6 +163,9 @@ msgstr "Publikationen" msgid "Delete" msgstr "Löschen" +msgid "Add" +msgstr "Hinzufügen" + msgid "New" msgstr "Neu" @@ -176,9 +182,6 @@ msgstr "" "Es existieren Eingaben die mit diesem Formular verknüpft sind. Löschen Sie " "diese Eingaben bevor Sie das Formular löschen." -msgid "Add" -msgstr "Hinzufügen" - msgid "New Note" msgstr "Neue Notiz" @@ -293,6 +296,9 @@ msgstr "Möchten Sie diese Reservations-Ressource wirklich löschen?" msgid "Delete resource" msgstr "Reservations-Ressource löschen" +msgid "Rule" +msgstr "Regel" + msgid "This event can't be editet." msgstr "Diese Veranstaltung kann nicht bearbeitet werden." @@ -1087,6 +1093,12 @@ msgstr "Bitte geben Sie ein Datum pro Zeile ein" msgid "Please enter only day and month" msgstr "Bitte geben Sie nur Tag und Monat ein" +msgid "General availability" +msgstr "Generelle Verfügbarkeit" + +msgid "Extend" +msgstr "Verlängern" + msgid "Except for" msgstr "Ausser an" @@ -1117,6 +1129,18 @@ msgstr "Optionen" msgid "Until" msgstr "Bis zum" +msgid "Extend by one day at midnight" +msgstr "Mitternachts um einen Tag verlängern" + +msgid "Extend by one month at the end of the month" +msgstr "Ende Monat um einen Monat verlängern" + +msgid "Extend by one year at the end of the year" +msgstr "Ende Jahr um ein Jahr verlängern" + +msgid "Start date before end date" +msgstr "Start Datum vor Ende" + msgid "New entries" msgstr "Neue Einträge" @@ -2586,6 +2610,9 @@ msgstr "Noch keine Dateien hochgeladen" msgid "The OneGov Cloud Team" msgstr "Das OneGov Cloud Team" +msgid "No rules defined." +msgstr "Keine Regeln definiert." + msgid "Export a vCard of this person" msgstr "Elektronische Visitenkarte (vCard)" @@ -3401,6 +3428,30 @@ msgstr "Ein Konto wurde für Sie erstellt" msgid "New allocation" msgstr "Neue Einteilung" +msgid "New Rule" +msgstr "Neue Regel" + +msgid "" +"Rules ensure that the allocations between start/end exist and that they are " +"extended beyond those dates at the given intervals. " +msgstr "" +"Regeln stellen sicher dass die Einteilungen zwischen Start und Ende bestehen " +"und dass sie im gewählten Interval verlängert werden." + +msgid "The rule was stopped" +msgstr "Die Regel wurde gestoppt" + +#, python-format +msgid "The rule was deleted, along with ${n} allocations" +msgstr "Die Regel wurde gelöscht, zusammen mit ${n} Einteilungen" + +#, python-format +msgid "New rule active, ${n} allocations created" +msgstr "Regel aktiv, ${n} Einteilungen erstellt" + +msgid "Stop" +msgstr "Stop" + msgid "No allocations to add" msgstr "Keine Einteilungen die hinzugefügt werden können" @@ -3408,6 +3459,24 @@ msgstr "Keine Einteilungen die hinzugefügt werden können" msgid "Successfully added ${n} allocations" msgstr "${n} Einteilungen wurden hinzugefügt" +#, python-format +msgid "Do you really want to stop \"${title}\"?" +msgstr "Möchten Sie \"${title}\" wirklich stoppen?" + +msgid "The rule will be removed without affecting existing allocations." +msgstr "Die Regel wird entfernt, bestehende Einteilungen bleiben." + +msgid "Stop rule" +msgstr "Regel stoppen" + +msgid "" +"All allocations created by the rule will be removed, if they haven't been " +"reserved yet." +msgstr "Alle Einteilungen der Regel werden entfernt, sofern nicht reserviert." + +msgid "Delete rule" +msgstr "Regel löschen" + #, python-format msgid "Search through ${count} indexed documents" msgstr "Durchsuchen Sie ${count} indizierte Dokumente" diff --git a/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po b/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po index e14b4ce..ebb5269 100644 --- a/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po +++ b/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2019-01-14 16:22+0100\n" +"POT-Creation-Date: 2019-01-16 14:27+0100\n" "PO-Revision-Date: 2018-12-08 11:02+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: French\n" @@ -124,6 +124,9 @@ msgstr "Réservations" msgid "Notifications" msgstr "Notifications" +msgid "Rules" +msgstr "Règles" + msgid "Edit allocation" msgstr "Modifier l'allocation" @@ -161,6 +164,9 @@ msgstr "Publications" msgid "Delete" msgstr "Supprimer" +msgid "Add" +msgstr "Ajouter" + msgid "New" msgstr "Nouveau" @@ -177,9 +183,6 @@ msgstr "" "Il existe des requêtes associées à ce formulaire. Il faut d'abord les " "supprimer." -msgid "Add" -msgstr "Ajouter" - msgid "New Note" msgstr "Nouveau commentaire" @@ -292,6 +295,9 @@ msgstr "Voulez-vous vraiment supprimer cette ressource ?" msgid "Delete resource" msgstr "Supprimer la ressource" +msgid "Rule" +msgstr "Règle" + msgid "This event can't be editet." msgstr "Cet événement ne peut pas être édité." @@ -1091,6 +1097,12 @@ msgstr "Veuillez renseigner une date par ligne" msgid "Please enter only day and month" msgstr "Veuillez ne renseigner que des jours et des mois" +msgid "General availability" +msgstr "Disponibilité générale" + +msgid "Extend" +msgstr "Prolonger" + msgid "Except for" msgstr "À l'exception de" @@ -1121,6 +1133,18 @@ msgstr "Options" msgid "Until" msgstr "Jusqu'à" +msgid "Extend by one day at midnight" +msgstr "Prolonger d'un jour à minuit" + +msgid "Extend by one month at the end of the month" +msgstr "Prolonger d'un mois à la fin du mois" + +msgid "Extend by one year at the end of the year" +msgstr "Prolonger d'un an à la fin de l'année" + +msgid "Start date before end date" +msgstr "Date de début avant la date de fin" + msgid "New entries" msgstr "Nouvelles entrées" @@ -2598,6 +2622,9 @@ msgstr "Aucun fichier téléchargé pour le moment" msgid "The OneGov Cloud Team" msgstr "L'équipe de OneGov Cloud" +msgid "No rules defined." +msgstr "Aucune règle définie." + msgid "Export a vCard of this person" msgstr "Exporter une vCard de cette personne" @@ -3424,6 +3451,31 @@ msgstr "Un compte a été créé pour vous" msgid "New allocation" msgstr "Nouvelle allocation" +msgid "New Rule" +msgstr "Nouvelle règle" + +msgid "" +"Rules ensure that the allocations between start/end exist and that they are " +"extended beyond those dates at the given intervals. " +msgstr "" +"Les règles garantissent que les allocations entre le début et la fin " +"existent et qu'elles sont prolongées au-delà de ces dates à des intervalles " +"donnés." + +msgid "The rule was stopped" +msgstr "La règle a été arrêtée" + +#, python-format +msgid "The rule was deleted, along with ${n} allocations" +msgstr "La règle a été supprimée, avec les allocations ${n}" + +#, python-format +msgid "New rule active, ${n} allocations created" +msgstr "Nouvelle règle active, ${n} allocations créées" + +msgid "Stop" +msgstr "Arrêter" + msgid "No allocations to add" msgstr "Aucune allocation à ajouter" @@ -3431,6 +3483,26 @@ msgstr "Aucune allocation à ajouter" msgid "Successfully added ${n} allocations" msgstr "${n} dotations ajoutées avec succès" +#, python-format +msgid "Do you really want to stop \"${title}\"?" +msgstr "Voulez-vous vraiment arrêter \"${title}\" ?" + +msgid "The rule will be removed without affecting existing allocations." +msgstr "La règle sera supprimée sans affecter les allocations existantes." + +msgid "Stop rule" +msgstr "Arrêter la règle" + +msgid "" +"All allocations created by the rule will be removed, if they haven't been " +"reserved yet." +msgstr "" +"Toutes les allocations créées par la règle seront supprimées si elles n'ont " +"pas encore été réservées." + +msgid "Delete rule" +msgstr "Supprimer la règle" + #, python-format msgid "Search through ${count} indexed documents" msgstr "Chercher parmi ${count} documents indexés" diff --git a/onegov/org/templates/allocation_rules.pt b/onegov/org/templates/allocation_rules.pt new file mode 100644 index 0000000..11058b2 --- /dev/null +++ b/onegov/org/templates/allocation_rules.pt @@ -0,0 +1,23 @@ +
+ + ${title} + + + +

+ No rules defined. +

+ +
    +
  • +
    + +
    + +
  • +
+ +
+
diff --git a/onegov/org/tests/test_views.py b/onegov/org/tests/test_views.py index 603f2d0..194603a 100644 --- a/onegov/org/tests/test_views.py +++ b/onegov/org/tests/test_views.py @@ -4603,3 +4603,208 @@ def sign(client, page, token): pdf = FileCollection(client.app.session()).query().one() client.get(f'/storage/{pdf.id}/details').click("Löschen") assert b'Signierte Datei gel\u00f6scht' in client.get('/timeline').body + + +def test_allocation_rules_on_rooms(client): + client.login_admin() + + resources = client.get('/resources') + + page = resources.click('Raum') + page.form['title'] = 'Room' + page.form.submit() + + def count_allocations(): + s = '2000-01-01' + e = '2050-01-31' + + return len(client.get(f'/resource/room/slots?start={s}&end={e}').json) + + def run_cronjob(): + client.get(f'/resource/room/process-rules') + + page = client.get('/resource/room').click("Regeln").click("Regel") + page.form['title'] = 'Täglich' + page.form['extend'] = 'daily' + page.form['start'] = '2019-01-01' + page.form['end'] = '2019-01-02' + page.form['as_whole_day'] = 'yes' + + page.select_checkbox('except_for', "Sa") + page.select_checkbox('except_for', "So") + + page = page.form.submit().follow() + + assert 'Regel aktiv, 2 Einteilungen erstellt' in page + assert count_allocations() == 2 + + # running the cronjob once will add a new allocation + with freeze_time('2019-01-02 22:00:00'): + run_cronjob() + + assert count_allocations() == 3 + + # running it a second time will not as there needs to be enough time + # between the two calls for the second one to succeed + with freeze_time('2019-01-02 22:00:00'): + run_cronjob() + + assert count_allocations() == 3 + + with freeze_time('2019-01-03 22:00:00'): + run_cronjob() + + assert count_allocations() == 4 + + # the next two times won't change anything as those are Saturday, Sunday + with freeze_time('2019-01-04 22:00:00'): + run_cronjob() + + assert count_allocations() == 4 + + with freeze_time('2019-01-05 22:00:00'): + run_cronjob() + + assert count_allocations() == 4 + + # then the cronjob should pick up again + with freeze_time('2019-01-06 22:00:00'): + run_cronjob() + + assert count_allocations() == 5 + + with freeze_time('2019-01-07 22:00:00'): + run_cronjob() + + assert count_allocations() == 6 + + # deleting the rule will delete all associated slots (but not others) + resources = ResourceCollection(client.app.libres_context) + resource = resources.by_name('room') + scheduler = resource.get_scheduler(client.app.libres_context) + + scheduler.allocate( + dates=(datetime(2018, 1, 1), datetime(2018, 1, 1)), + whole_day=True, + ) + + transaction.commit() + + assert count_allocations() == 7 + + page = client.get('/resource/room').click("Regeln") + page.click('Löschen') + + assert count_allocations() == 1 + + +def test_allocation_rules_on_daypasses(client): + client.login_admin() + + resources = client.get('/resources') + + page = resources.click('Tageskarte', index=0) + page.form['title'] = 'Daypass' + page.form.submit() + + def count_allocations(): + s = '2000-01-01' + e = '2050-01-31' + + return len(client.get( + f'/resource/daypass/slots?start={s}&end={e}').json) + + def run_cronjob(): + client.get(f'/resource/daypass/process-rules') + + page = client.get('/resource/daypass').click("Regeln").click("Regel") + page.form['title'] = 'Monatlich' + page.form['extend'] = 'monthly' + page.form['start'] = '2019-01-01' + page.form['end'] = '2019-01-31' + page.form['daypasses'] = '1' + page.form['daypasses_limit'] = '1' + page = page.form.submit().follow() + + assert 'Regel aktiv, 31 Einteilungen erstellt' in page + assert count_allocations() == 31 + + # running the cronjob on an ordinary day will not change anything + with freeze_time('2019-01-30 22:00:00'): + run_cronjob() + + assert count_allocations() == 31 + + # only run at the end of the month does it work + with freeze_time('2019-01-31 22:00:00'): + run_cronjob() + + assert count_allocations() == 59 + + # running it a second time on the same day has no effect + with freeze_time('2019-01-31 22:00:00'): + run_cronjob() + + assert count_allocations() == 59 + + # add another month + with freeze_time('2019-02-28 22:00:00'): + run_cronjob() + + assert count_allocations() == 90 + + # let's stop the rule, which should leave existing allocations + page = client.get('/resource/daypass').click("Regeln") + page.click('Stop') + + page = client.get('/resource/daypass').click("Regeln") + assert "Keine Regeln" in page + assert count_allocations() == 90 + + +def test_allocation_rules_with_holidays(client): + client.login_admin() + + page = client.get('/holiday-settings') + page.select_checkbox('cantonal_holidays', "Zug") + page.form.submit() + + resources = client.get('/resources') + page = resources.click('Tageskarte', index=0) + page.form['title'] = 'Daypass' + page.form.submit() + + def count_allocations(): + s = '2000-01-01' + e = '2050-01-31' + + return len(client.get( + f'/resource/daypass/slots?start={s}&end={e}').json) + + def run_cronjob(): + client.get(f'/resource/daypass/process-rules') + + page = client.get('/resource/daypass').click("Regeln").click("Regel") + page.form['title'] = 'Jährlich' + page.form['extend'] = 'yearly' + page.form['start'] = '2019-01-01' + page.form['end'] = '2019-12-31' + page.form['daypasses'] = '1' + page.form['daypasses_limit'] = '1' + page.form['on_holidays'] = 'no' + page = page.form.submit().follow() + + assert 'Regel aktiv, 352 Einteilungen erstellt' in page + assert count_allocations() == 352 + + # running the cronjob on an ordinary day will not change anything + with freeze_time('2019-01-31 22:00:00'): + run_cronjob() + + assert count_allocations() == 352 + + # only run at the end of the year does it work + with freeze_time('2019-12-31 22:00:00'): + run_cronjob() + + assert count_allocations() == 705 diff --git a/onegov/org/theme/styles/org.scss b/onegov/org/theme/styles/org.scss index a79affe..1e5103c 100644 --- a/onegov/org/theme/styles/org.scss +++ b/onegov/org/theme/styles/org.scss @@ -97,6 +97,10 @@ a:not([href]):not([role]) { cursor: pointer !important; } +.confirm { + cursor: pointer !important; +} + .partition-occupied { cursor: default !important; } @@ -160,6 +164,13 @@ body { line-height: auto; } +/* + Small text +*/ +.small-text { + font-size: .875rem; +} + /* Small atomic text particles whose white-space should not be wrapped */ @@ -1349,6 +1360,7 @@ $payment-capture-icon: '\f09d'; $payment-refund-icon: '\f0e2'; $payment-provider-icon: '\f283'; $payments-icon: '\f09d'; +$rule-link-icon: '\f085'; $select-icon: '\f14a'; $send-link-icon: '\f1d9'; $sync-icon: '\f021'; @@ -1482,6 +1494,7 @@ $editbar-bg-color: $topbar-link-bg-hover; .send-link::before { @include icon($send-link-icon); } .subscribe-link::before { @include icon($subscribe-link-icon); } .test-link::before { @include icon($test-link-icon); } + .rule-link::before { @include icon($rule-link-icon); } .sync::before { @include icon($sync-icon); } .unmute::before { @include icon($unmute); } .upload::before { @include icon($upload-icon); } @@ -5244,3 +5257,76 @@ h2 + div.timeline { .DayPicker-NavButton--interactionDisabled { display: none; } + +/* + Allocation rules +*/ + +.allocation-rules { + display: flex; + flex-wrap: wrap; + list-style: none; + margin: 0; + + .allocation-rule { + background: $snow; + border-radius: 2px; + flex: 0 0 32.66%; + margin-bottom: 1rem; + margin-right: 1%; + position: relative; + + &:nth-child(3n) { + margin-right: 0; + } + } + + @media #{$small-only} { + .allocation-rule { + flex: 0 0 100%; + margin-right: 0; + } + } + + .allocation-rule-details > div:first-child .field-display:first-of-type { + dt { + display: none; + } + + dd { + background: $silver; + border-radius: 2px 2px 0 0; + font-size: 1rem; + font-weight: bold; + left: -.5rem ; + margin-bottom: -.25rem; + padding: 0 8rem .2rem .5rem; + position: relative; + top: -.5rem; + width: calc(100% + 1rem); + } + } + + .allocation-rule-details { + padding: .5rem .5rem 0; + + * { + font-size: .8rem; + } + + h2 { + display: none; + } + + dd { + margin-bottom: .25rem; + } + } + + .allocation-rule-actions { + font-size: .8rem; + position: absolute; + right: .5rem; + top: .275rem; + } +} diff --git a/onegov/org/views/allocation.py b/onegov/org/views/allocation.py index 82bd42c..67c20e3 100644 --- a/onegov/org/views/allocation.py +++ b/onegov/org/views/allocation.py @@ -1,19 +1,31 @@ import morepath +from datetime import timedelta +from libres.db.models import ReservedSlot from libres.modules.errors import LibresError -from onegov.core.security import Public, Private -from onegov.reservation import Allocation, Resource, ResourceCollection +from onegov.core.security import Public, Private, Secret +from onegov.core.utils import is_uuid +from onegov.form import merge_forms from onegov.org import OrgApp, utils, _ -from onegov.org.elements import Link -from onegov.org.forms import ( - DaypassAllocationForm, - DaypassAllocationEditForm, - RoomAllocationForm, - RoomAllocationEditForm -) -from onegov.org.layout import ResourceLayout, AllocationEditFormLayout -from sqlalchemy.orm import defer, defaultload +from onegov.org.forms import AllocationRuleForm +from onegov.org.forms import DaypassAllocationEditForm +from onegov.org.forms import DaypassAllocationForm +from onegov.org.forms import RoomAllocationEditForm +from onegov.org.forms import RoomAllocationForm +from onegov.org.layout import AllocationEditFormLayout +from onegov.org.layout import AllocationRulesLayout +from onegov.org.layout import ResourceLayout +from onegov.org.new_elements import Link, Confirm, Intercooler +from onegov.reservation import Allocation +from onegov.reservation import Reservation +from onegov.reservation import Resource +from onegov.reservation import ResourceCollection from purl import URL +from sedate import utcnow +from sqlalchemy import not_, func +from sqlalchemy.dialects.postgresql import JSON +from sqlalchemy.orm import defer, defaultload +from webob import exc @OrgApp.json(model=Resource, name='slots', permission=Public) @@ -51,6 +63,97 @@ def view_allocations_json(self, request): ) +@OrgApp.view(model=Resource, name='process-rules', permission=Secret) +def process_rules(self, request): + """ Manually runs the rules processing cronjobs for testing. + + Not really dangerous, though it should be replaced with something + proper instead, cronjobs currently do not run in most tests and that + should be remedied. + + """ + + if request.current_username == 'admin@example.org': + handle_rules_cronjob(self, request) + + +@OrgApp.html(model=Resource, name='rules', permission=Private, + template='allocation_rules.pt') +def view_allocation_rules(self, request): + layout = AllocationRulesLayout(self, request) + + def link_for_rule(rule, name): + url = URL(request.link(self, name)) + url = url.query_param('csrf-token', layout.csrf_token) + url = url.query_param('rule', rule['id']) + + return url.as_string() + + def actions_for_rule(rule): + yield Link( + text=_("Stop"), + url=link_for_rule(rule, 'stop-rule'), + traits=( + Confirm( + _( + 'Do you really want to stop "${title}"?', + mapping={'title': rule['title']} + ), + _( + "The rule will be removed without affecting " + "existing allocations." + ), + _("Stop rule") + ), + Intercooler( + request_method="POST", + redirect_after=request.link(self, 'rules') + ) + ) + ) + + yield Link( + text=_("Delete"), + url=link_for_rule(rule, 'delete-rule'), + traits=( + Confirm( + _( + 'Do you really want to delete "${title}"?', + mapping={'title': rule['title']} + ), + _( + "All allocations created by the rule will be removed, " + "if they haven't been reserved yet." + ), + _("Delete rule") + ), + Intercooler( + request_method="POST", + redirect_after=request.link(self, 'rules') + ) + ) + ) + + def rules_with_actions(): + form_class = get_allocation_rule_form_class(self, request) + + for rule in self.content.get('rules', ()): + form = request.get_form(form_class, csrf_support=False, model=self) + form.rule = rule + + yield { + 'title': rule['title'], + 'actions': tuple(actions_for_rule(rule)), + 'form': form + } + + return { + 'layout': layout, + 'title': _("Rules"), + 'rules': tuple(rules_with_actions()) + } + + def get_new_allocation_form_class(resource, request): """ Returns the form class for new allocations (different resources have different allocation forms). @@ -84,6 +187,14 @@ def get_edit_allocation_form_class(allocation, request): raise NotImplementedError +def get_allocation_rule_form_class(resource, request): + """ Returns the form class for allocation rules. """ + + form = get_new_allocation_form_class(resource, request) + + return merge_forms(AllocationRuleForm, form) + + @OrgApp.form(model=Resource, template='form.pt', name='new-allocation', permission=Private, form=get_new_allocation_form_class) def handle_new_allocation(self, request, form): @@ -180,6 +291,10 @@ def handle_edit_allocation(self, request, form): except LibresError as e: utils.show_libres_error(e, request) else: + # when we edit an allocation, we disassociate it from any rules + if self.data and 'rule' in self.data: + self.data = {k: v for k, v in self.data.items() if k != 'rule'} + request.success(_("Your changes were saved")) resource.highlight_allocations([self]) @@ -212,3 +327,167 @@ def handle_delete_allocation(self, request): @request.after def trigger_calendar_update(response): response.headers.add('X-IC-Trigger', 'rc-allocations-changed') + + +@OrgApp.form(model=Resource, template='form.pt', name='new-rule', + permission=Private, form=get_allocation_rule_form_class) +def handle_allocation_rule(self, request, form): + layout = AllocationRulesLayout(self, request) + + if form.submitted(request): + changes = form.apply(self) + + rules = self.content.get('rules', []) + rules.append(form.rule) + self.content['rules'] = rules + + request.success(_( + "New rule active, ${n} allocations created", mapping={'n': changes} + )) + + return request.redirect(request.link(self, name='rules')) + + return { + 'layout': layout, + 'title': _("New Rule"), + 'form': form, + 'helptext': _( + "Rules ensure that the allocations between start/end exist and " + "that they are extended beyond those dates at the given " + "intervals. " + ) + } + + +def rule_id_from_request(request): + """ Returns the rule_id from the request params, ensuring that + an actual uuid is returned. + + """ + rule_id = request.params.get('rule') + + if not is_uuid(rule_id): + raise exc.HTTPBadRequest() + + return rule_id + + +def handle_rules_cronjob(resource, request): + """ Handles all cronjob duties of the rules stored on the given + resource. + + """ + if not resource.content.get('rules'): + return + + targets = [] + + now = utcnow() + tomorrow = (now + timedelta(days=1)).date() + + def should_process(rule): + # do not reprocess rules if they were processed less than 12 hours + # prior - this prevents flaky cronjobs from accidentally processing + # rules too often + if rule['last_run']\ + and rule['last_run'] > utcnow() - timedelta(hours=12): + return False + + # we assume to be called once a day, so if we are called, a daily + # rule has to be processed + if rule['extend'] == 'daily': + return True + + if rule['extend'] == 'monthly' and tomorrow.day == 1: + return True + + if rule['extend'] == 'yearly'\ + and tomorrow.month == 1\ + and tomorrow.day == 1: + + return True + + return False + + def prepare_rule(rule): + if should_process(rule): + rule['iteration'] += 1 + rule['last_run'] = now + + targets.append(rule) + + return rule + + resource.content['rules'] = [ + prepare_rule(r) for r + in resource.content.get('rules', ())] + + form_class = get_allocation_rule_form_class(resource, request) + + for rule in targets: + form = request.get_form(form_class, csrf_support=False, model=resource) + form.rule = rule + form.apply(resource) + + +def delete_rule(resource, rule_id): + """ Removes the given rule from the resource. """ + + resource.content['rules'] = [ + rule for rule in resource.content.get('rules', ()) + if rule['id'] != rule_id + ] + + +@OrgApp.view(model=Resource, request_method='POST', permission=Private, + name='stop-rule') +def handle_stop_rule(self, request): + request.assert_valid_csrf_token() + + rule_id = rule_id_from_request(request) + delete_rule(self, rule_id) + + request.success(_("The rule was stopped")) + + +@OrgApp.view(model=Resource, request_method='POST', permission=Private, + name='delete-rule') +def handle_delete_rule(self, request): + request.assert_valid_csrf_token() + + rule_id = rule_id_from_request(request) + + # all the slots + slots = self.scheduler.managed_reserved_slots() + slots = slots.with_entities(ReservedSlot.allocation_id) + + # all the reservations + reservations = self.scheduler.managed_reservations() + reservations = reservations.with_entities(Reservation.target) + + # include the allocations created by the given rule... + candidates = self.scheduler.managed_allocations() + candidates = candidates.filter( + func.json_extract_path_text( + func.cast(Allocation.data, JSON), 'rule' + ) == rule_id + ) + + # .. without the ones with slots + candidates = candidates.filter( + not_(Allocation.id.in_(slots.subquery()))) + + # .. without the ones with reservations + candidates = candidates.filter( + not_(Allocation.group.in_(reservations.subquery()))) + + # delete the allocations + count = candidates.delete('fetch') + + delete_rule(self, rule_id) + + request.success( + _("The rule was deleted, along with ${n} allocations", mapping={ + 'n': count + }) + )