Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
53550c6
add GoogleCalendarEventFactory
GentleEnvy Jun 14, 2024
646016e
add `GoogleCalendarEventFactory` with tests
GentleEnvy Jun 17, 2024
d42f89c
add `GoogleCalendarEventFactory` with tests
GentleEnvy Jun 18, 2024
af12ce9
Merge remote-tracking branch 'refs/remotes/origin/task-583' into task…
GentleEnvy Jun 18, 2024
af7abeb
delete old `GoogleCalendarEventFactory`
GentleEnvy Jun 18, 2024
502db11
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 18, 2024
2157c21
add `TimezoneConverter`
GentleEnvy Jun 19, 2024
46a59bd
add context to test log messages
GentleEnvy Jun 19, 2024
2a9c05d
Merge remote-tracking branch 'origin/task-583' into task-583
GentleEnvy Jun 19, 2024
ab6099f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 19, 2024
e238981
add `BookingSlotsFactory`
GentleEnvy Jun 19, 2024
7c8abea
Merge remote-tracking branch 'origin/task-583' into task-583
GentleEnvy Jun 19, 2024
493de09
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 19, 2024
7e4c19a
add `BookingsSlotsTrialView`
GentleEnvy Jun 21, 2024
b500176
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 21, 2024
b243e30
auto-update
GentleEnvy Jun 30, 2024
eb258a9
auto-update
GentleEnvy Jul 6, 2024
2c66bb8
auto-update
GentleEnvy Jul 6, 2024
1e9a6d9
auto-update
GentleEnvy Jul 6, 2024
b5bd230
auto-update
GentleEnvy Jul 6, 2024
1ab033b
auto-update
GentleEnvy Jul 6, 2024
aa22062
auto-update
GentleEnvy Jul 6, 2024
95bbce3
auto-update
GentleEnvy Jul 6, 2024
6bac0fa
auto-update
GentleEnvy Jul 6, 2024
6c27085
auto-update
GentleEnvy Jul 6, 2024
1d3b7c6
auto-update
GentleEnvy Jul 7, 2024
abc6b0d
auto-update
GentleEnvy Jul 7, 2024
cf304f2
auto-update
GentleEnvy Jul 7, 2024
733053f
auto-update
GentleEnvy Jul 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/base/serializers/fields/image.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.core.files.base import ContentFile
from drf_base64.fields import Base64ImageField as _Base64ImageField
from rest_framework.fields import SkipField

Expand All @@ -18,7 +19,7 @@ def __init__(self, allowed_extensions=None, **kwargs):
)
super().__init__(**kwargs)

def _decode(self, data):
def _decode(self, data) -> ContentFile:
try:
value = super()._decode(data)
except SkipField:
Expand Down
34 changes: 34 additions & 0 deletions app/base/serializers/fields/timezone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import re
from datetime import datetime, timedelta, timezone
from typing import Final

from rest_framework import serializers


class TimezoneField(serializers.CharField):
TIMEZONE_REGEX_PATTERN: Final[re.Pattern] = re.compile(
'^[+-](?:2[0-3]|[01][0-9]):[0-5][0-9]$'
)

default_error_messages = serializers.CharField.default_error_messages | {
'invalid_format': (
f"Timezone must be in the format ±hh:mm (regex: "
f"{TIMEZONE_REGEX_PATTERN.pattern})"
)
}

def to_internal_value(self, data):
if not re.match(self.TIMEZONE_REGEX_PATTERN, data):
self.fail('invalid_format')
sign = 1 if data[0] == '+' else -1
hours, minutes = map(int, data[1:].split(':'))
offset = timedelta(hours=hours * sign, minutes=minutes * sign)
return timezone(offset)

def to_representation(self, value: timedelta | timezone):
if isinstance(value, timezone):
value: timedelta = datetime.now(value).utcoffset()
total_minutes = value.total_seconds() / 60
sign = '+' if total_minutes >= 0 else '-'
hours, minutes = divmod(abs(int(total_minutes)), 60)
return f"{sign}{hours:02}:{minutes:02}"
45 changes: 27 additions & 18 deletions app/base/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,58 +19,67 @@ class BaseTest(APITestCase):
assert_is_none = APITestCase.assertIsNone
assert_is_not_none = APITestCase.assertIsNotNone

def assert_equal(self, exp_value, value) -> None:
def assert_equal(self, exp_value, value, context=None) -> None:
match exp_value:
case dict():
self.assert_dict(exp_value, value)
self.assert_dict(exp_value, value, context=context)
case list():
self.assert_list(exp_value, value)
self.assert_list(exp_value, value, context=context)
case set():
self.assert_set(exp_value, value)
self.assert_set(exp_value, value, context=context)
case _:
if callable(exp_value):
if exp_value(value) is False:
self.fail(f"{exp_value=}({value=}) is False")
self.fail(f"{exp_value=}({value=}) is False : {context=}")
elif exp_value is not ...:
self.assert_is_instance(value, type(exp_value))
self.assertEqual(exp_value, value)
self.assert_is_instance(value, type(exp_value), msg=f"{context=}")
self.assertEqual(exp_value, value, msg=f"{context=}")

def assert_list(self, exp_list: list, list_: list) -> None:
def assert_list(self, exp_list: list, list_: list, context=None) -> None:
if exp_list and exp_list[-1] is ...:
self.assert_list(exp_list[:-1], list_[: len(exp_list) - 1])
self.assert_list(exp_list[:-1], list_[: len(exp_list) - 1], context=context)
else:
self.assert_equal(len(exp_list), len(list_))
self.assert_equal(len(exp_list), len(list_), context=context)
for exp_element, element in zip(exp_list, list_):
self.assert_equal(exp_element, element)
self.assert_equal(exp_element, element, context=context)

def assert_set(self, exp_set: set, set_: set) -> None:
self.assert_equal(len(exp_set), len(set_))
def assert_set(self, exp_set: set, set_: set, context=None) -> None:
self.assert_equal(len(exp_set), len(set_), context=context)
for exp_element in exp_set:
for element in set_:
try:
self.assert_equal(exp_element, element)
self.assert_equal(exp_element, element, context=context)
break
except AssertionError:
continue
else:
self.fail(f"{exp_set} != {set_}")
self.fail(f"{exp_set} != {set_} : {context=}")

def assert_dict(self, exp_dict: dict, dict_: dict) -> None:
def assert_dict(self, exp_dict: dict, dict_: dict, context=None) -> None:
def dfs(inner_dict, inner_exp_dict):
def visit(exp_key, exp_value):
self.assert_in(exp_key, inner_dict)
value = inner_dict[exp_key]
if isinstance(value, dict):
dfs(value, exp_value)
else:
self.assert_equal(exp_value, value)
self.assert_equal(exp_value, value, context=context)

[visit(*items) for items in inner_exp_dict.items()]

dfs(dict_, exp_dict)

def assert_instance(self, instance: models.Model, instance_data: dict):
self.assert_dict(instance_data, model_to_dict(instance))
instance_dict = model_to_dict(instance)
self.assert_dict(
instance_data,
instance_dict,
context={
'instance': instance,
'instance_data': instance_data,
'instance_dict': instance_dict,
},
)

def assert_model(
self,
Expand Down
6 changes: 6 additions & 0 deletions app/base/tests/fakers.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ def image(self, size: tuple[int, int] = (1, 1), extension=None) -> ContentFile:
fake.file_name(category='image', extension=extension),
)

def timezone(self) -> datetime.timezone:
hours_offset = self.random.randint(-12, 14)
minutes_offset = self.random.choice([0, 15, 30, 45])
offset = datetime.timedelta(hours=hours_offset, minutes=minutes_offset)
return datetime.timezone(offset)


class Faker(_FactoryFaker):
def __init__(self, provider, **kwargs):
Expand Down
10 changes: 10 additions & 0 deletions app/base/views/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rest_framework import exceptions, status
from rest_framework.generics import GenericAPIView
from rest_framework.mixins import ListModelMixin
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import BaseThrottle

Expand All @@ -14,11 +15,20 @@
from app.base.services.cache import Cacher
from app.base.utils.common import status_by_method
from app.base.utils.schema import extend_schema
from app.users.models import User

__all__ = ['BaseView']


class _RequestProtocol(Request):
@property
def user(self) -> User:
return super().user()


class BaseView(GenericAPIView):
request: _RequestProtocol

many: bool = False
serializer_class = BaseSerializer
permission_classes = []
Expand Down
Empty file.
8 changes: 8 additions & 0 deletions app/bookings/entites/booking_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from app.base.entities.base import BaseEntity
from app.bookings.models import AbstractBooking


class BookingTypeEntity(BaseEntity):
title: str
duration_minutes: int
model: type[AbstractBooking]
9 changes: 9 additions & 0 deletions app/bookings/entites/slot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from datetime import datetime

from app.base.entities.base import BaseEntity


class SlotEntity(BaseEntity):
start_time: datetime
end_time: datetime
is_free: bool
Empty file added app/bookings/enums/__init__.py
Empty file.
18 changes: 18 additions & 0 deletions app/bookings/enums/booking_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from enum import Enum
from typing import Final

from app.bookings.entites.booking_type import BookingTypeEntity
from app.bookings.models import HourlyBooking, TrialBooking


class BookingTypeEnum(Enum):
TRIAL: Final[BookingTypeEntity] = BookingTypeEntity(
title='Trial meet', duration_minutes=15, model=TrialBooking
)
HOURLY: Final[BookingTypeEntity] = BookingTypeEntity(
title='Hourly meet', duration_minutes=60, model=HourlyBooking
)
# FIXME: package is a separate type ?
# PACKAGE: Final[BookingTypeEntity] = BookingTypeEntity(
# title='Hourly meet', duration_minutes=60, model=PackageBooking
# )
Empty file.
22 changes: 22 additions & 0 deletions app/bookings/serializers/slots/trial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from datetime import datetime

from rest_framework import serializers

from app.base.serializers.base import BaseSerializer
from app.base.serializers.fields.timezone import TimezoneField


class _SlotGETBookingsSlotsTrialSerializer(BaseSerializer):
start_time = serializers.DateTimeField()
end_time = serializers.DateTimeField()
is_free = serializers.BooleanField()


class GETBookingsSlotsTrialSerializer(BaseSerializer):
slots = _SlotGETBookingsSlotsTrialSerializer(many=True)


class QueryParamsGETBookingsSlotsTrialSerializer(BaseSerializer):
day = serializers.DateField(default=datetime.today())
timezone = TimezoneField(default='+00:00')
step_minutes = serializers.IntegerField(min_value=5, max_value=60, default=15)
Empty file.
Empty file.
45 changes: 45 additions & 0 deletions app/bookings/services/factories/slots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from datetime import date, datetime, timedelta, timezone

from django.db import models

from app.bookings.entites.slot import SlotEntity
from app.calendar.models import CalendarEvent
from app.users.models import User


class BookingSlotsFactory:
def __init__(self):
self.calendar_event_manager = CalendarEvent.objects

def _get_events_for_day(
self, user: User, start_of_day: datetime
) -> models.QuerySet:
start_of_day = start_of_day.astimezone(timezone.utc)
end_of_day = start_of_day + timedelta(days=1)
return CalendarEvent.objects.filter(
models.Q(start_time__lte=end_of_day) & models.Q(end_time__gte=start_of_day),
models.Q(host=user) | models.Q(guests=user),
)

def create_slots_for_day(
self, user: User, day: date, tz: timezone, step_minutes: int
) -> list[SlotEntity]:
start_of_day_local = datetime.combine(day, datetime.min.time(), tz)
end_of_day_local = start_of_day_local + timedelta(days=1)
events = self._get_events_for_day(user, start_of_day_local)
slots = []
current_time = start_of_day_local
while current_time < end_of_day_local:
slot_end_time = current_time + timedelta(minutes=step_minutes)
is_free = not any(
event.start_time.astimezone(tz) < slot_end_time
and event.end_time.astimezone(tz) > current_time
for event in events
)
slots.append(
SlotEntity(
start_time=current_time, end_time=slot_end_time, is_free=is_free
)
)
current_time = slot_end_time
return slots
Empty file.
Empty file.
75 changes: 75 additions & 0 deletions app/bookings/tests/services/factories/test_slots.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from datetime import date, datetime, timedelta, timezone

from app.base.tests.base import BaseTest
from app.bookings.services.factories.slots import BookingSlotsFactory
from app.calendar.tests.factories import CalendarEventFactory
from app.users.tests.factories import UserFactory


class BookingSlotsFactoryTest(BaseTest):
def test_create_slots_for_day(self):
# Arrange
user = UserFactory()
factory = BookingSlotsFactory()
tz = timezone(timedelta(hours=3)) # UTC+3
day = date(2023, 6, 19)
step_minutes = 30
CalendarEventFactory(
start_time=datetime(2023, 6, 18, 23), # 02:00 (UTC+3)
end_time=datetime(2023, 6, 19), # 3:00 (UTC+3)
host=user,
)
CalendarEventFactory(
start_time=datetime(2023, 6, 19, 8), # 11:00 (UTC+3)
end_time=datetime(2023, 6, 19, 9), # 12:00 (UTC+3)
guests=[user],
)
expected_busy_slots = [
{
'start_time': datetime(2023, 6, 19, 2, tzinfo=tz),
'end_time': datetime(2023, 6, 19, 2, 30, tzinfo=tz),
},
{
'start_time': datetime(2023, 6, 19, 2, 30, tzinfo=tz),
'end_time': datetime(2023, 6, 19, 3, tzinfo=tz),
},
{
'start_time': datetime(2023, 6, 19, 11, tzinfo=tz),
'end_time': datetime(2023, 6, 19, 11, 30, tzinfo=tz),
},
{
'start_time': datetime(2023, 6, 19, 11, 30, tzinfo=tz),
'end_time': datetime(2023, 6, 19, 12, tzinfo=tz),
},
]

# Act
slots = factory.create_slots_for_day(
user=user,
day=day,
tz=tz,
step_minutes=step_minutes,
)

# Assert
for expected_slot in expected_busy_slots:
self.assert_true(
any(
slot.start_time == expected_slot['start_time']
and slot.end_time == expected_slot['end_time']
and not slot.is_free
for slot in slots
),
msg=f"Slot {expected_slot} is not busy as expected",
)

for slot in slots:
if not any(
slot.start_time == expected_slot['start_time']
and slot.end_time == expected_slot['end_time']
for expected_slot in expected_busy_slots
):
self.assert_true(
slot.is_free,
msg=f"Slot {slot} is expected to be free but it is not",
)
Empty file.
Loading