Skip to content

Commit

Permalink
Create Reservation Rate Excel report
Browse files Browse the repository at this point in the history
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
Show file tree
Hide file tree
Showing 4 changed files with 369 additions and 1 deletion.
1 change: 1 addition & 0 deletions reports/api/__init__.py
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
310 changes: 310 additions & 0 deletions reports/api/reservation_rate.py
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'
56 changes: 56 additions & 0 deletions reports/tests/test_reservation_rate.py
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
3 changes: 2 additions & 1 deletion respa/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@
]

if 'reports' in settings.INSTALLED_APPS:
from reports.api import DailyReservationsReport, ReservationDetailsReport
from reports.api import DailyReservationsReport, ReservationDetailsReport, ReservationRateReport
urlpatterns.extend([
path('reports/daily_reservations/', DailyReservationsReport.as_view(), name='daily-reservations-report'),
path('reports/reservation_details/', ReservationDetailsReport.as_view(), name='reservation-details-report'),
path('reports/reservation_rate/', ReservationRateReport.as_view(), name='reservation-rate-report'),
])

if settings.RESPA_PAYMENTS_ENABLED:
Expand Down

0 comments on commit c65626e

Please sign in to comment.