diff --git a/app/base/serializers/fields/image.py b/app/base/serializers/fields/image.py index 54432631..96c5335a 100644 --- a/app/base/serializers/fields/image.py +++ b/app/base/serializers/fields/image.py @@ -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 @@ -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: diff --git a/app/base/serializers/fields/timezone.py b/app/base/serializers/fields/timezone.py new file mode 100644 index 00000000..55af9727 --- /dev/null +++ b/app/base/serializers/fields/timezone.py @@ -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}" diff --git a/app/base/tests/base.py b/app/base/tests/base.py index 025ca0ea..7ec5124a 100644 --- a/app/base/tests/base.py +++ b/app/base/tests/base.py @@ -19,43 +19,43 @@ 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) @@ -63,14 +63,23 @@ def visit(exp_key, exp_value): 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, diff --git a/app/base/tests/fakers.py b/app/base/tests/fakers.py index 03fca0ea..0f4e5b4c 100644 --- a/app/base/tests/fakers.py +++ b/app/base/tests/fakers.py @@ -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): diff --git a/app/base/views/base.py b/app/base/views/base.py index 4f43b28e..3c62bed7 100644 --- a/app/base/views/base.py +++ b/app/base/views/base.py @@ -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 @@ -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 = [] diff --git a/app/bookings/entites/__init__.py b/app/bookings/entites/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/entites/booking_type.py b/app/bookings/entites/booking_type.py new file mode 100644 index 00000000..fb319358 --- /dev/null +++ b/app/bookings/entites/booking_type.py @@ -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] diff --git a/app/bookings/entites/slot.py b/app/bookings/entites/slot.py new file mode 100644 index 00000000..0277e9e4 --- /dev/null +++ b/app/bookings/entites/slot.py @@ -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 diff --git a/app/bookings/enums/__init__.py b/app/bookings/enums/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/enums/booking_type.py b/app/bookings/enums/booking_type.py new file mode 100644 index 00000000..21a1a505 --- /dev/null +++ b/app/bookings/enums/booking_type.py @@ -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 + # ) diff --git a/app/bookings/serializers/slots/__init__.py b/app/bookings/serializers/slots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/serializers/slots/trial.py b/app/bookings/serializers/slots/trial.py new file mode 100644 index 00000000..97aea8e4 --- /dev/null +++ b/app/bookings/serializers/slots/trial.py @@ -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) diff --git a/app/bookings/services/__init__.py b/app/bookings/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/services/factories/__init__.py b/app/bookings/services/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/services/factories/slots.py b/app/bookings/services/factories/slots.py new file mode 100644 index 00000000..2bae2b15 --- /dev/null +++ b/app/bookings/services/factories/slots.py @@ -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 diff --git a/app/bookings/tests/services/__init__.py b/app/bookings/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/tests/services/factories/__init__.py b/app/bookings/tests/services/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/tests/services/factories/test_slots.py b/app/bookings/tests/services/factories/test_slots.py new file mode 100644 index 00000000..b65a2f54 --- /dev/null +++ b/app/bookings/tests/services/factories/test_slots.py @@ -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", + ) diff --git a/app/bookings/tests/views/slots/__init__.py b/app/bookings/tests/views/slots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/tests/views/slots/test_trial.py b/app/bookings/tests/views/slots/test_trial.py new file mode 100644 index 00000000..90cbe305 --- /dev/null +++ b/app/bookings/tests/views/slots/test_trial.py @@ -0,0 +1,82 @@ +from datetime import date, datetime, timedelta, timezone + +from app.base.tests.views.base import BaseViewTest +from app.calendar.tests.factories import CalendarEventFactory + + +class BookingSlotsFactoryTest(BaseViewTest): + day: str + tz: str + step_minutes: int + + _base_path = '/bookings/slots/trial/' + + @property + def path(self): + return ( + f"{self._base_path}?day={self.day}&timezone={self.tz}&" + f"step_minutes={self.step_minutes}" + ) + + def test_create_slots_for_day(self): + # Arrange + day = date(2023, 6, 19) + tz = timezone(timedelta(hours=3)) # UTC+3 + self.day = day.strftime('%Y-%m-%d') + self.tz = '%2B00%3A00' + self.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=self.me, + ) + CalendarEventFactory( + start_time=datetime(2023, 6, 19, 8), # 11:00 (UTC+3) + end_time=datetime(2023, 6, 19, 9), # 12:00 (UTC+3) + guests=[self.me], + ) + 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 + response = self._test('get', {'slots': [...]}) + slots = response.json()['slots'] + + # 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", + ) diff --git a/app/bookings/urls.py b/app/bookings/urls.py index 78e76dd1..f617d0f9 100644 --- a/app/bookings/urls.py +++ b/app/bookings/urls.py @@ -2,10 +2,12 @@ from app.bookings.views.hourly import BookingsHourlyView from app.bookings.views.package import BookingsPackageView +from app.bookings.views.slots.trial import BookingsSlotsTrialView from app.bookings.views.trial import BookingsTrialView urlpatterns = [ path('trial/', BookingsTrialView.as_view()), path('hourly/', BookingsHourlyView.as_view()), path('package/', BookingsPackageView.as_view()), + path('slots/trial/', BookingsSlotsTrialView.as_view()), ] diff --git a/app/bookings/views/slots/__init__.py b/app/bookings/views/slots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/bookings/views/slots/trial.py b/app/bookings/views/slots/trial.py new file mode 100644 index 00000000..ae617bbf --- /dev/null +++ b/app/bookings/views/slots/trial.py @@ -0,0 +1,64 @@ +from datetime import datetime + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter +from rest_framework.response import Response + +from app.base.serializers.fields.timezone import TimezoneField +from app.base.utils.schema import extend_schema +from app.base.views import BaseView +from app.bookings.serializers.slots.trial import ( + GETBookingsSlotsTrialSerializer, + QueryParamsGETBookingsSlotsTrialSerializer, +) +from app.bookings.services.factories.slots import BookingSlotsFactory +from app.users.permissions import AuthenticatedPermission + + +class BookingsSlotsTrialView(BaseView): + permissions_map = {'get': [AuthenticatedPermission]} + serializer_map = {'get': GETBookingsSlotsTrialSerializer} + + @extend_schema( + parameters=[ + OpenApiParameter( + name='day', + type=OpenApiTypes.DATE, + description="The date parameter, default is today's date", + default=datetime.today().strftime('%Y-%m-%d'), + ), + OpenApiParameter( + name='timezone', + type=OpenApiTypes.REGEX, + pattern=TimezoneField.TIMEZONE_REGEX_PATTERN.pattern, + description=( + "The timezone offset in the format ±hh:mm, default is +00:00" + ), + default='+00:00', + ), + OpenApiParameter( + name='step_minutes', + type=OpenApiTypes.INT, + description="The slots step in minutes, default is 15", + default=15, + ), + ] + ) + def get(self): + slots_factory = BookingSlotsFactory() + + query_params_serializer = QueryParamsGETBookingsSlotsTrialSerializer( + data=self.request.query_params + ) + query_params_serializer.is_valid() + params = query_params_serializer.validated_data + + slots = slots_factory.create_slots_for_day( + user=self.request.user, + day=params['day'], + tz=params['timezone'], + step_minutes=params['step_minutes'], + ) + + serializer = self.get_serializer(instance={'slots': slots}) + return Response(data=serializer.data) diff --git a/app/calendar/entites/__init__.py b/app/calendar/entites/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/entites/calendar_event.py b/app/calendar/entites/calendar_event.py new file mode 100644 index 00000000..fdc38f95 --- /dev/null +++ b/app/calendar/entites/calendar_event.py @@ -0,0 +1,14 @@ +from datetime import datetime + +import pydantic + +from app.base.entities.base import BaseEntity +from app.users.models import User + + +class CalendarEventEntity(BaseEntity): + start_time: datetime + end_time: datetime + title: str + host: User + guests: set[User] = pydantic.Field(min_length=1) diff --git a/app/calendar/managers/__init__.py b/app/calendar/managers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/managers/event.py b/app/calendar/managers/event.py new file mode 100644 index 00000000..17075472 --- /dev/null +++ b/app/calendar/managers/event.py @@ -0,0 +1,13 @@ +from django.db import models + +from app.users.models import User + + +class CalendarEventManager(models.Manager): + def create(self, **kwargs): + from app.calendar.models import CalendarEvent + + guests: set[User] = set(kwargs.pop('guests', set())) + calendar_event: CalendarEvent = super().create(**kwargs) + calendar_event.guests.set(guests) + return calendar_event diff --git a/app/calendar/migrations/0001_initial.py b/app/calendar/migrations/0001_initial.py new file mode 100644 index 00000000..a4e7f746 --- /dev/null +++ b/app/calendar/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.7 on 2024-06-19 09:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CalendarEvent', + fields=[ + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('title', models.TextField()), + ('google_event_uuid', models.UUIDField(unique=True)), + ( + 'guests', + models.ManyToManyField( + related_name='calendar_events_by_guests', + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'host', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='calendar_events_by_host', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'ordering': ['pk'], + 'abstract': False, + }, + ), + ] diff --git a/app/calendar/migrations/__init__.py b/app/calendar/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/models.py b/app/calendar/models.py new file mode 100644 index 00000000..a5c8f5c4 --- /dev/null +++ b/app/calendar/models.py @@ -0,0 +1,25 @@ +from django.db import models + +from app.base.models.base import BaseModel +from app.calendar.managers.event import CalendarEventManager +from app.calendar.services.converters.timezone import TimezoneConverter +from app.users.models import User + + +class CalendarEvent(BaseModel): + start_time = models.DateTimeField() + end_time = models.DateTimeField() + title = models.TextField() + host = models.ForeignKey( + User, on_delete=models.CASCADE, related_name='calendar_events_by_host' + ) + guests = models.ManyToManyField(User, related_name='calendar_events_by_guests') + google_event_uuid = models.UUIDField(unique=True) + + objects = CalendarEventManager() + + def save(self, **kwargs): + timezone_converter = TimezoneConverter() + self.start_time = timezone_converter.convert_to_utc(self.start_time) + self.end_time = timezone_converter.convert_to_utc(self.end_time) + super().save(**kwargs) diff --git a/app/calendar/services/converters/__init__.py b/app/calendar/services/converters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/services/converters/timezone.py b/app/calendar/services/converters/timezone.py new file mode 100644 index 00000000..c2c248f3 --- /dev/null +++ b/app/calendar/services/converters/timezone.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone + + +class TimezoneConverter: + class NotUTCException(Exception): + def __init__(self, message="The time must be in UTC"): + super().__init__(message) + + def convert_to_utc(self, dt: datetime) -> datetime: + utc_dt = dt.astimezone(timezone.utc) + return utc_dt + + def convert_from_utc(self, dt: datetime, target_timezone: timezone) -> datetime: + if dt.tzinfo and dt.tzinfo != timezone.utc: + raise self.NotUTCException + target_dt = dt.astimezone(target_timezone) + return target_dt diff --git a/app/calendar/services/factories/__init__.py b/app/calendar/services/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/services/factories/events/__init__.py b/app/calendar/services/factories/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/services/factories/events/base.py b/app/calendar/services/factories/events/base.py new file mode 100644 index 00000000..2c1607fd --- /dev/null +++ b/app/calendar/services/factories/events/base.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.calendar.entites.calendar_event import CalendarEventEntity +from app.calendar.models import CalendarEvent + + +class BaseCalendarEventFactory(ABC): + def __init__(self): + self.calendar_event_manager = CalendarEvent.objects + + @abstractmethod + def create_event(self, calendar_event_entity: CalendarEventEntity) -> CalendarEvent: + raise NotImplementedError diff --git a/app/calendar/services/factories/events/google.py b/app/calendar/services/factories/events/google.py new file mode 100644 index 00000000..2a71cafd --- /dev/null +++ b/app/calendar/services/factories/events/google.py @@ -0,0 +1,55 @@ +import uuid + +from googleapiclient.discovery import build + +from app.calendar.entites.calendar_event import CalendarEventEntity +from app.calendar.services.contexters.credentials import GoogleCredentialsContexter +from app.calendar.services.factories.events.base import BaseCalendarEventFactory + + +class GoogleCalendarEventFactory(BaseCalendarEventFactory): + def __init__(self): + super().__init__() + self.contexter = GoogleCredentialsContexter() + + def _create_event_in_google_calendar( + self, calendar_event_entity: CalendarEventEntity + ) -> uuid.UUID: + host = calendar_event_entity.host + google_event_uuid = uuid.uuid4() + with self.contexter.create_context(host.social_auth.get()) as context: + service = build( + serviceName='calendar', version='v3', credentials=context.credentials + ) + event = { + 'summary': calendar_event_entity.title, + 'description': calendar_event_entity.title, + 'start': { + 'dateTime': calendar_event_entity.start_time.isoformat(), + 'timeZone': 'Europe/Moscow', + }, + 'end': { + 'dateTime': calendar_event_entity.end_time.isoformat(), + 'timeZone': 'Europe/Moscow', + }, + 'reminders': {'useDefault': False, 'overrides': []}, + 'attendees': [ + {'email': guest.email for guest in calendar_event_entity.guests} + ], + 'conferenceData': { + 'createRequest': { + 'conferenceSolutionKey': {'type': 'hangoutsMeet'}, + 'requestId': str(google_event_uuid), + } + }, + } + service.events().insert( + calendarId='primary', body=event, conferenceDataVersion=1 + ).execute() + return google_event_uuid + + def create_event(self, calendar_event_entity): + google_event_uuid = self._create_event_in_google_calendar(calendar_event_entity) + return self.calendar_event_manager.create( + **calendar_event_entity.dict(), google_event_uuid=google_event_uuid + ) diff --git a/app/calendar/tests/__init__.py b/app/calendar/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/tests/factories.py b/app/calendar/tests/factories.py new file mode 100644 index 00000000..9ba5cb51 --- /dev/null +++ b/app/calendar/tests/factories.py @@ -0,0 +1,29 @@ +from datetime import datetime, timedelta + +import factory +from factory.django import DjangoModelFactory + +from app.base.tests.fakers import fake +from app.calendar.models import CalendarEvent +from app.users.tests.factories import UserFactory + + +class CalendarEventFactory(DjangoModelFactory): + start_time = factory.LazyFunction(datetime.now) + end_time = factory.LazyFunction( + lambda: datetime.now() + timedelta(hours=fake.random.randint(1, 3)) + ) + title = factory.Faker('sentence') + host = factory.SubFactory(UserFactory) + google_event_uuid = factory.Faker('uuid4') + + class Meta: + model = CalendarEvent + + @factory.post_generation + def guests(self, create, extracted, **_): + if not create: + return + if extracted: + for guest in extracted: + self.guests.add(guest) diff --git a/app/calendar/tests/services/__init__.py b/app/calendar/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/tests/services/factories/__init__.py b/app/calendar/tests/services/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/tests/services/factories/events/__init__.py b/app/calendar/tests/services/factories/events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/tests/services/factories/events/test_google.py b/app/calendar/tests/services/factories/events/test_google.py new file mode 100644 index 00000000..5cd7a592 --- /dev/null +++ b/app/calendar/tests/services/factories/events/test_google.py @@ -0,0 +1,59 @@ +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, patch + +from app.base.tests.base import BaseTest +from app.base.tests.fakers import fake +from app.calendar.entites.calendar_event import CalendarEventEntity +from app.calendar.models import CalendarEvent +from app.calendar.services.factories.events.google import GoogleCalendarEventFactory +from app.users.tests.factories import UserFactory, UserSocialAuthFactory + + +class GoogleCalendarEventFactoryTest(BaseTest): + @patch('app.calendar.services.factories.events.google.build') + def test_create_event_in_google_calendar(self, mock_build): + # Arrange + mock_service = MagicMock() + mock_build.return_value = mock_service + + host = UserFactory() + UserSocialAuthFactory(user=host) + guests = {UserFactory(), UserFactory()} + + start_time = datetime.now(timezone.utc) + end_time = start_time + timedelta(hours=fake.random.randint(1, 3)) + tz = fake.timezone() + calendar_event_entity = CalendarEventEntity( + host=host, + title=fake.text(), + start_time=start_time.astimezone(tz), + end_time=end_time.astimezone(tz), + guests=guests, + ) + + factory = GoogleCalendarEventFactory() + + # Act + calendar_event = factory.create_event(calendar_event_entity) + + # Assert + self.assert_model( + CalendarEvent, + { + 'title': calendar_event_entity.title, + 'start_time': start_time, + 'end_time': end_time, + 'google_event_uuid': calendar_event.google_event_uuid, + 'host': host.id, + 'guests': list(guests), + }, + ) + mock_build.assert_called_once_with( + serviceName='calendar', + version='v3', + credentials=mock_build.call_args[1][ + 'credentials' + ], # Ignore specific credential instance + ) + mock_service.events().insert.assert_called_once() + mock_service.events().insert().execute.assert_called_once() diff --git a/app/calendar/urls.py b/app/calendar/urls.py index 740ab2a3..637600f5 100644 --- a/app/calendar/urls.py +++ b/app/calendar/urls.py @@ -1,5 +1 @@ -from django.urls import path - -from .views import CalendarView - -urlpatterns = [path('', CalendarView.as_view())] +urlpatterns = [] diff --git a/app/calendar/views/__init__.py b/app/calendar/views/__init__.py deleted file mode 100644 index 2a1891cc..00000000 --- a/app/calendar/views/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .general import * diff --git a/app/calendar/views/general.py b/app/calendar/views/general.py deleted file mode 100644 index a53037a7..00000000 --- a/app/calendar/views/general.py +++ /dev/null @@ -1,48 +0,0 @@ -import uuid -from datetime import datetime, timedelta - -from googleapiclient.discovery import build -from rest_framework.response import Response - -from app.base.views import BaseView -from app.calendar.services.contexters.credentials import GoogleCredentialsContexter - - -class CalendarView(BaseView): - def post(self): - user = self.request.user - contexter = GoogleCredentialsContexter() - with contexter.create_context(user.social_auth.get()) as context: - service = build( - serviceName='calendar', version='v3', credentials=context.credentials - ) - event = { - 'summary': 'Тестовая встреча', - 'description': 'Тестовая встреча, созданная через Google Calendar API', - 'start': { - 'dateTime': ( - datetime.now() + timedelta(hours=3, minutes=10) - ).isoformat(), - 'timeZone': 'Europe/Moscow', - }, - 'end': { - 'dateTime': ( - datetime.now() + timedelta(hours=3, minutes=20) - ).isoformat(), - 'timeZone': 'Europe/Moscow', - }, - 'reminders': {'useDefault': False, 'overrides': []}, - 'attendees': [{'email': 'envy42125@gmail.com'}], - 'conferenceData': { - 'createRequest': { - 'conferenceSolutionKey': {'type': 'hangoutsMeet'}, - 'requestId': str(uuid.uuid4()), - } - }, - } - event = ( - service.events() - .insert(calendarId='primary', body=event, conferenceDataVersion=1) - .execute() - ) - return Response(data={'status': 'success', 'event': event}) diff --git a/app/platform/models/__init__.py b/app/platform/models/__init__.py deleted file mode 100644 index bd9a2b4e..00000000 --- a/app/platform/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ._unmanaged import * diff --git a/app/platform/models/_unmanaged.py b/app/platform/models/_unmanaged.py deleted file mode 100644 index ffdf114b..00000000 --- a/app/platform/models/_unmanaged.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import models - -from app.base.models.base import BaseModel -from app.platform.managers import BasePlatformManager - - -class CalUser(BaseModel): - id = models.IntegerField(primary_key=True) - username = models.TextField() - email = models.EmailField() - - objects = BasePlatformManager() - - class Meta: - managed = False - db_table = 'users' diff --git a/app/users/tests/factories.py b/app/users/tests/factories.py index cfe6d800..65fe1537 100644 --- a/app/users/tests/factories.py +++ b/app/users/tests/factories.py @@ -1,8 +1,10 @@ +import factory from django.contrib.auth.hashers import make_password from django.contrib.auth.models import Group +from social_django.models import UserSocialAuth from app.base.tests.factories.base import BaseFactory -from app.base.tests.fakers import Faker +from app.base.tests.fakers import Faker, fake from app.users.models import User @@ -34,3 +36,20 @@ class Meta: model = Group name = Faker('english_word') + + +class UserSocialAuthFactory(BaseFactory): + class Meta: + model = UserSocialAuth + + user = factory.SubFactory(UserFactory) + provider = Faker('word') + uid = Faker('uuid4') + extra_data = factory.LazyFunction( + lambda: { + 'auth_time': int(fake.unix_time()), + 'expires': fake.random_int(min=3600, max=7200), + 'access_token': fake.uuid4(), + 'refresh_token': fake.uuid4(), + } + )