MAKENTNU/web

View on GitHub
src/make_queue/views/reservation.py

Summary

Maintainability
B
5 hrs
Test Coverage
from abc import ABC
from math import ceil
 
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.generic import FormView, ListView, TemplateView
from django_hosts import reverse
 
from news.models import TimePlace
from util.locale_utils import timedelta_to_hours
from util.logging_utils import log_request_exception
from util.view_utils import QueryParameterFormMixin
from .machine import MachineRelatedViewMixin
from ..forms.reservation import ReservationFindFreeSlotsForm, ReservationForm, ReservationListQueryForm
from ..models.machine import Machine, MachineType
from ..models.reservation import Reservation, ReservationRule
from ..templatetags.reservation_extra import calendar_url_reservation, can_change_reservation
 
 
TODO found
# TODO: rewrite this whole view (and everything that uses it), so that it's more extendable,
# and makes more use of the functionality of forms and Django's `CreateView` and `UpdateView`
class ReservationCreateOrUpdateView(TemplateView, ABC):
"""Base abstract class for the reservation create or change view."""
template_name = 'make_queue/reservation_form.html'
 
new_reservation: bool
reservation: Reservation = None
 
Function `get_error_message` has a Cognitive Complexity of 12 (exceeds 5 allowed). Consider refactoring.
def get_error_message(self, form, reservation):
"""
Generates the correct error message for the given form.
 
:param reservation: The reservation to generate an error message for
:param form: The form to generate an error message for
:return: The error message
"""
if not reservation.is_within_allowed_period() and not (reservation.special or reservation.event):
num_days = reservation.FUTURE_LIMIT.days
return ngettext(
'Reservations can only be made {num_days} day ahead of time',
'Reservations can only be made {num_days} days ahead of time',
num_days
).format(num_days=num_days)
if self.request.user.has_perm('make_queue.can_create_event_reservation') and form.cleaned_data["event"]:
return _("The time slot or event is no longer available")
if reservation.check_machine_out_of_order():
return _("The machine is out of order")
if reservation.check_machine_maintenance():
return _("The machine is under maintenance")
if reservation.start_time == reservation.end_time:
Avoid too many `return` statements within this function.
return _("The reservation cannot start and end at the same time")
if not ReservationRule.covered_rules(reservation.start_time, reservation.end_time,
reservation.machine.machine_type):
Avoid too many `return` statements within this function.
return _("It is not possible to reserve the machine during these hours. Check the rules for when the machine is reservable")
if not reservation.quota_can_create_reservation():
Avoid too many `return` statements within this function.
return _("The reservation exceeds your quota")
if reservation.check_start_time_after_end_time():
Avoid too many `return` statements within this function.
return _("The start time can't be after the end time")
if reservation.starts_before_now():
Avoid too many `return` statements within this function.
return _("The reservation can't start in the past")
Avoid too many `return` statements within this function.
return _("The time slot is not available")
 
def validate_and_save(self, reservation, form):
"""
Tries to validate and save the given reservation.
 
:param reservation: The reservation to validate and save
:param form: The form used to create/change the reservation
:return: Either a redirect to the new/changed reservation in the calendar or an error message indicating why
the reservation cannot be validated
"""
if not reservation.validate():
# Hack to "simulate" `ReservationUpdateView`
self.reservation = reservation
context_data = self.get_context_data()
context_data["error"] = self.get_error_message(form, reservation)
return render(self.request, self.template_name, context_data)
 
reservation.save()
return redirect(calendar_url_reservation(reservation))
 
Function `get_context_data` has a Cognitive Complexity of 8 (exceeds 5 allowed). Consider refactoring.
def get_context_data(self, **kwargs):
"""
Creates the context data required for the make reservation template.
If reservation is given as a keyword argument, the view is made for that reservation.
 
:param kwargs: The request arguments for creating the context data
:return: The context data needed for the template
"""
 
machine_queryset = Machine.objects.visible_to(self.request.user).default_order_by()
# Always include a list of events and machines to populate the dropdown lists
context_data = {
"new_reservation": self.new_reservation,
"event_timeplaces": list(TimePlace.objects.filter(end_time__gte=timezone.localtime())),
"machine_types": [
machine_type
for machine_type in
MachineType.objects.default_order_by().prefetch_machines(
machine_queryset=machine_queryset, machines_attr_name='instances',
)
if machine_type.can_user_use(self.request.user)
],
"maximum_days_in_advance": Reservation.FUTURE_LIMIT.days,
}
 
# If we are updating an existing reservation, populate the information relevant to that reservation
if not self.new_reservation or self.reservation:
# noinspection PyUnresolvedReferences
reservation = self.reservation # Defined in `ReservationUpdateView`
context_data["start_time"] = reservation.start_time
context_data["reservation"] = reservation
context_data["end_time"] = reservation.end_time
context_data["selected_machine"] = reservation.machine
context_data["event"] = reservation.event
context_data["special"] = reservation.special
context_data["special_text"] = reservation.special_text
context_data["comment"] = reservation.comment
context_data["can_change_start_time"] = reservation.can_change_start_time()
context_data["can_change_end_time"] = reservation.can_change_end_time()
# Otherwise populate with default information given to the view
else:
if hasattr(self, 'machine'):
# Set in `ReservationCreateView`
selected_machine = self.machine
else:
# `machine_pk` is only set in `test_get_context_data_non_reservation()` 🙃🔥
selected_machine = get_object_or_404(Machine, pk=kwargs['machine_pk'])
context_data["selected_machine"] = selected_machine
if "start_time" in kwargs:
context_data["start_time"] = kwargs["start_time"]
context_data["can_change_start_time"] = True
context_data["can_change_end_time"] = True
 
return context_data
 
def dispatch(self, request, *args, **kwargs):
"""
If the request is a post request use the handle_post method,
otherwise use the default method of the template view.
 
:param request: The HTTP request
:return: HTTP response
"""
if request.method == 'POST':
return self.handle_post(request, **kwargs)
return super().dispatch(request, *args, **kwargs)
 
def handle_post(self, request, **kwargs):
"""
Handles and validates update requests.
 
:param request: The HTTP request
"""
try:
form = ReservationForm(request.POST)
if form.is_valid():
return self.form_valid(form, **kwargs)
except Exception as e:
log_request_exception("Validating reservation failed.", e, request)
return self.get(request, **kwargs)
 
 
class ReservationCreateView(PermissionRequiredMixin, MachineRelatedViewMixin, ReservationCreateOrUpdateView):
"""View for creating a new reservation."""
new_reservation = True
 
def has_permission(self):
return self.machine.can_user_use(self.request.user)
 
def form_valid(self, form, **kwargs):
"""
Creates a reservation from a valid ``ReservationForm``.
 
:param form: The valid reservation form
:return: HTTP response
"""
reservation = Reservation(
start_time=form.cleaned_data["start_time"],
end_time=form.cleaned_data["end_time"],
user=self.request.user,
machine=form.cleaned_data["machine"],
comment=form.cleaned_data["comment"],
)
 
if form.cleaned_data["event"]:
reservation.event = form.cleaned_data["event"]
 
if form.cleaned_data["special"]:
reservation.special = True
reservation.special_text = form.cleaned_data["special_text"]
 
return self.validate_and_save(reservation, form)
 
 
class ReservationUpdateView(ReservationCreateOrUpdateView):
"""View for changing a reservation (Cannot be UpdateView due to the abstract inheritance of reservations)."""
new_reservation = False
reservation: Reservation
 
@property
def success_url(self):
owner_param = urlencode({'owner': ReservationListQueryForm.Owner.ME})
return f"{reverse('reservation_list')}?{owner_param}"
 
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
reservation_pk = self.kwargs['pk']
self.reservation = get_object_or_404(Reservation, pk=reservation_pk)
 
def dispatch(self, request, *args, **kwargs):
"""
Redirects the user to its reservation page if the given reservation cannot be changed.
 
:param request: The HTTP request
"""
reservation = self.reservation
# User must be able to change the given reservation
if can_change_reservation(reservation, request.user):
return super().dispatch(request, *args, **kwargs)
else:
return HttpResponseRedirect(self.success_url)
 
def form_valid(self, form, **kwargs):
"""
Handles updating the reservation if the form is valid, otherwise render the form view with an error code.
 
:param form: The valid form
:return: HTTP Response
"""
reservation = self.reservation
# The user is not allowed to change the machine for a reservation
if reservation.machine != form.cleaned_data["machine"]:
return HttpResponseRedirect(self.success_url)
 
# If the reservation has begun, the user is not allowed to change the start time
if reservation.start_time < timezone.now() and reservation.start_time != form.cleaned_data["start_time"]:
return HttpResponseRedirect(self.success_url)
 
reservation.comment = form.cleaned_data["comment"]
 
reservation.start_time = form.cleaned_data["start_time"]
reservation.end_time = form.cleaned_data["end_time"]
if reservation.event:
reservation.event = form.cleaned_data["event"]
 
if reservation.special:
reservation.special_text = form.cleaned_data["special_text"]
 
return self.validate_and_save(reservation, form)
 
 
class ReservationListView(PermissionRequiredMixin, QueryParameterFormMixin, ListView):
"""View for listing either the user's reservations or MAKE's."""
model = Reservation
form_class = ReservationListQueryForm
template_name = 'make_queue/reservation_list.html'
context_object_name = 'reservations'
extra_context = {
'ReservationOwner': ReservationListQueryForm.Owner,
}
 
def has_permission(self):
self.validate_query_params()
if self._query_param_errors:
return self.form_invalid()
 
Error: invalid syntax (, line 273)
match self.query_params['owner']:
case ReservationListQueryForm.Owner.ME:
return self.request.user.is_authenticated
case ReservationListQueryForm.Owner.MAKE:
return self.user_has_admin_perms()
 
def user_has_admin_perms(self):
return self.request.user.has_perm('make_queue.can_create_event_reservation')
 
def get_queryset(self):
non_admin_reservations_query = Q(event=None, special=False)
 
match self.query_params['owner']:
case ReservationListQueryForm.Owner.ME:
filter_query = Q(user=self.request.user)
if not self.user_has_admin_perms():
filter_query &= non_admin_reservations_query
queryset = Reservation.objects.filter(filter_query)
 
case ReservationListQueryForm.Owner.MAKE:
queryset = Reservation.objects.exclude(non_admin_reservations_query)
 
# noinspection PyUnboundLocalVariable
return queryset.order_by('-end_time', '-start_time')
 
def get_context_data(self, **kwargs):
return super().get_context_data(**{
'reservations_owned_by_MAKE': self.query_params['owner'] == ReservationListQueryForm.Owner.MAKE,
'has_admin_perms': self.user_has_admin_perms(),
**kwargs,
})
 
 
class ReservationFindFreeSlotsView(LoginRequiredMixin, FormView):
"""
View to find free time slots for reservations.
"""
form_class = ReservationFindFreeSlotsForm
template_name = 'make_queue/reservation_find_free_slots.html'
 
def get_initial(self):
return {'machine_type': MachineType.objects.first()}
 
@staticmethod
def format_period(machine, start_time, end_time):
"""
Formats a time period for the context.
"""
return {
'machine': machine,
'start_time': start_time,
'end_time': end_time,
'duration': ceil(timedelta_to_hours(end_time - start_time)),
}
 
def get_periods(self, machine: Machine, required_time):
"""
Finds all future periods for the given machine with a minimum length.
 
:param machine: The machine to get periods for
:param required_time: The minimum required time for the period
:return: A list of periods
"""
periods = []
reservations = list(
machine.reservations.filter(end_time__gte=timezone.now()).order_by('start_time')
)
 
# Find all periods between reservations
for period_start, period_end in zip(reservations, reservations[1:]):
duration = timedelta_to_hours(
period_end.start_time - period_start.end_time)
if duration >= required_time:
periods.append(self.format_period(
machine,
period_start.end_time,
period_end.start_time
))
 
# Add remaining time after last reservation
if reservations:
periods.append(self.format_period(
machine, reservations[-1].end_time,
timezone.now() + Reservation.FUTURE_LIMIT
))
# If the machine is not reserved anytime in the future, we include the
# whole allowed period
else:
periods.append(self.format_period(
machine,
timezone.now(),
timezone.now() + Reservation.FUTURE_LIMIT
))
return periods
 
def form_valid(self, form):
"""
Renders the page with free slots in respect to the valid form.
 
:param form: A valid ``ReservationFindFreeSlotsForm`` form
:return: A HTTP response rendering the page with the found free slots
"""
context = self.get_context_data()
 
# Time should be expressed in hours
required_time = form.cleaned_data['hours'] + form.cleaned_data['minutes'] / 60
 
periods = []
for machine in form.cleaned_data['machine_type'].machines.all():
if not machine.get_status() == Machine.Status.OUT_OF_ORDER:
periods.extend(self.get_periods(machine, required_time))
 
# Periods in the near future is more interesting than in the distant
# future
periods.sort(key=lambda period: period['start_time'])
 
context.update({
'free_slots': periods,
})
return self.render_to_response(context)