From 53550c6b0d73a1ce92dcb4a0231e5c935839fafd Mon Sep 17 00:00:00 2001 From: Envy Date: Fri, 14 Jun 2024 17:09:02 +0300 Subject: [PATCH 01/12] add GoogleCalendarEventFactory Signed-off-by: Envy --- app/calendar/factories/__init__.py | 0 app/calendar/factories/event.py | 48 ++++++++++++++++++++++++++++++ app/calendar/views/general.py | 42 ++------------------------ 3 files changed, 51 insertions(+), 39 deletions(-) create mode 100644 app/calendar/factories/__init__.py create mode 100644 app/calendar/factories/event.py diff --git a/app/calendar/factories/__init__.py b/app/calendar/factories/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/calendar/factories/event.py b/app/calendar/factories/event.py new file mode 100644 index 00000000..4a793747 --- /dev/null +++ b/app/calendar/factories/event.py @@ -0,0 +1,48 @@ +import uuid +from datetime import datetime + +from googleapiclient.discovery import build + +from app.calendar.services.contexters.credentials import GoogleCredentialsContexter +from app.users.models import User + + +class GoogleCalendarEventFactory: + def __init__(self, contexter=GoogleCredentialsContexter()): + self.contexter = contexter + self.google_api_version = 'v3' + + def create( + self, + user: User, + summary: str, + description: str, + start_time: datetime, + end_time: datetime, + attendees: list[str], + ) -> dict: + with self.contexter.create_context(user.social_auth.get()) as context: + service = build( + serviceName='calendar', + version=self.google_api_version, + credentials=context.credentials, + ) + event = { + 'summary': summary, + 'description': description, + 'start': {'dateTime': start_time.isoformat()}, + 'end': {'dateTime': end_time.isoformat()}, + 'reminders': {'useDefault': False}, + 'attendees': [{'email': email} for email in attendees], + 'conferenceData': { + 'createRequest': { + 'conferenceSolutionKey': {'type': 'hangoutsMeet'}, + 'requestId': str(uuid.uuid4()), + } + }, + } + return ( + service.events() + .insert(calendarId='primary', body=event, conferenceDataVersion=1) + .execute() + ) diff --git a/app/calendar/views/general.py b/app/calendar/views/general.py index a53037a7..9158b67d 100644 --- a/app/calendar/views/general.py +++ b/app/calendar/views/general.py @@ -1,48 +1,12 @@ -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 +from app.calendar.factories.event import GoogleCalendarEventFactory 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() - ) + google_calendar_event_factory = GoogleCalendarEventFactory() + event = google_calendar_event_factory.create(user) return Response(data={'status': 'success', 'event': event}) From 646016ee2382bf712d151d8d26725886e1312d96 Mon Sep 17 00:00:00 2001 From: Envy Date: Mon, 17 Jun 2024 19:39:27 +0300 Subject: [PATCH 02/12] add `GoogleCalendarEventFactory` with tests --- app/calendar/entites/__init__.py | 0 app/calendar/entites/calendar_event.py | 14 +++++ app/calendar/migrations/__init__.py | 0 app/calendar/models.py | 13 +++++ app/calendar/services/factories/__init__.py | 0 .../services/factories/events/__init__.py | 0 .../services/factories/events/base.py | 13 +++++ .../services/factories/events/google.py | 55 +++++++++++++++++++ app/calendar/tests/__init__.py | 0 app/calendar/tests/services/__init__.py | 0 .../tests/services/factories/__init__.py | 0 .../services/factories/events/__init__.py | 0 .../services/factories/events/test_google.py | 45 +++++++++++++++ app/platform/models/__init__.py | 1 - app/platform/models/_unmanaged.py | 16 ------ app/users/tests/factories.py | 22 +++++++- 16 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 app/calendar/entites/__init__.py create mode 100644 app/calendar/entites/calendar_event.py create mode 100644 app/calendar/migrations/__init__.py create mode 100644 app/calendar/models.py create mode 100644 app/calendar/services/factories/__init__.py create mode 100644 app/calendar/services/factories/events/__init__.py create mode 100644 app/calendar/services/factories/events/base.py create mode 100644 app/calendar/services/factories/events/google.py create mode 100644 app/calendar/tests/__init__.py create mode 100644 app/calendar/tests/services/__init__.py create mode 100644 app/calendar/tests/services/factories/__init__.py create mode 100644 app/calendar/tests/services/factories/events/__init__.py create mode 100644 app/calendar/tests/services/factories/events/test_google.py delete mode 100644 app/platform/models/__init__.py delete mode 100644 app/platform/models/_unmanaged.py 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/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..36ef60fe --- /dev/null +++ b/app/calendar/models.py @@ -0,0 +1,13 @@ +from django.db import models + +from app.base.models.base import BaseModel +from app.users.models import User + + +class CalendarEvent(BaseModel): + start_time = models.DateTimeField() + end_time = models.DateTimeField() + title = models.TextField() + host = models.TextField() + guests = models.ManyToManyField(User) + google_event_uuid = models.UUIDField(unique=True) 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/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..d7443c4c --- /dev/null +++ b/app/calendar/tests/services/factories/events/test_google.py @@ -0,0 +1,45 @@ +from datetime import datetime, timedelta +from app.base.tests.base import BaseTest +from unittest.mock import patch, MagicMock +import uuid + +from app.calendar.entites.calendar_event import CalendarEventEntity +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()} + + calendar_event_entity = CalendarEventEntity( + host=host, + title="Test Event", + start_time=datetime.now(), + end_time=datetime.now() + timedelta(hours=1), + guests=guests, + ) + + factory = GoogleCalendarEventFactory() + + # Act + event_uuid = factory._create_event_in_google_calendar(calendar_event_entity) + + # Assert + self.assertTrue(isinstance(event_uuid, uuid.UUID)) + 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/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..9a5985fc 100644 --- a/app/users/tests/factories.py +++ b/app/users/tests/factories.py @@ -1,10 +1,13 @@ from django.contrib.auth.hashers import make_password from django.contrib.auth.models import Group +import factory 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 +from social_django.models import UserSocialAuth + class UserFactory(BaseFactory): email = Faker('email') @@ -34,3 +37,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(), + } + ) From d42f89c8559ff46e1b93d17b74b795aa120e211f Mon Sep 17 00:00:00 2001 From: Envy Date: Tue, 18 Jun 2024 14:23:14 +0300 Subject: [PATCH 03/12] add `GoogleCalendarEventFactory` with tests --- app/calendar/migrations/0001_initial.py | 32 +++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 app/calendar/migrations/0001_initial.py diff --git a/app/calendar/migrations/0001_initial.py b/app/calendar/migrations/0001_initial.py new file mode 100644 index 00000000..9c880f94 --- /dev/null +++ b/app/calendar/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.7 on 2024-06-17 14:46 + +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()), + ('host', models.TextField()), + ('google_event_uuid', models.UUIDField(unique=True)), + ('guests', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['pk'], + 'abstract': False, + }, + ), + ] From af7abeb5c79208c339360b07a9c2049fe9f9b89c Mon Sep 17 00:00:00 2001 From: Envy Date: Tue, 18 Jun 2024 14:27:47 +0300 Subject: [PATCH 04/12] delete old `GoogleCalendarEventFactory` --- app/calendar/factories/__init__.py | 0 app/calendar/factories/event.py | 48 ------------------------------ 2 files changed, 48 deletions(-) delete mode 100644 app/calendar/factories/__init__.py delete mode 100644 app/calendar/factories/event.py diff --git a/app/calendar/factories/__init__.py b/app/calendar/factories/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/app/calendar/factories/event.py b/app/calendar/factories/event.py deleted file mode 100644 index 4a793747..00000000 --- a/app/calendar/factories/event.py +++ /dev/null @@ -1,48 +0,0 @@ -import uuid -from datetime import datetime - -from googleapiclient.discovery import build - -from app.calendar.services.contexters.credentials import GoogleCredentialsContexter -from app.users.models import User - - -class GoogleCalendarEventFactory: - def __init__(self, contexter=GoogleCredentialsContexter()): - self.contexter = contexter - self.google_api_version = 'v3' - - def create( - self, - user: User, - summary: str, - description: str, - start_time: datetime, - end_time: datetime, - attendees: list[str], - ) -> dict: - with self.contexter.create_context(user.social_auth.get()) as context: - service = build( - serviceName='calendar', - version=self.google_api_version, - credentials=context.credentials, - ) - event = { - 'summary': summary, - 'description': description, - 'start': {'dateTime': start_time.isoformat()}, - 'end': {'dateTime': end_time.isoformat()}, - 'reminders': {'useDefault': False}, - 'attendees': [{'email': email} for email in attendees], - 'conferenceData': { - 'createRequest': { - 'conferenceSolutionKey': {'type': 'hangoutsMeet'}, - 'requestId': str(uuid.uuid4()), - } - }, - } - return ( - service.events() - .insert(calendarId='primary', body=event, conferenceDataVersion=1) - .execute() - ) From 502db114c1e8a86bf713063b0c6975f8e6af8fb7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:31:08 +0000 Subject: [PATCH 05/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- app/calendar/migrations/0001_initial.py | 10 +++++++++- .../tests/services/factories/events/test_google.py | 6 +++--- app/users/tests/factories.py | 5 ++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/calendar/migrations/0001_initial.py b/app/calendar/migrations/0001_initial.py index 9c880f94..02d3a563 100644 --- a/app/calendar/migrations/0001_initial.py +++ b/app/calendar/migrations/0001_initial.py @@ -16,7 +16,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name='CalendarEvent', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + '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()), diff --git a/app/calendar/tests/services/factories/events/test_google.py b/app/calendar/tests/services/factories/events/test_google.py index d7443c4c..35a0a622 100644 --- a/app/calendar/tests/services/factories/events/test_google.py +++ b/app/calendar/tests/services/factories/events/test_google.py @@ -1,8 +1,8 @@ -from datetime import datetime, timedelta -from app.base.tests.base import BaseTest -from unittest.mock import patch, MagicMock import uuid +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch +from app.base.tests.base import BaseTest from app.calendar.entites.calendar_event import CalendarEventEntity from app.calendar.services.factories.events.google import GoogleCalendarEventFactory from app.users.tests.factories import UserFactory, UserSocialAuthFactory diff --git a/app/users/tests/factories.py b/app/users/tests/factories.py index 9a5985fc..65fe1537 100644 --- a/app/users/tests/factories.py +++ b/app/users/tests/factories.py @@ -1,13 +1,12 @@ +import factory from django.contrib.auth.hashers import make_password from django.contrib.auth.models import Group -import factory +from social_django.models import UserSocialAuth from app.base.tests.factories.base import BaseFactory from app.base.tests.fakers import Faker, fake from app.users.models import User -from social_django.models import UserSocialAuth - class UserFactory(BaseFactory): email = Faker('email') From 2157c2186f239b5c6d3bccac83dbbc779272b51c Mon Sep 17 00:00:00 2001 From: Envy Date: Wed, 19 Jun 2024 13:00:47 +0300 Subject: [PATCH 06/12] add `TimezoneConverter` --- app/base/tests/fakers.py | 11 +++++-- app/base/views/base.py | 12 ++++++++ app/calendar/managers/__init__.py | 0 app/calendar/managers/event.py | 13 ++++++++ app/calendar/migrations/0001_initial.py | 7 +++-- app/calendar/models.py | 16 ++++++++-- app/calendar/services/converters/__init__.py | 0 app/calendar/services/converters/timezone.py | 17 +++++++++++ .../services/factories/events/test_google.py | 30 ++++++++++++++----- app/calendar/urls.py | 6 +--- app/calendar/views/__init__.py | 1 - app/calendar/views/general.py | 12 -------- 12 files changed, 91 insertions(+), 34 deletions(-) create mode 100644 app/calendar/managers/__init__.py create mode 100644 app/calendar/managers/event.py create mode 100644 app/calendar/services/converters/__init__.py create mode 100644 app/calendar/services/converters/timezone.py delete mode 100644 app/calendar/views/__init__.py delete mode 100644 app/calendar/views/general.py diff --git a/app/base/tests/fakers.py b/app/base/tests/fakers.py index 03fca0ea..f30c1237 100644 --- a/app/base/tests/fakers.py +++ b/app/base/tests/fakers.py @@ -1,13 +1,12 @@ from __future__ import annotations -import datetime from collections.abc import Callable, Sequence +import datetime from typing import Any, Final from django.core.files.base import ContentFile from factory import Faker as _FactoryFaker -from faker import Faker as _Faker -from faker import Generator +from faker import Faker as _Faker, Generator class SubFaker(_Faker): @@ -49,6 +48,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..5025cfcd 100644 --- a/app/base/views/base.py +++ b/app/base/views/base.py @@ -1,9 +1,12 @@ from __future__ import annotations +from typing import Protocol, runtime_checkable + from django.views.decorators.csrf import csrf_exempt 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 +17,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/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 index 9c880f94..b40853cd 100644 --- a/app/calendar/migrations/0001_initial.py +++ b/app/calendar/migrations/0001_initial.py @@ -1,7 +1,8 @@ -# Generated by Django 4.2.7 on 2024-06-17 14:46 +# Generated by Django 4.2.7 on 2024-06-19 09:52 from django.conf import settings from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -20,9 +21,9 @@ class Migration(migrations.Migration): ('start_time', models.DateTimeField()), ('end_time', models.DateTimeField()), ('title', models.TextField()), - ('host', models.TextField()), ('google_event_uuid', models.UUIDField(unique=True)), - ('guests', models.ManyToManyField(to=settings.AUTH_USER_MODEL)), + ('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'], diff --git a/app/calendar/models.py b/app/calendar/models.py index 36ef60fe..a5c8f5c4 100644 --- a/app/calendar/models.py +++ b/app/calendar/models.py @@ -1,6 +1,8 @@ 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 @@ -8,6 +10,16 @@ class CalendarEvent(BaseModel): start_time = models.DateTimeField() end_time = models.DateTimeField() title = models.TextField() - host = models.TextField() - guests = models.ManyToManyField(User) + 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/tests/services/factories/events/test_google.py b/app/calendar/tests/services/factories/events/test_google.py index d7443c4c..6876bedb 100644 --- a/app/calendar/tests/services/factories/events/test_google.py +++ b/app/calendar/tests/services/factories/events/test_google.py @@ -1,9 +1,10 @@ -from datetime import datetime, timedelta -from app.base.tests.base import BaseTest +from datetime import datetime, timedelta, timezone from unittest.mock import patch, MagicMock -import uuid +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 @@ -19,21 +20,34 @@ def test_create_event_in_google_calendar(self, mock_build): 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="Test Event", - start_time=datetime.now(), - end_time=datetime.now() + timedelta(hours=1), + title=fake.text(), + start_time=start_time.astimezone(tz), + end_time=end_time.astimezone(tz), guests=guests, ) factory = GoogleCalendarEventFactory() # Act - event_uuid = factory._create_event_in_google_calendar(calendar_event_entity) + calendar_event = factory.create_event(calendar_event_entity) # Assert - self.assertTrue(isinstance(event_uuid, uuid.UUID)) + 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', 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 9158b67d..00000000 --- a/app/calendar/views/general.py +++ /dev/null @@ -1,12 +0,0 @@ -from rest_framework.response import Response - -from app.base.views import BaseView -from app.calendar.factories.event import GoogleCalendarEventFactory - - -class CalendarView(BaseView): - def post(self): - user = self.request.user - google_calendar_event_factory = GoogleCalendarEventFactory() - event = google_calendar_event_factory.create(user) - return Response(data={'status': 'success', 'event': event}) From 46a59bd27c80d07a1621ed605ae55c9009b9508a Mon Sep 17 00:00:00 2001 From: Envy Date: Wed, 19 Jun 2024 13:13:27 +0300 Subject: [PATCH 07/12] add context to test log messages --- app/base/tests/base.py | 45 +++++++++++-------- .../services/factories/events/test_google.py | 2 +- 2 files changed, 28 insertions(+), 19 deletions(-) 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/calendar/tests/services/factories/events/test_google.py b/app/calendar/tests/services/factories/events/test_google.py index 6876bedb..8512d8d7 100644 --- a/app/calendar/tests/services/factories/events/test_google.py +++ b/app/calendar/tests/services/factories/events/test_google.py @@ -44,7 +44,7 @@ def test_create_event_in_google_calendar(self, mock_build): 'start_time': start_time, 'end_time': end_time, 'google_event_uuid': calendar_event.google_event_uuid, - 'host': host.id, + 'host': host, 'guests': list(guests), }, ) From ab6099fc5144a6c98b82b3b72691ed98e0bc5187 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 10:23:03 +0000 Subject: [PATCH 08/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- app/base/tests/fakers.py | 5 +++-- app/base/views/base.py | 1 - app/calendar/migrations/0001_initial.py | 19 ++++++++++++++++--- .../services/factories/events/test_google.py | 2 +- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/base/tests/fakers.py b/app/base/tests/fakers.py index f30c1237..0f4e5b4c 100644 --- a/app/base/tests/fakers.py +++ b/app/base/tests/fakers.py @@ -1,12 +1,13 @@ from __future__ import annotations -from collections.abc import Callable, Sequence import datetime +from collections.abc import Callable, Sequence from typing import Any, Final from django.core.files.base import ContentFile from factory import Faker as _FactoryFaker -from faker import Faker as _Faker, Generator +from faker import Faker as _Faker +from faker import Generator class SubFaker(_Faker): diff --git a/app/base/views/base.py b/app/base/views/base.py index 5025cfcd..de33e34f 100644 --- a/app/base/views/base.py +++ b/app/base/views/base.py @@ -1,6 +1,5 @@ from __future__ import annotations -from typing import Protocol, runtime_checkable from django.views.decorators.csrf import csrf_exempt from rest_framework import exceptions, status diff --git a/app/calendar/migrations/0001_initial.py b/app/calendar/migrations/0001_initial.py index 42e92790..a4e7f746 100644 --- a/app/calendar/migrations/0001_initial.py +++ b/app/calendar/migrations/0001_initial.py @@ -1,8 +1,8 @@ # 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 -import django.db.models.deletion class Migration(migrations.Migration): @@ -30,8 +30,21 @@ class Migration(migrations.Migration): ('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)), + ( + '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'], diff --git a/app/calendar/tests/services/factories/events/test_google.py b/app/calendar/tests/services/factories/events/test_google.py index 8512d8d7..fe5324b7 100644 --- a/app/calendar/tests/services/factories/events/test_google.py +++ b/app/calendar/tests/services/factories/events/test_google.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from app.base.tests.base import BaseTest from app.base.tests.fakers import fake From e238981b653079efe36ff70403f18fd785bb753f Mon Sep 17 00:00:00 2001 From: Envy Date: Wed, 19 Jun 2024 22:14:22 +0300 Subject: [PATCH 09/12] add `BookingSlotsFactory` --- app/bookings/entites/__init__.py | 0 app/bookings/entites/slot.py | 9 +++ app/bookings/services/__init__.py | 0 app/bookings/services/factories/__init__.py | 0 app/bookings/services/factories/slots.py | 45 +++++++++++ app/bookings/tests/services/__init__.py | 0 .../tests/services/factories/__init__.py | 0 .../tests/services/factories/test_slots.py | 75 +++++++++++++++++++ app/calendar/tests/factories.py | 29 +++++++ .../services/factories/events/test_google.py | 2 +- 10 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 app/bookings/entites/__init__.py create mode 100644 app/bookings/entites/slot.py create mode 100644 app/bookings/services/__init__.py create mode 100644 app/bookings/services/factories/__init__.py create mode 100644 app/bookings/services/factories/slots.py create mode 100644 app/bookings/tests/services/__init__.py create mode 100644 app/bookings/tests/services/factories/__init__.py create mode 100644 app/bookings/tests/services/factories/test_slots.py create mode 100644 app/calendar/tests/factories.py 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/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/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..c96a0407 --- /dev/null +++ b/app/bookings/services/factories/slots.py @@ -0,0 +1,45 @@ +from datetime import datetime, date, timezone, timedelta + +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..6508c40d --- /dev/null +++ b/app/bookings/tests/services/factories/test_slots.py @@ -0,0 +1,75 @@ +from datetime import datetime, date, timezone, timedelta + +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/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/factories/events/test_google.py b/app/calendar/tests/services/factories/events/test_google.py index 8512d8d7..6876bedb 100644 --- a/app/calendar/tests/services/factories/events/test_google.py +++ b/app/calendar/tests/services/factories/events/test_google.py @@ -44,7 +44,7 @@ def test_create_event_in_google_calendar(self, mock_build): 'start_time': start_time, 'end_time': end_time, 'google_event_uuid': calendar_event.google_event_uuid, - 'host': host, + 'host': host.id, 'guests': list(guests), }, ) From 493de09057dc8824f4c94c2db43dfde100cb6eea Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Jun 2024 19:14:54 +0000 Subject: [PATCH 10/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- app/base/views/base.py | 1 - app/bookings/services/factories/slots.py | 2 +- app/bookings/tests/services/factories/test_slots.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/base/views/base.py b/app/base/views/base.py index de33e34f..3c62bed7 100644 --- a/app/base/views/base.py +++ b/app/base/views/base.py @@ -1,6 +1,5 @@ from __future__ import annotations - from django.views.decorators.csrf import csrf_exempt from rest_framework import exceptions, status from rest_framework.generics import GenericAPIView diff --git a/app/bookings/services/factories/slots.py b/app/bookings/services/factories/slots.py index c96a0407..2bae2b15 100644 --- a/app/bookings/services/factories/slots.py +++ b/app/bookings/services/factories/slots.py @@ -1,4 +1,4 @@ -from datetime import datetime, date, timezone, timedelta +from datetime import date, datetime, timedelta, timezone from django.db import models diff --git a/app/bookings/tests/services/factories/test_slots.py b/app/bookings/tests/services/factories/test_slots.py index 6508c40d..b65a2f54 100644 --- a/app/bookings/tests/services/factories/test_slots.py +++ b/app/bookings/tests/services/factories/test_slots.py @@ -1,4 +1,4 @@ -from datetime import datetime, date, timezone, timedelta +from datetime import date, datetime, timedelta, timezone from app.base.tests.base import BaseTest from app.bookings.services.factories.slots import BookingSlotsFactory From 7e4c19a9ad96a2426735984fadb558f32595e87c Mon Sep 17 00:00:00 2001 From: Envy Date: Fri, 21 Jun 2024 14:23:26 +0300 Subject: [PATCH 11/12] add `BookingsSlotsTrialView` --- app/base/serializers/fields/image.py | 3 +- app/base/serializers/fields/timezone.py | 34 ++++++++ app/bookings/entites/booking_type.py | 8 ++ app/bookings/enums/__init__.py | 0 app/bookings/enums/booking_type.py | 18 +++++ app/bookings/serializers/slots/__init__.py | 0 app/bookings/serializers/slots/trial.py | 22 ++++++ app/bookings/tests/views/slots/__init__.py | 0 app/bookings/tests/views/slots/test_trial.py | 82 ++++++++++++++++++++ app/bookings/urls.py | 2 + app/bookings/views/slots/__init__.py | 0 app/bookings/views/slots/trial.py | 64 +++++++++++++++ 12 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 app/base/serializers/fields/timezone.py create mode 100644 app/bookings/entites/booking_type.py create mode 100644 app/bookings/enums/__init__.py create mode 100644 app/bookings/enums/booking_type.py create mode 100644 app/bookings/serializers/slots/__init__.py create mode 100644 app/bookings/serializers/slots/trial.py create mode 100644 app/bookings/tests/views/slots/__init__.py create mode 100644 app/bookings/tests/views/slots/test_trial.py create mode 100644 app/bookings/views/slots/__init__.py create mode 100644 app/bookings/views/slots/trial.py 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..93c95860 --- /dev/null +++ b/app/base/serializers/fields/timezone.py @@ -0,0 +1,34 @@ +from datetime import timedelta, timezone, datetime +import re +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/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/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..9a50abed --- /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 TrialBooking, HourlyBooking + + +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/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..d2a527c5 --- /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 ( + QueryParamsGETBookingsSlotsTrialSerializer, + GETBookingsSlotsTrialSerializer, +) +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) From b5001761c012f68aa9331c674406b7e10db6ac18 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 21 Jun 2024 11:23:47 +0000 Subject: [PATCH 12/12] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- app/base/serializers/fields/timezone.py | 2 +- app/bookings/enums/booking_type.py | 2 +- app/bookings/views/slots/trial.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/base/serializers/fields/timezone.py b/app/base/serializers/fields/timezone.py index 93c95860..55af9727 100644 --- a/app/base/serializers/fields/timezone.py +++ b/app/base/serializers/fields/timezone.py @@ -1,5 +1,5 @@ -from datetime import timedelta, timezone, datetime import re +from datetime import datetime, timedelta, timezone from typing import Final from rest_framework import serializers diff --git a/app/bookings/enums/booking_type.py b/app/bookings/enums/booking_type.py index 9a50abed..21a1a505 100644 --- a/app/bookings/enums/booking_type.py +++ b/app/bookings/enums/booking_type.py @@ -2,7 +2,7 @@ from typing import Final from app.bookings.entites.booking_type import BookingTypeEntity -from app.bookings.models import TrialBooking, HourlyBooking +from app.bookings.models import HourlyBooking, TrialBooking class BookingTypeEnum(Enum): diff --git a/app/bookings/views/slots/trial.py b/app/bookings/views/slots/trial.py index d2a527c5..ae617bbf 100644 --- a/app/bookings/views/slots/trial.py +++ b/app/bookings/views/slots/trial.py @@ -8,8 +8,8 @@ from app.base.utils.schema import extend_schema from app.base.views import BaseView from app.bookings.serializers.slots.trial import ( - QueryParamsGETBookingsSlotsTrialSerializer, GETBookingsSlotsTrialSerializer, + QueryParamsGETBookingsSlotsTrialSerializer, ) from app.bookings.services.factories.slots import BookingSlotsFactory from app.users.permissions import AuthenticatedPermission