From ff329a70b9dbcea1f8c420a354e843fd09a74476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Krienb=C3=BChl?= Date: Thu, 12 May 2016 14:47:44 +0200 Subject: [PATCH] Adds an occupancy report to reservation resources Resolves #183 --- HISTORY.rst | 3 + onegov/town/app.py | 9 +- onegov/town/assets/js/date-range-selector.js | 21 ++ onegov/town/assets/js/events.js | 21 -- onegov/town/assets/js/reservationcalendar.jsx | 7 + onegov/town/layout.py | 10 + .../locale/de_ch/LC_MESSAGES/onegov.town.po | 17 +- onegov/town/models/resource.py | 6 + onegov/town/templates/macros.pt | 36 ++- onegov/town/templates/occurrences.pt | 33 +-- onegov/town/templates/resource_occupancy.pt | 54 ++++ onegov/town/tests/test_views.py | 38 ++- onegov/town/theme/styles/town.scss | 254 ++++++++++-------- onegov/town/views/occurrence.py | 3 - onegov/town/views/resource.py | 108 +++++++- 15 files changed, 444 insertions(+), 176 deletions(-) create mode 100644 onegov/town/assets/js/date-range-selector.js delete mode 100644 onegov/town/assets/js/events.js create mode 100644 onegov/town/templates/resource_occupancy.pt diff --git a/HISTORY.rst b/HISTORY.rst index e4b1bb6..5310dab 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,9 @@ Changelog --------- +- Adds an occupancy report to reservation resources. + [href] + - Fixes unreserved allocations showing associated tickets. [href] diff --git a/onegov/town/app.py b/onegov/town/app.py index 5457f25..f936ae5 100644 --- a/onegov/town/app.py +++ b/onegov/town/app.py @@ -224,15 +224,8 @@ def get_sortable_asset(): yield 'sortable_custom.js' -@TownApp.webasset('events') -def get_events_asset(): - yield 'url.js' - yield 'events.js' - - @TownApp.webasset('fullcalendar') def get_fullcalendar_asset(): - yield 'url.js' yield 'fullcalendar.css' yield 'moment.js' yield 'moment.de.js' @@ -305,4 +298,6 @@ def get_common_asset(): yield 'jquery.popupoverlay.js' yield 'videoframe.js' yield 'datetimepicker.js' + yield 'url.js' + yield 'date-range-selector.js' yield 'common.js' diff --git a/onegov/town/assets/js/date-range-selector.js b/onegov/town/assets/js/date-range-selector.js new file mode 100644 index 0000000..cb1050f --- /dev/null +++ b/onegov/town/assets/js/date-range-selector.js @@ -0,0 +1,21 @@ +// set date filter on input change +var set_date_range_selector_filter = function(name, value) { + var location = new Url(); + location.query[name] = convert_date(value, datetimepicker_i18n[get_locale()].format, 'Y-m-d'); + delete location.query.page; + window.location.href = location.toString(); +}; + +if (Modernizr.inputtypes.date) { + $('.date-range-selector input[type="date"]').on('input', function() { + set_date_range_selector_filter($(this).attr('name'), $(this).val()); + }); +} else { + $('.date-range-selector input[type="date"]').each(function() { + $(this).datetimepicker({ + onChangeDateTime: function(_dp, $input) { + set_date_range_selector_filter($input.attr('name'), $input.val()); + } + }); + }); +} diff --git a/onegov/town/assets/js/events.js b/onegov/town/assets/js/events.js deleted file mode 100644 index a20a123..0000000 --- a/onegov/town/assets/js/events.js +++ /dev/null @@ -1,21 +0,0 @@ -// set date filter on input change -var set_date_filter = function(name, value) { - var location = new Url(); - location.query[name] = convert_date(value, datetimepicker_i18n[get_locale()].format, 'Y-m-d'); - delete location.query.page; - window.location.href = location.toString(); -}; - -if (Modernizr.inputtypes.date) { - $('input[type="date"]').on('input', function() { - set_date_filter($(this).attr('name'), $(this).val()); - }); -} else { - $('input[type="date"]').each(function() { - $(this).datetimepicker({ - onChangeDateTime: function(dp, $input) { - set_date_filter($input.attr('name'), $input.val()); - } - }); - }); -} diff --git a/onegov/town/assets/js/reservationcalendar.jsx b/onegov/town/assets/js/reservationcalendar.jsx index 15771d2..b6afa6e 100644 --- a/onegov/town/assets/js/reservationcalendar.jsx +++ b/onegov/town/assets/js/reservationcalendar.jsx @@ -384,6 +384,13 @@ rc.setupHistory = function(fcOptions) { url.query.view = view.name; url.query.date = view.intervalStart.format('YYYYMMDD'); + $('a.calendar-dependent').each(function(_ix, el) { + var dependentUrl = new Url($(el).attr('href')); + dependentUrl.query.view = url.query.view; + dependentUrl.query.date = url.query.date; + $(el).attr('href', dependentUrl.toString()); + }); + var state = [ { 'view': view.name, diff --git a/onegov/town/layout.py b/onegov/town/layout.py index fb1235f..659ce98 100644 --- a/onegov/town/layout.py +++ b/onegov/town/layout.py @@ -1,3 +1,5 @@ +import sedate + from cached_property import cached_property from dateutil import rrule from onegov.core.layout import ChameleonLayout @@ -69,6 +71,9 @@ def primary_color(self): return self.town.theme_options.get( 'primary-color', user_options['primary-color']) + def today(self): + return sedate.to_timezone(sedate.utcnow(), self.timezone).date() + @cached_property def default_map_view(self): return self.town.default_map_view or None @@ -780,6 +785,11 @@ def editbar_links(self): url=self.request.link(self.model, 'cleanup'), classes=('cleanup-link', ) ), + Link( + text=_("Occupancy"), + url=self.request.link(self.model, 'belegung'), + classes=('occupancy-link', 'calendar-dependent') + ) ] diff --git a/onegov/town/locale/de_ch/LC_MESSAGES/onegov.town.po b/onegov/town/locale/de_ch/LC_MESSAGES/onegov.town.po index 306f6ff..6f554eb 100644 --- a/onegov/town/locale/de_ch/LC_MESSAGES/onegov.town.po +++ b/onegov/town/locale/de_ch/LC_MESSAGES/onegov.town.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2016-04-28 16:18+0200\n" +"POT-Creation-Date: 2016-05-11 14:15+0200\n" "PO-Revision-Date: 2015-10-15 09:42+0200\n" "Last-Translator: Denis Krienbühl \n" "Language-Team: German\n" @@ -145,6 +145,9 @@ msgstr "Es existieren Reservationen für diese Reservations-Ressource" msgid "Clean up" msgstr "Aufräumen" +msgid "Occupancy" +msgstr "Belegung" + msgid "Do you really want to delete this event?" msgstr "Möchten Sie die Veranstaltung wirklich löschen?" @@ -692,12 +695,6 @@ msgstr "Möchten Sie wirklich alle Reservationen absagen?" msgid "Rejecting these reservations can't be undone." msgstr "Die Absage aller Reservationen kann nicht rückgängig gemacht werden." -msgid "Edit details" -msgstr "Details bearbeiten" - -msgid "Accept event" -msgstr "Veranstaltung annehmen" - #, python-format msgid "Reject ${title}" msgstr "${title} absagen" @@ -712,6 +709,12 @@ msgstr "Die Absage von ${title} kann nicht rückgängig gemacht werden." msgid "Reject reservation" msgstr "Reservation absagen" +msgid "Edit details" +msgstr "Details bearbeiten" + +msgid "Accept event" +msgstr "Veranstaltung annehmen" + msgid "Link" msgstr "Verknüpfung" diff --git a/onegov/town/models/resource.py b/onegov/town/models/resource.py index 5020cc9..9239e9c 100644 --- a/onegov/town/models/resource.py +++ b/onegov/town/models/resource.py @@ -95,6 +95,9 @@ class DaypassResource(Resource, HiddenFromPublicExtension, SearchableResource, # the default view view = 'month' + # show or hide quota numbers in reports + show_quota = True + class RoomResource(Resource, HiddenFromPublicExtension, SearchableResource, ContactExtension, PersonLinkExtension, @@ -105,3 +108,6 @@ class RoomResource(Resource, HiddenFromPublicExtension, SearchableResource, # the default view view = 'agendaWeek' + + # show or hide quota numbers in reports + show_quota = False diff --git a/onegov/town/templates/macros.pt b/onegov/town/templates/macros.pt index d5fb263..30ac364 100644 --- a/onegov/town/templates/macros.pt +++ b/onegov/town/templates/macros.pt @@ -503,11 +503,11 @@ - +

-
+ ${layout.format_date(date, 'weekday_long')} @@ -541,3 +541,35 @@
No events found.
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
\ No newline at end of file diff --git a/onegov/town/templates/occurrences.pt b/onegov/town/templates/occurrences.pt index a695e88..ff08b0d 100644 --- a/onegov/town/templates/occurrences.pt +++ b/onegov/town/templates/occurrences.pt @@ -20,7 +20,7 @@
-
+
${number_of_occurrences} Events
@@ -33,36 +33,13 @@
-
-
+ + -
-
-
- - -
-
-
-
- - -
-
-
- - -
+ +
diff --git a/onegov/town/templates/resource_occupancy.pt b/onegov/town/templates/resource_occupancy.pt new file mode 100644 index 0000000..a72f1f0 --- /dev/null +++ b/onegov/town/templates/resource_occupancy.pt @@ -0,0 +1,54 @@ +
+ + ${title} + + + + + ${layout.format_date(start, 'date_long')} - ${layout.format_date(end, 'date_long')} + + + ${layout.format_date(start, 'date_long')} + + + +
+
+
+ +
+ +
+
+
    +
  • + + + ${layout.format_time_range(entry.start, entry.end)} + + + Whole day + + + (${entry.quota}) + + + +
  • +
+
+
+
+
+
+
+ ${count} + Reservations +
+ +
+
+
+
diff --git a/onegov/town/tests/test_views.py b/onegov/town/tests/test_views.py index 61cf15a..27deb7f 100644 --- a/onegov/town/tests/test_views.py +++ b/onegov/town/tests/test_views.py @@ -1775,6 +1775,42 @@ def test_reserve_in_parallel(town_app): ).follow() +def test_occupancy_view(town_app): + + # prepate the required data + resources = ResourceCollection(town_app.libres_context) + resource = resources.by_name('sbb-tageskarte') + scheduler = resource.get_scheduler(town_app.libres_context) + + allocations = scheduler.allocate( + dates=(datetime(2015, 8, 28), datetime(2015, 8, 28)), + whole_day=True + ) + + client = Client(town_app) + reserve = bound_reserve(client, allocations[0]) + transaction.commit() + + client.login_admin() + + # create a reservation + assert reserve().json == {'success': True} + formular = client.get('/ressource/sbb-tageskarte/formular') + formular.form['email'] = 'info@example.org' + formular.form.submit().follow().click('Abschliessen') + + ticket = client.get('/tickets/ALL/open').click('Annehmen').follow() + + # at this point, the reservation won't show up in the occupancy view + occupancy = client.get('/ressource/sbb-tageskarte/belegung?date=20150828') + assert len(occupancy.pyquery('.occupancy-block')) == 0 + + # ..until we accept it + ticket.click('Alle Reservationen annehmen') + occupancy = client.get('/ressource/sbb-tageskarte/belegung?date=20150828') + assert len(occupancy.pyquery('.occupancy-block')) == 1 + + def test_reserve_session_separation(town_app): c1 = Client(town_app) c1.login_admin() @@ -2102,7 +2138,7 @@ def events(query=''): def total_events(query=''): page = client.get('/veranstaltungen/?{}'.format(query)) - return int(page.pyquery('.occurrences-filter-result span')[0].text) + return int(page.pyquery('.date-range-selector-result span')[0].text) def dates(query=''): page = client.get('/veranstaltungen/?{}'.format(query)) diff --git a/onegov/town/theme/styles/town.scss b/onegov/town/theme/styles/town.scss index 536b777..9752e02 100644 --- a/onegov/town/theme/styles/town.scss +++ b/onegov/town/theme/styles/town.scss @@ -698,6 +698,7 @@ $new-reservation-icon: '\f271'; $new-room-icon: '\f015'; $paste-link-icon: '\f0ea'; $send-link-icon: '\f1d9'; +$occupancy-link-icon: '\f022'; .edit-bar { background-color: $primary-color; @@ -748,6 +749,7 @@ $send-link-icon: '\f1d9'; .new-room:before { @include icon($new-room-icon); } .paste-link:before { @include icon($paste-link-icon); } .send-link:before { @include icon($send-link-icon); } + .occupancy-link::before { @include icon($occupancy-link-icon); } } /* @@ -2104,7 +2106,7 @@ button { } } -.occurrences-filter-result { +.date-range-selector-result { margin-bottom: 1em; span:first-child { @@ -2114,7 +2116,7 @@ button { } .occurrences-filter-tags, -.occurrences-filter-date { +.date-range-selector { margin-bottom: 1em; } @@ -2463,109 +2465,6 @@ ul.search-results { } } - -/* - Print Styles -*/ -@media print { - .alert-box, - .bottom-links, - .edit-bar, - .footer, - .occurrences-day-date, - .occurrences-filter-date, - .occurrences-filter-tags, - .occurrence-exports, - .page-links, - .searchbox, - .side-nav, - .ticket-count, - div.push, - .top-bar { - display: none; - } - - .page-content-panel { - border: 1px solid $tuatara; - border-radius: 3px; - } - - body { - font-size: 12px !important; - } - - // tickets status panel - .page-content-panel { - margin-bottom: .5rem; - - .field-display-block { - float: left; - margin-right: 1.5rem; - } - } - - .field-display-block * { - background-color: transparent; - color: $body-font-color; - font-size: 12px; - margin: 0 !important; - padding: 0; - } - - .ticket-summary { - * { - font-size: 12px; - } - - ul { - margin-bottom: 0; - } - } - - h1 { - font-size: 1.6rem; - } - - h2 { - @include hairline; - font-size: 1.2rem; - margin-bottom: .25rem; - } - - h3 { - font-size: 1.1rem; - margin-bottom: .15rem; - } - - h4, - h5, - h6 { - font-size: 1rem; - } - - .header { - max-height: 50px; - } - - .logo { - max-height: 40px; - - img, - svg { - max-height: 45px; - width: auto; - } - } - - ul.breadcrumbs { - padding: 0 0 .25rem 1rem; - - * { - font-size: 9px; - } - } -} - /* Datetime picker tweaks */ @@ -2811,3 +2710,148 @@ ul.search-results { font-size: 1rem; margin-bottom: 1rem; } + +/* + Occupancy report +*/ +.occupancy-block > .columns { + margin-bottom: 1rem; +} + +.occupancy-entry { + list-style: none; + margin-left: 0; + + .date, + .quota { + font-weight: bold; + } + + .title { + margin-bottom: .5rem; + } +} + +/* + Print Styles +*/ +@media print { + .alert-box, + .bottom-links, + .date-range-selector, + .date-range-selector-result, + .edit-bar, + .footer, + .occurrence-exports, + .occurrences-day-date, + .occurrences-filter-tags, + .page-links, + .searchbox, + .side-nav, + .ticket-count, + div.push, + .top-bar { + display: none; + } + + .page-content-panel { + border: 1px solid $tuatara; + border-radius: 3px; + } + + body { + font-size: 12px !important; + } + + // tickets status panel + .page-content-panel { + margin-bottom: .5rem; + + .field-display-block { + float: left; + margin-right: 1.5rem; + } + } + + .field-display-block * { + background-color: transparent; + color: $body-font-color; + font-size: 12px; + margin: 0 !important; + padding: 0; + } + + .ticket-summary { + * { + font-size: 12px; + } + + ul { + margin-bottom: 0; + } + } + + h1 { + font-size: 1.6rem; + } + + h2 { + @include hairline; + font-size: 1.2rem; + margin-bottom: .25rem; + } + + h3 { + font-size: 1.1rem; + margin-bottom: .15rem; + } + + h4, + h5, + h6 { + font-size: 1rem; + } + + .header { + max-height: 50px; + } + + .logo { + max-height: 40px; + + img, + svg { + max-height: 45px; + width: auto; + } + } + + ul.breadcrumbs { + padding: 0 0 .25rem 1rem; + + * { + font-size: 9px; + } + } + + .occupancy-block { + h2 { + font-weight: bold; + } + + .occupancy-entry { + .date, + .quota { + font-weight: normal; + } + } + + * { + font-size: 12px; + } + + > .columns { + margin-bottom: 0; + } + } +} diff --git a/onegov/town/views/occurrence.py b/onegov/town/views/occurrence.py index 82d3e4c..97dd803 100644 --- a/onegov/town/views/occurrence.py +++ b/onegov/town/views/occurrence.py @@ -21,9 +21,6 @@ def view_occurrences(self, request): """ View all occurrences of all events. """ - request.include('common') - request.include('events') - layout = OccurrencesLayout(self, request) tags = ( diff --git a/onegov/town/views/resource.py b/onegov/town/views/resource.py index 13515da..41c1228 100644 --- a/onegov/town/views/resource.py +++ b/onegov/town/views/resource.py @@ -1,17 +1,23 @@ import morepath +import sedate -from collections import OrderedDict +from collections import OrderedDict, namedtuple +from datetime import datetime, timedelta +from isodate import parse_date, ISO8601Error from itertools import groupby +from libres.db.models import Reservation +from onegov.core.orm.types import UUID from onegov.core.security import Public, Private 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.layout import ResourcesLayout, ResourceLayout from onegov.town.models.resource import DaypassResource, RoomResource -from sqlalchemy.sql.expression import nullsfirst +from sqlalchemy.sql.expression import cast, nullsfirst from webob import exc @@ -184,3 +190,101 @@ def get_reservations(self, request): utils.ReservationInfo(reservation, request).as_dict() for reservation in self.bound_reservations(request) ] + + +def get_date(text, default): + try: + date = parse_date(text) + return datetime(date.year, date.month, date.day, tzinfo=default.tzinfo) + except (ISO8601Error, TypeError): + return 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) + + start = get_date(params.get('start'), default_start) + end = get_date(params.get('end'), default_end) + + 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) + + return sedate.align_range_to_day(start, end, resource.timezone) + + +@TownApp.html(model=Resource, permission=Private, name='belegung', + template='resource_occupancy.pt') +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 = query.with_entities( + Reservation.start, Reservation.end, Reservation.quota, + Ticket.subtitle, Ticket.id + ) + + def group_key(record): + return sedate.to_timezone(record[0], self.timezone).date() + + occupancy = OrderedDict() + grouped = groupby(query.all(), group_key) + Entry = namedtuple('Entry', ('start', 'end', 'title', 'quota', 'url')) + count = 0 + + for date, records in grouped: + occupancy[date] = tuple( + Entry( + start=sedate.to_timezone(r[0], self.timezone), + end=sedate.to_timezone( + r[1] + timedelta(microseconds=1), self.timezone), + quota=r[2], + title=r[3], + url=request.class_link(Ticket, { + 'handler_code': 'RSV', + 'id': r[4] + }) + ) for r in records + ) + count += len(occupancy[date]) + + layout = ResourceLayout(self, request) + layout.breadcrumbs.append(Link(_("Occupancy"), '#')) + layout.editbar_links = None + + return { + 'layout': layout, + 'title': _("Occupancy"), + 'occupancy': occupancy, + 'resource': self, + 'start': sedate.to_timezone(start, self.timezone).date(), + 'end': sedate.to_timezone(end, self.timezone).date(), + 'count': count, + }