forked from City-of-Helsinki/respa
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Create Reservation Rate Excel report
Add new endpoint where you can download an excel report that contains a separate sheet for each unit. Each sheet will have a summary that contains unit name, time filters, reservation rate. Below summary a list of resources with their sums of reserved time per each resource. Below this will be listings of reservation details per resource. The endpoint will take parameters that will be used for filtering the queries. Required filters are: list of units, begin date and end date. Times are optional, but will default to 08:00 and 16:00. Create serializers for the purpose of having a simple top-down data structure. Use the BaseReport base class for the new view. Create a custom excel renderer.
- Loading branch information
Kevin Seestrand
committed
Mar 2, 2022
1 parent
6c3adf1
commit c65626e
Showing
4 changed files
with
369 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
from .daily_reservations import DailyReservationsReport # noqa | ||
from .reservation_details import ReservationDetailsReport # noqa | ||
from .reservation_rate import ReservationRateReport |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,310 @@ | ||
import datetime, io, xlsxwriter | ||
|
||
from django.utils import timezone | ||
from rest_framework import renderers, generics, serializers | ||
from rest_framework.exceptions import NotFound, ValidationError | ||
from rest_framework.response import Response | ||
|
||
from .base import BaseReport | ||
from resources.models import Reservation, Resource, Unit | ||
|
||
|
||
class ReservationRateReportExcelRenderer(renderers.BaseRenderer): | ||
media_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' | ||
format = 'xlsx' | ||
charset = None | ||
render_style = 'binary' | ||
|
||
def _hour_min_string(self, time1, time2=None): | ||
""" | ||
Converts decimal times into hours and minutes | ||
and formats them into string like so: 'Xh XXmin' | ||
or 'Xh XXmin / Xh XXmin' | ||
:param time1: decimal time float | ||
:param time2: decimal time float | ||
:rtype: string | ||
""" | ||
|
||
hours1 = int(time1) | ||
mins1 = int(round((time1 * 60) % 60)) | ||
|
||
if not time2: | ||
return f"{hours1}h {mins1}min" | ||
|
||
hours2 = int(time2) | ||
mins2 = int(round((time2 * 60) % 60)) | ||
|
||
return f"{hours1}h {mins1}min / {hours2}h {mins2}min" | ||
|
||
def _data_to_representation(self, data): | ||
""" | ||
Compiles data to be presentation ready for renderer. | ||
""" | ||
|
||
begin = data["begin"] | ||
end = data["end"] | ||
del data["begin"] | ||
del data["end"] | ||
|
||
day_period = f"{begin.strftime('%d.%m.%Y')} - {end.strftime('%d.%m.%Y')}" | ||
time_period = f"{begin.strftime('%H.%M')} - {end.strftime('%H.%M')}" | ||
|
||
data["day_period"] = day_period | ||
data["time_period"] = time_period | ||
|
||
for unit in data["units"]: | ||
unit_reserved_time_sum_decimal = 0.00 | ||
|
||
for resource in unit["resources"]: | ||
resource_reserved_time_sum_decimal = 0.00 | ||
|
||
for reservation in resource["reservations"]: | ||
resource_reserved_time_sum_decimal += reservation["reserved_time"] | ||
del reservation["reserved_time"] | ||
|
||
unit_reserved_time_sum_decimal += resource_reserved_time_sum_decimal | ||
resource["reserved_time_sum"] = self._hour_min_string(resource_reserved_time_sum_decimal) | ||
|
||
# Calculate max reservable time for the whole unit. | ||
day_diff = end - begin; day_diff = day_diff.days | ||
hour_diff = end.hour - begin.hour | ||
minute_sum = (begin.minute + end.minute) / 60 | ||
unit_max_reservable_time_decimal = ( | ||
(hour_diff + minute_sum) | ||
* day_diff | ||
* len(unit["resources"]) | ||
) | ||
|
||
unit_reservation_rate = self._hour_min_string( | ||
unit_reserved_time_sum_decimal, | ||
time2=unit_max_reservable_time_decimal | ||
) | ||
|
||
unit["unit_reservation_rate"] = unit_reservation_rate | ||
|
||
return data | ||
|
||
def render(self, data, media_type=None, renderer_context=None): | ||
""" | ||
Renders a separate sheet for each unit. Each sheet will contain | ||
a summary of unit info, reservation rates and sums of reserved | ||
time per resource. Below the summary will be listings of | ||
reservation details per resource. | ||
Data will mostly be in string types because it is compiled | ||
to a ready-to-present format for easier rendering. See | ||
function: _data_to_representation. | ||
Returns an Excel file in xlsx format. | ||
:rtype: bytes | ||
""" | ||
|
||
data = self._data_to_representation({ | ||
"units": data, | ||
"begin": renderer_context["begin"], | ||
"end": renderer_context["end"] | ||
}) | ||
|
||
output = io.BytesIO() | ||
workbook = xlsxwriter.Workbook(output) | ||
header_format = workbook.add_format({'bold': True}) | ||
|
||
summary_headers = [ | ||
(0, 0, "Kiinteistö"), | ||
(0, 1, "Ajankohta"), | ||
(0, 2, "Aikaväli"), | ||
(0, 3, "Kiinteistön varausaste"), | ||
(3, 0, "Resurssin nimi"), | ||
(3, 1, "Tilatyyppi"), | ||
(3, 2, "Varatut ajat yhteensä"), | ||
] | ||
|
||
reservation_headers = [ | ||
(0, "Varaajan nimi"), | ||
(1, "Varauksen nimi"), | ||
(2, "Alkoi"), | ||
(3, "Päättyi"), | ||
] | ||
|
||
row_pos = 0 | ||
|
||
for unit in data["units"]: | ||
sheet = workbook.add_worksheet(unit["name"]) | ||
|
||
sheet.set_column(0, 4, 40) # Sets width of columns | ||
|
||
for header in summary_headers: | ||
sheet.write(*header, header_format) | ||
|
||
sheet.write(1, 0, unit["name"]) | ||
sheet.write(1, 1, data["day_period"]) | ||
sheet.write(1, 2, data["time_period"]) | ||
sheet.write(1, 3, unit["unit_reservation_rate"]) | ||
|
||
row_pos += 4 | ||
|
||
for resource in unit["resources"]: | ||
sheet.write(row_pos, 0, resource["name"]) | ||
sheet.write(row_pos, 1, resource["type"]) | ||
sheet.write(row_pos, 2, resource["reserved_time_sum"]) | ||
row_pos += 1 | ||
|
||
row_pos += 2 | ||
|
||
for resource in unit["resources"]: | ||
sheet.write(row_pos, 0, resource["name"], header_format) | ||
sheet.write(row_pos, 1, resource["type"], header_format) | ||
|
||
row_pos += 1 | ||
|
||
for header in reservation_headers: | ||
col, text = header | ||
sheet.write(row_pos, col, text, header_format) | ||
|
||
row_pos += 1 | ||
|
||
for reservation in resource["reservations"]: | ||
sheet.write(row_pos, 0, reservation["reserver_name"]) | ||
sheet.write(row_pos, 1, reservation["event_subject"]) | ||
sheet.write(row_pos, 2, reservation["begin"]) | ||
sheet.write(row_pos, 3, reservation["end"]) | ||
row_pos += 1 | ||
|
||
row_pos += 4 | ||
|
||
row_pos = 0 | ||
|
||
workbook.close() | ||
|
||
return output.getvalue() | ||
|
||
|
||
class ReservationSerializer(serializers.ModelSerializer): | ||
reserved_time = serializers.SerializerMethodField() | ||
begin = serializers.SerializerMethodField() | ||
end = serializers.SerializerMethodField() | ||
|
||
class Meta: | ||
model = Reservation | ||
fields = ( | ||
"begin", | ||
"end", | ||
"reserver_name", | ||
"event_subject", | ||
"reserved_time", | ||
) | ||
|
||
def get_reserved_time(self, obj): | ||
return (obj.end - obj.begin) / datetime.timedelta(hours=1) | ||
|
||
def get_begin(self, obj): | ||
return timezone.localtime(obj.begin).strftime("%d.%m.%Y %H.%M") | ||
|
||
def get_end(self, obj): | ||
return timezone.localtime(obj.end).strftime("%d.%m.%Y %H.%M") | ||
|
||
|
||
class ResourceSerializer(serializers.ModelSerializer): | ||
reservations = serializers.SerializerMethodField() | ||
type = serializers.CharField(source="type.name") | ||
|
||
class Meta: | ||
model = Resource | ||
fields = ( | ||
"type", | ||
"name", | ||
"reservations" | ||
) | ||
|
||
def get_reservations(self, obj): | ||
begin = self.context["begin"] | ||
end = self.context["end"] | ||
|
||
qs = ( | ||
Reservation.objects | ||
.filter(resource=obj) | ||
.filter( | ||
begin__range=(begin, end) | ||
) | ||
.order_by("-begin") | ||
) | ||
serializer = ReservationSerializer(qs, many=True) | ||
|
||
return serializer.data | ||
|
||
|
||
class UnitSerializer(serializers.ModelSerializer): | ||
resources = serializers.SerializerMethodField() | ||
|
||
class Meta: | ||
model = Unit | ||
fields = ( | ||
"name", | ||
"resources" | ||
) | ||
|
||
def get_resources(self, obj): | ||
qs = Resource.objects.filter(unit=obj).order_by("type") | ||
|
||
serializer = ResourceSerializer(qs, many=True, context=self.context) | ||
|
||
return serializer.data | ||
|
||
|
||
class ReservationRateReport(BaseReport): | ||
serializer_class = UnitSerializer | ||
renderer_classes = (ReservationRateReportExcelRenderer,) | ||
|
||
def get_queryset(self): | ||
return Unit.objects.all() | ||
|
||
def filter_queryset(self, queryset): | ||
params = self.request.query_params | ||
|
||
units = params.getlist("units") | ||
start_date = params.get("start_date") | ||
end_date = params.get("end_date") | ||
start_time = params.get("start_time", "08:00") | ||
end_time = params.get("end_time", "16:00") | ||
|
||
if not units: | ||
raise NotFound("Missing unit id(s)") | ||
|
||
if not start_date or not end_date: | ||
raise NotFound("Missing start date or end date") | ||
|
||
try: | ||
begin = datetime.datetime.strptime( | ||
f"{start_date} {start_time}", "%Y-%m-%d %H:%M" | ||
) | ||
end = datetime.datetime.strptime( | ||
f"{end_date} {end_time}", "%Y-%m-%d %H:%M" | ||
) | ||
except Exception as e: | ||
raise ValidationError("Dates be in Y-m-d format and times must be in H:M format") | ||
|
||
if begin > end: | ||
raise ValidationError("End time must be after begin time") | ||
|
||
self._begin = begin | ||
self._end = end | ||
|
||
return queryset.filter(id__in=units) | ||
|
||
def get_serializer_context(self): | ||
context = super().get_serializer_context() | ||
context['begin'] = self._begin | ||
context['end'] = self._end | ||
return context | ||
|
||
def get_renderer_context(self): | ||
context = super().get_renderer_context() | ||
if hasattr(self, '_begin') and hasattr(self, '_end'): | ||
context['begin'] = self._begin | ||
context['end'] = self._end | ||
return context | ||
|
||
def get_filename(self, request, validated_data): | ||
return 'varaus_aste_raportti.xlsx' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
import pytest | ||
from resources.models import Unit, Resource, Reservation | ||
from resources.tests.conftest import * | ||
|
||
url = '/reports/reservation_rate/' | ||
|
||
|
||
@pytest.fixture | ||
def reservation(resource_in_unit, user): | ||
return Reservation.objects.create( | ||
resource=resource_in_unit, | ||
begin='2015-04-04T09:00:00+02:00', | ||
end='2015-04-04T10:00:00+02:00', | ||
user=user, | ||
reserver_name='John Smith', | ||
event_subject="John's welcome party", | ||
) | ||
|
||
def check_valid_response(response, reservation): | ||
headers = response._headers | ||
assert headers['content-type'][1] == 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' | ||
assert headers['content-disposition'][1].endswith('.xlsx') | ||
content = str(response.content) | ||
|
||
assert len(content) > 0 | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_get_reservation_rate_report(api_client, reservation): | ||
response = api_client.get( | ||
f"{url}?units={reservation.resource.unit.id}&start_date=2015-04-01&end_date=2015-04-06&start_time=08:00&end_time=16:00" | ||
) | ||
assert response.status_code == 200 | ||
|
||
check_valid_response(response, reservation) | ||
|
||
|
||
@pytest.mark.django_db | ||
def test_reservation_rate_filter_errors(api_client, test_unit, reservation, resource_in_unit): | ||
response = api_client.get( | ||
f"{url}?&start_date=2015-04-01&end_date=2015-04-06&start_time=08:00&end_time=16:00" | ||
) | ||
# missing unit ids | ||
assert response.status_code == 404 | ||
|
||
# missing start date or end date | ||
response = api_client.get( | ||
f"{url}?&start_date=2015-04-01&end_date=2015-04-06&start_time=08:00" | ||
) | ||
assert response.status_code == 404 | ||
|
||
# incorrect datetime formats | ||
response = api_client.get( | ||
f"{url}?units={reservation.resource.unit.id}&start_date=3-04-01&end_date=2015-04-06&start_time=08:00&end_time=16:00" | ||
) | ||
assert response.status_code == 400 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters