From 59c63578d1b98807f57174f734b9a8138dd39c02 Mon Sep 17 00:00:00 2001 From: Dan Jacob Date: Thu, 5 Oct 2023 14:57:34 +0300 Subject: [PATCH 1/2] Resources: format utils.py Code formatted with Black and isort Refs: TTVA-169 --- resources/models/utils.py | 212 ++++++++++++++++++++++---------------- 1 file changed, 124 insertions(+), 88 deletions(-) diff --git a/resources/models/utils.py b/resources/models/utils.py index 7d65eb054..f7d5cd16b 100644 --- a/resources/models/utils.py +++ b/resources/models/utils.py @@ -1,24 +1,21 @@ +import arrow import base64 import csv import datetime -import struct -import time import io import logging - -import arrow +import struct +import time +import xlsxwriter from django.conf import settings -from django.utils import formats -from django.utils.translation import ungettext -from django.core.mail import EmailMultiAlternatives from django.contrib.sites.models import Site -from django.utils.translation import ugettext_lazy as _ -from django.utils import timezone +from django.core.mail import EmailMultiAlternatives +from django.utils import formats, timezone from django.utils.timezone import localtime +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext +from icalendar import Calendar, Event, vDatetime, vGeo, vText from rest_framework.reverse import reverse -from icalendar import Calendar, Event, vDatetime, vText, vGeo -import xlsxwriter - DEFAULT_LANG = settings.LANGUAGES[0][0] @@ -51,13 +48,13 @@ def get_translated(obj, attr): # Needed for slug fields populating def get_translated_name(obj): - return get_translated(obj, 'name') + return get_translated(obj, "name") def generate_id(): t = time.time() * 1000000 - b = base64.b32encode(struct.pack(">Q", int(t)).lstrip(b'\x00')).strip(b'=').lower() - return b.decode('utf8') + b = base64.b32encode(struct.pack(">Q", int(t)).lstrip(b"\x00")).strip(b"=").lower() + return b.decode("utf8") def time_to_dtz(time, date=None, arr=None): @@ -66,7 +63,9 @@ def time_to_dtz(time, date=None, arr=None): if date: return tz.localize(datetime.datetime.combine(date, time)) elif arr: - return tz.localize(datetime.datetime(arr.year, arr.month, arr.day, time.hour, time.minute)) + return tz.localize( + datetime.datetime(arr.year, arr.month, arr.day, time.hour, time.minute) + ) else: return None @@ -94,27 +93,41 @@ def humanize_duration(duration): """ hours = duration.days * 24 + duration.seconds // 3600 mins = duration.seconds // 60 % 60 - hours_string = ungettext('%(count)d hour', '%(count)d hours', hours) % {'count': hours} if hours else None - mins_string = ungettext('%(count)d minute', '%(count)d minutes', mins) % {'count': mins} if mins else None - return ' '.join(filter(None, (hours_string, mins_string))) + hours_string = ( + ungettext("%(count)d hour", "%(count)d hours", hours) % {"count": hours} + if hours + else None + ) + mins_string = ( + ungettext("%(count)d minute", "%(count)d minutes", mins) % {"count": mins} + if mins + else None + ) + return " ".join(filter(None, (hours_string, mins_string))) -notification_logger = logging.getLogger('respa.notifications') +notification_logger = logging.getLogger("respa.notifications") def send_respa_mail(email_address, subject, body, html_body=None, attachments=None): - if not getattr(settings, 'RESPA_MAILS_ENABLED', False): + if not getattr(settings, "RESPA_MAILS_ENABLED", False): return - from_address = (getattr(settings, 'RESPA_MAILS_FROM_ADDRESS', None) or - 'noreply@%s' % Site.objects.get_current().domain) + from_address = ( + getattr(settings, "RESPA_MAILS_FROM_ADDRESS", None) + or "noreply@%s" % Site.objects.get_current().domain + ) - notification_logger.info('Sending notification email to %s: "%s"' % (email_address, subject)) + notification_logger.info( + 'Sending notification email to %s: "%s"' % (email_address, subject) + ) text_content = body - msg = EmailMultiAlternatives(subject, text_content, from_address, [email_address], attachments=attachments) + msg = EmailMultiAlternatives( + subject, text_content, from_address, [email_address], attachments=attachments + ) if html_body: - msg.attach_alternative(html_body, 'text/html') + msg.attach_alternative(html_body, "text/html") msg.send() @@ -122,29 +135,29 @@ def generate_reservation_csv(reservations): output = io.StringIO() csv_writer = csv.writer(output) headers = [ - 'Unit', - 'Resource', - 'Begin time', - 'End time', - 'Created at', - 'User', - 'Comments', - 'Staff event', - 'State', + "Unit", + "Resource", + "Begin time", + "End time", + "Created at", + "User", + "Comments", + "Staff event", + "State", ] csv_writer.writerow([_(header) for header in headers]) for reservation in reservations: row_data = [ - reservation['unit'], - reservation['resource'], - localtime(reservation['begin']).replace(tzinfo=None), - localtime(reservation['end']).replace(tzinfo=None), - localtime(reservation['created_at']).replace(tzinfo=None), - reservation['user'] if reservation['user'] else '', - reservation['comments'] if reservation['comments'] else '', - reservation['staff_event'], - reservation['state'], + reservation["unit"], + reservation["resource"], + localtime(reservation["begin"]).replace(tzinfo=None), + localtime(reservation["end"]).replace(tzinfo=None), + localtime(reservation["created_at"]).replace(tzinfo=None), + reservation["user"] if reservation["user"] else "", + reservation["comments"] if reservation["comments"] else "", + reservation["staff_event"], + reservation["state"], ] csv_writer.writerow(row_data) return output.getvalue() @@ -166,46 +179,57 @@ def generate_reservation_xlsx(reservations, exclude_reservation_extra_fields=Fal :rtype: bytes """ - from resources.models import Reservation, RESERVATION_EXTRA_FIELDS + from resources.models import RESERVATION_EXTRA_FIELDS, Reservation output = io.BytesIO() workbook = xlsxwriter.Workbook(output) worksheet = workbook.add_worksheet() headers = [ - ('Unit', 30), - ('Resource', 30), - ('Begin time', 15), - ('End time', 15), - ('Created at', 15), - ('User', 30), - ('Comments', 30), - ('Staff event', 10), - ('State', 15), + ("Unit", 30), + ("Resource", 30), + ("Begin time", 15), + ("End time", 15), + ("Created at", 15), + ("User", 30), + ("Comments", 30), + ("Staff event", 10), + ("State", 15), ] if not exclude_reservation_extra_fields: for field in RESERVATION_EXTRA_FIELDS: headers.append((Reservation._meta.get_field(field).verbose_name, 20)) - header_format = workbook.add_format({'bold': True}) + header_format = workbook.add_format({"bold": True}) for column, header in enumerate(headers): worksheet.write(0, column, str(_(header[0])), header_format) worksheet.set_column(column, column, header[1]) - date_format = workbook.add_format({'num_format': 'dd.mm.yyyy hh:mm', 'align': 'left'}) + date_format = workbook.add_format( + {"num_format": "dd.mm.yyyy hh:mm", "align": "left"} + ) for row, reservation in enumerate(reservations, 1): - worksheet.write(row, 0, reservation['unit']) - worksheet.write(row, 1, reservation['resource']) - worksheet.write(row, 2, localtime(reservation['begin']).replace(tzinfo=None), date_format) - worksheet.write(row, 3, localtime(reservation['end']).replace(tzinfo=None), date_format) - worksheet.write(row, 4, localtime(reservation['created_at']).replace(tzinfo=None), date_format) - if 'user' in reservation: - worksheet.write(row, 5, reservation['user']) - if 'comments' in reservation: - worksheet.write(row, 6, reservation['comments']) - worksheet.write(row, 7, reservation['staff_event']) - worksheet.write(row, 8, reservation['state']) + worksheet.write(row, 0, reservation["unit"]) + worksheet.write(row, 1, reservation["resource"]) + worksheet.write( + row, 2, localtime(reservation["begin"]).replace(tzinfo=None), date_format + ) + worksheet.write( + row, 3, localtime(reservation["end"]).replace(tzinfo=None), date_format + ) + worksheet.write( + row, + 4, + localtime(reservation["created_at"]).replace(tzinfo=None), + date_format, + ) + if "user" in reservation: + worksheet.write(row, 5, reservation["user"]) + if "comments" in reservation: + worksheet.write(row, 6, reservation["comments"]) + worksheet.write(row, 7, reservation["staff_event"]) + worksheet.write(row, 8, reservation["state"]) for i, field in enumerate(RESERVATION_EXTRA_FIELDS, 9): if field in reservation: worksheet.write(row, i, reservation[field]) @@ -233,32 +257,42 @@ def create_datetime_days_from_now(days_from_now, exclude_extra_day=False): def localize_datetime(dt): - return formats.date_format(timezone.localtime(dt), 'DATETIME_FORMAT') + return formats.date_format(timezone.localtime(dt), "DATETIME_FORMAT") def format_dt_range(language, begin, end): - if language == 'fi': + if language == "fi": # ma 1.1.2017 klo 12.00 - begin_format = r'D j.n.Y \k\l\o G.i' + begin_format = r"D j.n.Y \k\l\o G.i" if begin.date() == end.date(): - end_format = 'G.i' - sep = '–' + end_format = "G.i" + sep = "–" else: end_format = begin_format - sep = ' – ' - - res = sep.join([formats.date_format(begin, begin_format), formats.date_format(end, end_format)]) + sep = " – " + + res = sep.join( + [ + formats.date_format(begin, begin_format), + formats.date_format(end, end_format), + ] + ) else: # default to English - begin_format = r'D j/n/Y G:i' + begin_format = r"D j/n/Y G:i" if begin.date() == end.date(): - end_format = 'G:i' - sep = '–' + end_format = "G:i" + sep = "–" else: end_format = begin_format - sep = ' – ' + sep = " – " - res = sep.join([formats.date_format(begin, begin_format), formats.date_format(end, end_format)]) + res = sep.join( + [ + formats.date_format(begin, begin_format), + formats.date_format(end, end_format), + ] + ) return res @@ -273,14 +307,16 @@ def build_reservations_ical_file(reservations): event = Event() begin_utc = timezone.localtime(reservation.begin, timezone.utc) end_utc = timezone.localtime(reservation.end, timezone.utc) - event['uid'] = 'respa_reservation_{}'.format(reservation.id) - event['dtstart'] = vDatetime(begin_utc) - event['dtend'] = vDatetime(end_utc) + event["uid"] = "respa_reservation_{}".format(reservation.id) + event["dtstart"] = vDatetime(begin_utc) + event["dtend"] = vDatetime(end_utc) unit = reservation.resource.unit - event['location'] = vText('{} {} {}'.format(unit.name, unit.street_address, unit.address_zip)) + event["location"] = vText( + "{} {} {}".format(unit.name, unit.street_address, unit.address_zip) + ) if unit.location: - event['geo'] = vGeo(unit.location) - event['summary'] = vText('{} {}'.format(unit.name, reservation.resource.name)) + event["geo"] = vGeo(unit.location) + event["summary"] = vText("{} {}".format(unit.name, reservation.resource.name)) cal.add_component(event) return cal.to_ical() @@ -290,5 +326,5 @@ def build_ical_feed_url(ical_token, request): Return iCal feed url for given token without query parameters """ - url = reverse('ical-feed', kwargs={'ical_token': ical_token}, request=request) - return url[:url.find('?')] + url = reverse("ical-feed", kwargs={"ical_token": ical_token}, request=request) + return url[: url.find("?")] From 61c6e6300105c3930db21df1b75e9874ae892721 Mon Sep 17 00:00:00 2001 From: Dan Jacob Date: Thu, 5 Oct 2023 15:33:06 +0300 Subject: [PATCH 2/2] Resources: fix for Resource.get_reservable_before() This method now should return result from start of current day, rather than from current datetime. In addition, a new utils function add_days() has been added. This should replace create_datetime_days_from_now() with clearer arguments. create_datetime_days_from_now() calls this function, so can be deprecated. Refs: TTVA-169 --- resources/models/resource.py | 16 +++++-- resources/models/utils.py | 30 ++++++++++--- resources/tests/test_reservation_api.py | 18 ++++++-- resources/tests/test_resource.py | 29 ++++++++++++- resources/tests/test_resource_api.py | 6 ++- resources/tests/test_utils.py | 57 +++++++++++++++++++++++++ 6 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 resources/tests/test_utils.py diff --git a/resources/models/resource.py b/resources/models/resource.py index 745d726e9..68b09be8c 100644 --- a/resources/models/resource.py +++ b/resources/models/resource.py @@ -34,6 +34,7 @@ from .permissions import RESOURCE_GROUP_PERMISSIONS, UNIT_ROLE_PERMISSIONS from .unit import Unit from .utils import ( + add_days, create_datetime_days_from_now, get_translated, get_translated_name, @@ -988,9 +989,18 @@ def get_reservable_max_days_in_advance(self): ) def get_reservable_before(self): - return create_datetime_days_from_now( - self.get_reservable_max_days_in_advance(), exclude_extra_day=True - ) + """Return the max date up to which you can reserve + the resource. + + Should include the number of days equal to + resource max days in advance (or unit max days in advance) + (inclusive). + + If max days is None, returns None. + """ + num_days = self.get_reservable_max_days_in_advance() + # add an extra day so we include that day as well + return add_days(num_days + 1) if num_days else None def get_reservable_min_days_in_advance(self): return ( diff --git a/resources/models/utils.py b/resources/models/utils.py index f7d5cd16b..83a02b74e 100644 --- a/resources/models/utils.py +++ b/resources/models/utils.py @@ -245,15 +245,35 @@ def get_object_or_none(cls, **kwargs): def create_datetime_days_from_now(days_from_now, exclude_extra_day=False): + """DEPRECATED: use add_days(). + If days_from_now is None, returns None. + + If exclude_extra_day is True, returns current datetime + number of days, + otherwise returns from start of day + number of days + 1. + """ if days_from_now is None: return None - if exclude_extra_day: - return timezone.now() + datetime.timedelta(days=days_from_now) - dt = timezone.now() + datetime.timedelta(days=days_from_now + 1) - dt = dt.replace(hour=0, minute=0, second=0, microsecond=0) + return ( + add_days(days_from_now, from_start_of_day=False) + if exclude_extra_day + else add_days(days_from_now + 1, from_start_of_day=True) + ) + + +def add_days(days_from_now, *, from_start_of_day=True): + """ + Adds days to current time. + + If from_start_of_day is True, count from start of today. + """ + + now = timezone.now() + + if from_start_of_day: + now = now.replace(hour=0, minute=0, second=0, microsecond=0) - return dt + return now + datetime.timedelta(days=days_from_now) def localize_datetime(dt): diff --git a/resources/tests/test_reservation_api.py b/resources/tests/test_reservation_api.py index 50aad0981..bc46b3858 100644 --- a/resources/tests/test_reservation_api.py +++ b/resources/tests/test_reservation_api.py @@ -3618,10 +3618,22 @@ def test_whole_day_reservation_allow_partial_reservation_same_day( "tampereazuread", 201, ), # Resource auth -> strong; User auth -> tampereazuread - ("PIKI", "tampereazuread", 201), # Resource_auth -> PIKI; User auth -> tampereazuread + ( + "PIKI", + "tampereazuread", + 201, + ), # Resource_auth -> PIKI; User auth -> tampereazuread ("mid", "tampereazuread", 201), # Resource auth -> mid; User auth -> tampereazuread - ("weak", "tampereazuread", 201), # Resource auth -> weak; User auth -> tampereazuread - ("none", "tampereazuread", 201), # Resouce auth -> none; User auth -> tampereazuread + ( + "weak", + "tampereazuread", + 201, + ), # Resource auth -> weak; User auth -> tampereazuread + ( + "none", + "tampereazuread", + 201, + ), # Resouce auth -> none; User auth -> tampereazuread ] diff --git a/resources/tests/test_resource.py b/resources/tests/test_resource.py index c778a06d6..97831af59 100644 --- a/resources/tests/test_resource.py +++ b/resources/tests/test_resource.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import datetime +import freezegun import pytest from decimal import Decimal from django.core.exceptions import ValidationError @@ -9,7 +10,7 @@ from resources.enums import UnitAuthorizationLevel from resources.errors import InvalidImage -from resources.models import Resource, ResourceImage +from resources.models import Resource, ResourceImage, Unit from resources.tests.utils import ( create_resource_image, get_field_errors, @@ -33,6 +34,32 @@ def space_resource_with_product(priced_product, space_resource): return space_resource +@freezegun.freeze_time("2023-10-5 15:40") +def test_get_reservable_before_none(): + resource = Resource( + reservable_max_days_in_advance=None, + unit=Unit(reservable_max_days_in_advance=None), + ) + assert resource.get_reservable_before() is None + + +@freezegun.freeze_time("2023-10-5 15:40") +def test_get_reservable_before_not_none(): + resource = Resource( + reservable_max_days_in_advance=7, + ) + # should be start of 13th + dt = resource.get_reservable_before() + assert dt + + assert dt.year == 2023 + assert dt.month == 10 + assert dt.day == 13 + + assert dt.hour == 0 + assert dt.minute == 0 + + @pytest.mark.django_db def test_free_of_charge_free_to_use_true_no_pricing_info(space_resource): space_resource.free_to_use = True diff --git a/resources/tests/test_resource_api.py b/resources/tests/test_resource_api.py index 00feeff85..f1f5d0581 100644 --- a/resources/tests/test_resource_api.py +++ b/resources/tests/test_resource_api.py @@ -744,9 +744,10 @@ def test_reservable_in_advance_fields( # only the unit has days set, expect those on the resource assert response.data["reservable_max_days_in_advance"] == 5 + # should be inclusive of last day before = timezone.now().replace( hour=0, minute=0, second=0, microsecond=0 - ) + datetime.timedelta(days=5) + ) + datetime.timedelta(days=6) assert response.data["reservable_before"] == before resource_in_unit.reservable_max_days_in_advance = 10 @@ -758,9 +759,10 @@ def test_reservable_in_advance_fields( # both the unit and the resource have days set, expect the resource's days # to override the unit's days assert response.data["reservable_max_days_in_advance"] == 10 + # should be inclusive of last day before = timezone.now().replace( hour=0, minute=0, second=0, microsecond=0 - ) + datetime.timedelta(days=10) + ) + datetime.timedelta(days=11) assert response.data["reservable_before"] == before diff --git a/resources/tests/test_utils.py b/resources/tests/test_utils.py new file mode 100644 index 000000000..53b4cedf4 --- /dev/null +++ b/resources/tests/test_utils.py @@ -0,0 +1,57 @@ +import freezegun + +from resources.models.utils import add_days, create_datetime_days_from_now + + +def test_create_datetime_days_from_now_none(): + assert create_datetime_days_from_now(None) is None + + +@freezegun.freeze_time("2023-10-5 15:40") +def test_create_datetime_days_from_now_include_extra_day(): + dt = create_datetime_days_from_now(3, exclude_extra_day=False) + assert dt + + assert dt.year == 2023 + assert dt.month == 10 + assert dt.day == 9 + + assert dt.hour == 0 + assert dt.minute == 0 + + +@freezegun.freeze_time("2023-10-5 15:40") +def test_create_datetime_days_from_now_exclude_extra_day(): + dt = create_datetime_days_from_now(3, exclude_extra_day=True) + assert dt + + assert dt.year == 2023 + assert dt.month == 10 + assert dt.day == 8 + + assert dt.hour == 15 + assert dt.minute == 40 + + +@freezegun.freeze_time("2023-10-5 15:40") +def test_add_days_from_start_of_day(): + dt = add_days(3, from_start_of_day=True) + + assert dt.year == 2023 + assert dt.month == 10 + assert dt.day == 8 + + assert dt.hour == 0 + assert dt.minute == 0 + + +@freezegun.freeze_time("2023-10-5 15:40") +def test_add_days_from_current_time(): + dt = add_days(3, from_start_of_day=False) + + assert dt.year == 2023 + assert dt.month == 10 + assert dt.day == 8 + + assert dt.hour == 15 + assert dt.minute == 40