From 11824170cfad2697e2d8323ff7937cf3f8838238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Krienb=C3=BChl?= Date: Fri, 13 May 2016 14:41:34 +0200 Subject: [PATCH] Adds the ability to export the reservations of a resource --- HISTORY.rst | 5 +- onegov/town/forms/__init__.py | 7 +- onegov/town/forms/resource.py | 27 +++- onegov/town/layout.py | 7 +- onegov/town/models/resource.py | 44 ++++- onegov/town/templates/macros.pt | 2 +- onegov/town/templates/resource_export.pt | 15 ++ onegov/town/templates/resource_occupancy.pt | 4 +- onegov/town/theme/styles/town.scss | 13 +- onegov/town/views/resource.py | 169 ++++++++++++++++---- 10 files changed, 250 insertions(+), 43 deletions(-) create mode 100644 onegov/town/templates/resource_export.pt diff --git a/HISTORY.rst b/HISTORY.rst index 5310dab..a04c6e1 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,7 +1,10 @@ Changelog --------- -- Adds an occupancy report to reservation resources. +- Adds the ability to export the reservations of a resource. + [href] + +- Adds an occupancy report on resource for reservations. [href] - Fixes unreserved allocations showing associated tickets. diff --git a/onegov/town/forms/__init__.py b/onegov/town/forms/__init__.py index ee48392..875feed 100644 --- a/onegov/town/forms/__init__.py +++ b/onegov/town/forms/__init__.py @@ -17,7 +17,11 @@ PasswordResetForm ) from onegov.town.forms.reservation import ReservationForm -from onegov.town.forms.resource import ResourceForm, ResourceCleanupForm +from onegov.town.forms.resource import ( + ResourceForm, + ResourceCleanupForm, + ResourceExportForm +) from onegov.town.forms.settings import SettingsForm from onegov.town.forms.signup import SignupForm from onegov.town.forms.userprofile import UserProfileForm @@ -37,6 +41,7 @@ 'ReservationForm', 'ResourceForm', 'ResourceCleanupForm', + 'ResourceExportForm', 'RequestPasswordResetForm', 'RoomAllocationForm', 'RoomAllocationEditForm', diff --git a/onegov/town/forms/resource.py b/onegov/town/forms/resource.py index 08f7fbd..0bba9d8 100644 --- a/onegov/town/forms/resource.py +++ b/onegov/town/forms/resource.py @@ -4,7 +4,7 @@ from onegov.town import _ from onegov.town.forms.reservation import RESERVED_FIELDS from onegov.town.utils import annotate_html -from wtforms import StringField, TextAreaField, validators +from wtforms import RadioField, StringField, TextAreaField, validators from wtforms.fields.html5 import DateField @@ -40,8 +40,8 @@ class ResourceForm(Form): ) -class ResourceCleanupForm(Form): - """ Defines the form to remove multiple allocations. """ +class DateRangeForm(Form): + """ A form providing a start/end date range. """ start = DateField( label=_("Start"), @@ -63,3 +63,24 @@ def validate(self): result = False return result + + +class ResourceCleanupForm(DateRangeForm): + """ Defines the form to remove multiple allocations. """ + + +class ResourceExportForm(DateRangeForm): + """ Defines the form to export reservations. """ + + file_format = RadioField( + label=_("Format"), + choices=[ + ('csv', _("CSV File")), + ('xlsx', _("Excel File")), + ('json', _("JSON File")) + ], + default='csv', + validators=[ + validators.InputRequired() + ] + ) diff --git a/onegov/town/layout.py b/onegov/town/layout.py index 659ce98..23e1bda 100644 --- a/onegov/town/layout.py +++ b/onegov/town/layout.py @@ -783,12 +783,17 @@ def editbar_links(self): Link( text=_("Clean up"), url=self.request.link(self.model, 'cleanup'), - classes=('cleanup-link', ) + classes=('cleanup-link', 'calendar-dependent') ), Link( text=_("Occupancy"), url=self.request.link(self.model, 'belegung'), classes=('occupancy-link', 'calendar-dependent') + ), + Link( + text=_("Export"), + url=self.request.link(self.model, 'export'), + classes=('export-link', 'calendar-dependent') ) ] diff --git a/onegov/town/models/resource.py b/onegov/town/models/resource.py index 9239e9c..8297695 100644 --- a/onegov/town/models/resource.py +++ b/onegov/town/models/resource.py @@ -1,14 +1,20 @@ +import sedate + +from datetime import datetime from libres.db.models import Reservation from onegov.core.orm.mixins import meta_property, content_property +from onegov.core.orm.types import UUID from onegov.libres.models import Resource from onegov.form.models import FormSubmission +from onegov.search import ORMSearchable +from onegov.ticket import Ticket from onegov.town.models.extensions import ( HiddenFromPublicExtension, ContactExtension, PersonLinkExtension, CoordinatesExtension ) -from onegov.search import ORMSearchable +from sqlalchemy.sql.expression import cast from uuid import uuid4, uuid5 @@ -27,6 +33,23 @@ def deletable(self): return True + @property + def calendar_date_range(self): + """ Returns the date range set by the fullcalendar specific params. """ + + if self.date: + date = datetime(self.date.year, self.date.month, self.date.day) + date = sedate.replace_timezone(date, self.timezone) + else: + date = sedate.to_timezone(sedate.utcnow(), self.timezone) + + if self.view == 'month': + return sedate.align_range_to_month(date, date, self.timezone) + elif self.view == 'agendaWeek': + return sedate.align_range_to_week(date, date, self.timezone) + elif self.view == 'agendaDay': + return sedate.align_range_to_day(date, date, self.timezone) + def remove_expired_reservation_sessions(self, expiration_date=None): session = self.libres_context.get_service('session_provider').session() queries = self.scheduler.queries @@ -67,6 +90,25 @@ def bound_session_id(self, request): return uuid5(self.id, request.browser_session.libres_session_id.hex) + def reservations_with_tickets_query(self, start, end): + """ Returns a query which joins this resource's reservations between + start and end with the tickets table. + + """ + query = self.scheduler.managed_reservations() + query = query.filter(start <= Reservation.start) + query = query.filter(Reservation.end <= end) + + query = query.join( + Ticket, Reservation.token == cast(Ticket.handler_id, UUID)) + + query = query.order_by(Reservation.start) + query = query.order_by(Ticket.subtitle) + query = query.filter(Reservation.status == 'approved') + query = query.filter(Reservation.data != None) + + return query + class SearchableResource(ORMSearchable): diff --git a/onegov/town/templates/macros.pt b/onegov/town/templates/macros.pt index 30ac364..cc37787 100644 --- a/onegov/town/templates/macros.pt +++ b/onegov/town/templates/macros.pt @@ -76,7 +76,7 @@ -
+
${fieldset.label} diff --git a/onegov/town/templates/resource_export.pt b/onegov/town/templates/resource_export.pt new file mode 100644 index 0000000..a076ebc --- /dev/null +++ b/onegov/town/templates/resource_export.pt @@ -0,0 +1,15 @@ +
+ + ${title} + + +
+ Exports the reservations of the given date range. +
+
+
+
+
+
+ +
diff --git a/onegov/town/templates/resource_occupancy.pt b/onegov/town/templates/resource_occupancy.pt index a72f1f0..9042704 100644 --- a/onegov/town/templates/resource_occupancy.pt +++ b/onegov/town/templates/resource_occupancy.pt @@ -16,10 +16,10 @@
-
+
-
+
  • diff --git a/onegov/town/theme/styles/town.scss b/onegov/town/theme/styles/town.scss index 9752e02..1c3197f 100644 --- a/onegov/town/theme/styles/town.scss +++ b/onegov/town/theme/styles/town.scss @@ -683,6 +683,7 @@ $copy-link-icon: '\f0c5'; $delete-link-icon: '\f014'; $disabled-icon: '\f05e'; $edit-link-icon: '\f040'; +$export-link-icon: '\f019'; $file-link-icon: '\f0c6'; $image-link-icon: '\f03e'; $internal-link-icon: '\f0c1'; @@ -734,6 +735,7 @@ $occupancy-link-icon: '\f022'; .copy-link:before { @include icon($copy-link-icon); } .delete-link:before { @include icon($delete-link-icon); } .edit-link:before { @include icon($edit-link-icon); } + .export-link:before { @include icon($export-link-icon); } .file-url:before { @include icon($file-link-icon); } .image-url:before { @include icon($image-link-icon); } .internal-url:before { @include icon($internal-link-icon); } @@ -2045,16 +2047,21 @@ button { */ .calendar-day-box { - padding: .5em 0; width: 100%; @media #{$small-up} { font-weight: bold; + padding: 0; + + span { + font-size: 1.3rem; + } } @media #{$large-up} { background-color: $white-smoke; font-weight: normal; + padding: .5em 0; span { display: block; @@ -2715,7 +2722,9 @@ ul.search-results { Occupancy report */ .occupancy-block > .columns { - margin-bottom: 1rem; + @media #{$large-only} { + margin-bottom: 1rem; + } } .occupancy-entry { diff --git a/onegov/town/views/resource.py b/onegov/town/views/resource.py index 41c1228..3f4b0c5 100644 --- a/onegov/town/views/resource.py +++ b/onegov/town/views/resource.py @@ -1,3 +1,4 @@ +import json import morepath import sedate @@ -6,18 +7,24 @@ from isodate import parse_date, ISO8601Error from itertools import groupby from libres.db.models import Reservation -from onegov.core.orm.types import UUID +from morepath.request import Response +from onegov.core.csv import convert_list_of_dicts_to_csv +from onegov.core.csv import convert_list_of_dicts_to_xlsx from onegov.core.security import Public, Private +from onegov.core.utils import normalize_for_url +from onegov.form import FormSubmission from onegov.libres import ResourceCollection from onegov.libres.models import Resource from onegov.ticket import Ticket from onegov.town import TownApp, _ from onegov.town import utils from onegov.town.elements import Link -from onegov.town.forms import ResourceForm, ResourceCleanupForm +from onegov.town.forms import ( + ResourceForm, ResourceCleanupForm, ResourceExportForm +) from onegov.town.layout import ResourcesLayout, ResourceLayout from onegov.town.models.resource import DaypassResource, RoomResource -from sqlalchemy.sql.expression import cast, nullsfirst +from sqlalchemy.sql.expression import nullsfirst from webob import exc @@ -161,9 +168,7 @@ def handle_cleanup_allocations(self, request, form): if form.submitted(request): start, end = form.data['start'], form.data['end'] - - scheduler = self.get_scheduler(request.app.libres_context) - count = scheduler.remove_unused_allocations(start, end) + count = self.scheduler.remove_unused_allocations(start, end) request.success( _("Successfully removed ${count} unused allocations", mapping={ @@ -173,6 +178,9 @@ def handle_cleanup_allocations(self, request, form): return morepath.redirect(request.link(self)) + if request.method == 'GET': + form.start.data, form.end.data = get_date_range(self, request.params) + layout = ResourceLayout(self, request) layout.breadcrumbs.append(Link(_("Clean up"), '#')) layout.editbar_links = None @@ -201,19 +209,7 @@ def get_date(text, default): def get_date_range(resource, params): - now = sedate.align_date_to_day(sedate.utcnow(), resource.timezone, 'down') - date = sedate.replace_timezone( - get_date(params.get('date'), now), resource.timezone) - - if resource.view == 'month': - default_start, default_end = sedate.align_range_to_month( - date, date, resource.timezone) - elif resource.view == 'agendaWeek': - default_start, default_end = sedate.align_range_to_week( - date, date, resource.timezone) - elif resource.view == 'agendaDay': - default_start, default_end = sedate.align_range_to_day( - date, date, resource.timezone) + default_start, default_end = resource.calendar_date_range start = get_date(params.get('start'), default_start) end = get_date(params.get('end'), default_end) @@ -224,6 +220,9 @@ def get_date_range(resource, params): end = sedate.replace_timezone( datetime(end.year, end.month, end.day), resource.timezone) + if end < start: + start = end + return sedate.align_range_to_day(start, end, resource.timezone) @@ -234,18 +233,7 @@ def view_occupancy(self, request): # infer the default start/end date from the calendar view parameters start, end = get_date_range(self, request.params) - query = self.scheduler.managed_reservations() - query = query.filter(start <= Reservation.start) - query = query.filter(Reservation.end <= end) - - query = query.join( - Ticket, Reservation.token == cast(Ticket.handler_id, UUID)) - - query = query.order_by(Reservation.start) - query = query.order_by(Ticket.subtitle) - query = query.filter(Reservation.status == 'approved') - query = query.filter(Reservation.data != None) - + query = self.reservations_with_tickets_query(start, end) query = query.with_entities( Reservation.start, Reservation.end, Reservation.quota, Ticket.subtitle, Ticket.id @@ -288,3 +276,122 @@ def group_key(record): 'end': sedate.to_timezone(end, self.timezone).date(), 'count': count, } + + +@TownApp.form(model=Resource, permission=Private, name='export', + template='resource_export.pt', form=ResourceExportForm) +def view_export(self, request, form): + + # XXX this could be turned into a redirect to a GET view, which would + # make it easier for scripts to get this data, but since we don't have + # a good API story anyway we don't have spend to much energy on it here + # - instead we should do this in a comprehensive fashion + if form.submitted(request): + file_format = form.data['file_format'] + + constant_fields, results = run_export( + resource=self, + request=request, + start=form.data['start'], + end=form.data['end'], + nested=file_format == 'json' + ) + + def field_order(field): + # known fields come first in a defined order (-x -> -1) + # unknown fields are ordered from a-z (second item in tuple) + if field in constant_fields: + return constant_fields.index(field) - len(constant_fields), '' + else: + return 0, field + + if file_format == 'json': + return Response( + json.dumps(results), + content_type='application/json' + ) + elif file_format == 'csv': + return Response( + convert_list_of_dicts_to_csv(results, key=field_order), + content_type='text/plain' + ) + elif file_format == 'xlsx': + return Response( + convert_list_of_dicts_to_xlsx(results, key=field_order), + content_type=( + 'application/vnd.openxmlformats' + '-officedocument.spreadsheetml.sheet' + ), + content_disposition='inline; filename={}.xlsx'.format( + normalize_for_url(self.title) + ) + ) + else: + raise NotImplemented() + + if request.method == 'GET': + form.start.data, form.end.data = get_date_range(self, request.params) + + layout = ResourceLayout(self, request) + layout.breadcrumbs.append(Link(_("Occupancy"), '#')) + layout.editbar_links = None + + return { + 'layout': layout, + 'title': _("Export"), + 'form': form + } + + +def run_export(resource, request, start, end, nested): + start = sedate.replace_timezone( + datetime(start.year, start.month, start.day), + resource.timezone + ) + end = sedate.replace_timezone( + datetime(end.year, end.month, end.day), + resource.timezone + ) + + start, end = sedate.align_range_to_day(start, end, resource.timezone) + + query = resource.reservations_with_tickets_query(start, end) + query = query.join(FormSubmission, Reservation.token == FormSubmission.id) + query = query.with_entities( + Reservation.start, + Reservation.end, + Reservation.quota, + Reservation.email, + Ticket.number, + Ticket.subtitle, + FormSubmission.data, + ) + + results = [] + + # update me: reused outside this function + constant_fields = ('start', 'end', 'quota', 'email', 'ticket', 'title') + + for record in query.all(): + result = OrderedDict() + + start = sedate.to_timezone(record[0], resource.timezone) + end = sedate.to_timezone(record[1], resource.timezone) + end += timedelta(microseconds=1) + + result['start'] = start.isoformat() + result['end'] = end.isoformat() + result['quota'] = record[2] + result['email'] = record[3] + result['ticket'] = record[4] + result['title'] = record[5] + + if nested: + result['form'] = record[6] + else: + for key, value in record[6].items(): + result['form_' + key] = value + + results.append(result) + + return constant_fields, results