From ac24e2988af8d3d5c65dc94e1806ed9f4a9f5ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juha=20Yrj=C3=B6l=C3=A4?= Date: Sat, 13 Jul 2019 13:59:46 +0200 Subject: [PATCH] reports: Add new list-style reservation report type --- reports/api/__init__.py | 1 + reports/api/reservation_base.py | 80 +++++++++++++++++++++++ reports/api/reservation_details.py | 79 ++-------------------- reports/api/reservation_list.py | 74 +++++++++++++++++++++ reports/tests/test_reservation_details.py | 24 +++++-- reports/urls.py | 9 +++ requirements.txt | 2 +- respa/urls.py | 9 +-- 8 files changed, 194 insertions(+), 84 deletions(-) create mode 100644 reports/api/reservation_base.py create mode 100644 reports/api/reservation_list.py create mode 100644 reports/urls.py diff --git a/reports/api/__init__.py b/reports/api/__init__.py index f65458694..aadb9e3c4 100644 --- a/reports/api/__init__.py +++ b/reports/api/__init__.py @@ -1,2 +1,3 @@ from .daily_reservations import DailyReservationsReport # noqa from .reservation_details import ReservationDetailsReport # noqa +from .reservation_list import ReservationListReport # noqa diff --git a/reports/api/reservation_base.py b/reports/api/reservation_base.py new file mode 100644 index 000000000..20b4ee598 --- /dev/null +++ b/reports/api/reservation_base.py @@ -0,0 +1,80 @@ +import io + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import exceptions, serializers + +from caterings.models import CateringOrder +from resources.models import Reservation, Resource +from resources.api.reservation import ReservationSerializer, ReservationViewSet, ReservationCacheMixin + +from .base import DocxRenderer, BaseReport + + +class ReservationDocxRenderer(DocxRenderer): + def render(self, data, media_type=None, renderer_context=None): + document = self.create_document() + + # Prefetch some objects + resource_ids = [rv['resource'] for rv in data] + self.resources = {x.id: x for x in Resource.objects.filter(id__in=resource_ids).select_related('unit')} + catering_rv_ids = [rv['id'] for rv in data if rv.get('has_catering_order')] + catering_qs = CateringOrder.objects.filter(reservation__in=catering_rv_ids).prefetch_related('order_lines')\ + .prefetch_related('order_lines__product') + self.catering_orders = {x.reservation_id: x for x in catering_qs} + + self.render_all(data, document, renderer_context) + + output = io.BytesIO() + document.save(output) + + return output.getvalue() + + +class ReservationReport(BaseReport, ReservationCacheMixin): + queryset = ReservationViewSet.queryset.current() + serializer_class = ReservationSerializer + filter_backends = ReservationViewSet.filter_backends + filter_class = ReservationViewSet.filter_class + + def get_queryset(self): + queryset = super().get_queryset() + user = self.request.user + queryset = queryset.filter(resource__in=Resource.objects.visible_for(user)) + return queryset + + def get_serializer(self, *args, **kwargs): + if 'data' not in kwargs and len(args) == 1: + # It's a read operation + instance_or_page = args[0] + if isinstance(instance_or_page, Reservation): + self._page = [instance_or_page] + else: + self._page = instance_or_page + + return super().get_serializer(*args, **kwargs) + + def get_serializer_context(self, *args, **kwargs): + context = super().get_serializer_context() + if not hasattr(self, '_page'): + return context + context.update(self._get_cache_context()) + return context + + def filter_queryset(self, queryset): + params = self.request.query_params + reservation_id = params.get('reservation') + if reservation_id: + try: + Reservation.objects.get(id=reservation_id) + except Reservation.DoesNotExist: + raise exceptions.NotFound( + serializers.PrimaryKeyRelatedField.default_error_messages.get('does_not_exist').format(pk_value=reservation_id) + ) + queryset = queryset.filter(id=reservation_id) + else: + queryset = super().filter_queryset(queryset) + + if queryset.count() > 1000: + raise exceptions.NotAcceptable(_("Too many (> 1000) reservations to return")) + + return queryset diff --git a/reports/api/reservation_details.py b/reports/api/reservation_details.py index 04399cbec..4f43af3c4 100644 --- a/reports/api/reservation_details.py +++ b/reports/api/reservation_details.py @@ -1,20 +1,15 @@ -import io - from django.utils.translation import get_language, pgettext_lazy, ugettext_lazy as _ -from django.utils import formats from django.utils.timezone import localtime -from rest_framework import exceptions, serializers from docx.shared import Cm -from caterings.models import CateringOrder -from resources.models import Reservation, Resource +from resources.models import Reservation from resources.models.utils import format_dt_range -from resources.api.reservation import ReservationSerializer, ReservationViewSet, ReservationCacheMixin -from .base import BaseReport, DocxRenderer + +from .reservation_base import ReservationDocxRenderer, ReservationReport from .utils import iso_to_dt -class ReservationDetailsDocxRenderer(DocxRenderer): +class ReservationDetailsDocxRenderer(ReservationDocxRenderer): def render_one(self, document, reservation, renderer_context): begin = localtime(iso_to_dt(reservation['begin'])) end = localtime(iso_to_dt(reservation['end'])) @@ -70,77 +65,15 @@ def render_one(self, document, reservation, renderer_context): row_cells[0].text = _('Invoicing data') row_cells[1].text = catering_order.invoicing_data - def render(self, data, media_type=None, renderer_context=None): - document = self.create_document() - - # Prefetch some objects - resource_ids = [rv['resource'] for rv in data] - self.resources = {x.id: x for x in Resource.objects.filter(id__in=resource_ids).select_related('unit')} - catering_rv_ids = [rv['id'] for rv in data if rv.get('has_catering_order')] - catering_qs = CateringOrder.objects.filter(reservation__in=catering_rv_ids).prefetch_related('order_lines')\ - .prefetch_related('order_lines__product') - self.catering_orders = {x.reservation_id: x for x in catering_qs} - + def render_all(self, data, document, renderer_context): for idx, rv in enumerate(data): if idx != 0: document.add_page_break() self.render_one(document, rv, renderer_context) - output = io.BytesIO() - document.save(output) - - return output.getvalue() - -class ReservationDetailsReport(BaseReport, ReservationCacheMixin): - queryset = ReservationViewSet.queryset.current() - serializer_class = ReservationSerializer +class ReservationDetailsReport(ReservationReport): renderer_classes = (ReservationDetailsDocxRenderer,) - filter_backends = ReservationViewSet.filter_backends - filter_class = ReservationViewSet.filter_class - - def get_queryset(self): - queryset = super().get_queryset() - user = self.request.user - queryset = queryset.filter(resource__in=Resource.objects.visible_for(user)) - return queryset - - def get_serializer(self, *args, **kwargs): - if 'data' not in kwargs and len(args) == 1: - # It's a read operation - instance_or_page = args[0] - if isinstance(instance_or_page, Reservation): - self._page = [instance_or_page] - else: - self._page = instance_or_page - - return super().get_serializer(*args, **kwargs) - - def get_serializer_context(self, *args, **kwargs): - context = super().get_serializer_context() - if not hasattr(self, '_page'): - return context - context.update(self._get_cache_context()) - return context - - def filter_queryset(self, queryset): - params = self.request.query_params - reservation_id = params.get('reservation') - if reservation_id: - try: - Reservation.objects.get(id=reservation_id) - except Reservation.DoesNotExist: - raise exceptions.NotFound( - serializers.PrimaryKeyRelatedField.default_error_messages.get('does_not_exist').format(pk_value=reservation_id) - ) - queryset = queryset.filter(id=reservation_id) - else: - queryset = super().filter_queryset(queryset) - - if queryset.count() > 1000: - raise exceptions.NotAcceptable(_("Too many (> 1000) reservations to return")) - - return queryset def get_filename(self, request, data): return '%s.docx' % (_('reservation-details'),) diff --git a/reports/api/reservation_list.py b/reports/api/reservation_list.py new file mode 100644 index 000000000..51c14f1b8 --- /dev/null +++ b/reports/api/reservation_list.py @@ -0,0 +1,74 @@ +from django.utils.translation import ugettext_lazy as _ +from django.utils.timezone import localtime +from django.utils import formats +from docx.shared import Cm + +from .reservation_base import ReservationDocxRenderer, ReservationReport +from .utils import iso_to_dt + + +class ReservationListDocxRenderer(ReservationDocxRenderer): + def render_one(self, document, reservation, renderer_context): + begin = localtime(iso_to_dt(reservation['begin'])) + end = localtime(iso_to_dt(reservation['end'])) + + resource = self.resources[reservation['resource']] + time_str = '%s–%s' % tuple([formats.date_format(x, 'G.i') for x in (begin, end)]) + + table = self.day_table + if table is None: + table = self.day_table = document.add_table(rows=0, cols=0) + table.autofit = False + # Four columns, with the last one left empty for notes + for width in (Cm(4), Cm(10), Cm(2), Cm(2)): + table.add_column(width) + + row = table.add_row() + cells = row.cells + + p = cells[0].paragraphs[0] + run = p.add_run() + run.bold = True + run.text = time_str + + host_name = reservation.get('host_name') or reservation.get('reserver_name') + run = cells[0].add_paragraph(host_name).add_run() + # Add a paragraph as padding + cells[0].add_paragraph() + + p = cells[1].paragraphs[0] + run = p.add_run() + run.bold = True + run.text = '%s / %s' % (resource.name, resource.unit.name) + subject = reservation.get('event_subject') + if subject: + cells[1].add_paragraph(subject) + nr_participants = reservation.get('number_of_participants') + + cells[2].text = str(nr_participants) if nr_participants is not None else '' + + def render_all(self, data, document, renderer_context): + last_day = None + self.day_table = None + + for idx, rv in enumerate(data): + begin = localtime(iso_to_dt(rv['begin'])) + day = begin.date() + if day != last_day: + document.add_heading(formats.date_format(begin, r'D j.n.Y'), 3) + last_day = day + self.day_table = None + + self.render_one(document, rv, renderer_context) + + +class ReservationListReport(ReservationReport): + renderer_classes = (ReservationListDocxRenderer,) + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.order_by('begin', 'resource__unit__name', 'resource__name') + return queryset + + def get_filename(self, request, data): + return '%s.docx' % (_('reservation-list'),) diff --git a/reports/tests/test_reservation_details.py b/reports/tests/test_reservation_details.py index a800d3429..6ddd3e969 100644 --- a/reports/tests/test_reservation_details.py +++ b/reports/tests/test_reservation_details.py @@ -3,7 +3,8 @@ from resources.tests.conftest import * -list_url = '/reports/reservation_details/' +reservation_details_report_url = '/reports/reservation_details/' +reservation_list_report_url = '/reports/reservation_list/' @pytest.fixture @@ -39,7 +40,7 @@ def check_valid_response(response): @pytest.mark.django_db def test_get_reservation_details_report(api_client, reservation): - response = api_client.get(list_url + '?reservation=%s' % reservation.id) + response = api_client.get(reservation_details_report_url + '?reservation=%s' % reservation.id) assert response.status_code == 200 check_valid_response(response) @@ -47,10 +48,25 @@ def test_get_reservation_details_report(api_client, reservation): @pytest.mark.django_db def test_daily_reservations_filter_errors(api_client, test_unit, reservation, resource_in_unit): - response = api_client.get(list_url + '?reservation=592843752987', HTTP_ACCEPT_LANGUAGE='en') + response = api_client.get(reservation_details_report_url + '?reservation=592843752987', HTTP_ACCEPT_LANGUAGE='en') assert response.status_code == 404 assert 'does not exist' in str(response.data) - response = api_client.get(list_url + '?start=abc', HTTP_ACCEPT_LANGUAGE='en') + response = api_client.get(reservation_details_report_url + '?start=abc', HTTP_ACCEPT_LANGUAGE='en') + assert response.status_code == 400 + assert 'must be a timestamp in ISO' in str(response.data) + + +@pytest.mark.django_db +def test_get_reservation_list_report(api_client, reservation): + response = api_client.get(reservation_list_report_url) + assert response.status_code == 200 + + check_valid_response(response) + + +@pytest.mark.django_db +def test_reservations_list_report_filter_errors(api_client, test_unit, reservation, resource_in_unit): + response = api_client.get(reservation_list_report_url + '?start=abc', HTTP_ACCEPT_LANGUAGE='en') assert response.status_code == 400 assert 'must be a timestamp in ISO' in str(response.data) diff --git a/reports/urls.py b/reports/urls.py new file mode 100644 index 000000000..b23bd1d75 --- /dev/null +++ b/reports/urls.py @@ -0,0 +1,9 @@ +from .api import DailyReservationsReport, ReservationDetailsReport, ReservationListReport +from django.conf.urls import url + + +urlpatterns = [ + url(r'^daily_reservations/', DailyReservationsReport.as_view(), name='daily-reservations-report'), + url(r'^reservation_details/', ReservationDetailsReport.as_view(), name='reservation-details-report'), + url(r'^reservation_list/', ReservationListReport.as_view(), name='reservation-list-report'), +] diff --git a/requirements.txt b/requirements.txt index 20711bf68..775c8aa27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -70,7 +70,7 @@ pytest-cov==2.5.1 pytest-django==3.1.2 pytest==3.3.0 python-dateutil==2.6.1 # via arrow, delorean, freezegun, icalendar -python-docx==0.8.6 +python-docx==0.8.10 python3-openid==3.1.0 # via django-allauth pytz==2018.3 pyyaml==3.12 # via django-munigeo diff --git a/respa/urls.py b/respa/urls.py index 5ff149c6a..4a8322d30 100644 --- a/respa/urls.py +++ b/respa/urls.py @@ -45,12 +45,9 @@ ] if 'reports' in settings.INSTALLED_APPS: - from reports.api import DailyReservationsReport, ReservationDetailsReport - urlpatterns.extend([ - url(r'^reports/daily_reservations/', DailyReservationsReport.as_view(), name='daily-reservations-report'), - url(r'^reports/reservation_details/', ReservationDetailsReport.as_view(), name='reservation-details-report'), - ]) - + urlpatterns.append( + url(r'^reports/', include('reports.urls')) + ) if settings.DEBUG: urlpatterns.append(