diff --git a/docs/assets/wait_queue_management_1.png b/docs/assets/wait_queue_management_1.png new file mode 100644 index 00000000..e53f999a Binary files /dev/null and b/docs/assets/wait_queue_management_1.png differ diff --git a/docs/assets/wait_queue_management_2.png b/docs/assets/wait_queue_management_2.png new file mode 100644 index 00000000..2f0c78fc Binary files /dev/null and b/docs/assets/wait_queue_management_2.png differ diff --git a/docs/assets/wait_queue_management_3.png b/docs/assets/wait_queue_management_3.png new file mode 100644 index 00000000..b873f591 Binary files /dev/null and b/docs/assets/wait_queue_management_3.png differ diff --git a/docs/assets/wait_queue_management_4.png b/docs/assets/wait_queue_management_4.png new file mode 100644 index 00000000..45ba78ec Binary files /dev/null and b/docs/assets/wait_queue_management_4.png differ diff --git a/docs/assets/wait_queue_management_5.png b/docs/assets/wait_queue_management_5.png new file mode 100644 index 00000000..d5a0b2ae Binary files /dev/null and b/docs/assets/wait_queue_management_5.png differ diff --git a/docs/assets/wait_queue_management_6.png b/docs/assets/wait_queue_management_6.png new file mode 100644 index 00000000..cee6e0a5 Binary files /dev/null and b/docs/assets/wait_queue_management_6.png differ diff --git a/docs/assets/wait_queue_management_7.png b/docs/assets/wait_queue_management_7.png new file mode 100644 index 00000000..115e12ff Binary files /dev/null and b/docs/assets/wait_queue_management_7.png differ diff --git a/docs/assets/wait_queue_management_8.png b/docs/assets/wait_queue_management_8.png new file mode 100644 index 00000000..c116b077 Binary files /dev/null and b/docs/assets/wait_queue_management_8.png differ diff --git a/docs/use_cases/wait_queue_management.md b/docs/use_cases/wait_queue_management.md new file mode 100644 index 00000000..d67c085c --- /dev/null +++ b/docs/use_cases/wait_queue_management.md @@ -0,0 +1,104 @@ +# Wait Queue Management + +This document is here to illustrate the wait queue functionnality of this system. + +## Terminology + +| Term | Definition +|------------------------------ |-------------------------- +| **WaitQueue** | A wait queue is waiting place of a user for the specific retreat. WaitQueues are ordered by creation date (first in, first out) +| **WaitQueuePlace** | A WaitQueuePlace is a retirement place available for waiting people. Each time a new WaitQueuePlace is created, the system will begin notify people in order to fill it and find a new customer. +| **WaitQueuePlaceReserved** | A WaitQueuePlaceReserved is kind of a ticket authorization that we give to waiting people to say that they have the right to fill a WaitQueuePlace. The system create WaitQueuePlaceReserverd each 24h for the next customer in line. + +## Process + +### Initialisation + +At the beginning, there is nothing. + +We can see on the left the WaitQueue (people in queue) and on the bottom the new place that we need to fill. + +![Capture](../assets/wait_queue_management_1.png) + +### A reservation is cancelled + +When a reservation is cancelled, the system will automatically create a first WaitQueuePlace in order to notify waiting people and find a new customer. + +Here we can see that the first user has automatically been notified, he now have a WaitQueuePlaceReserved + +![Capture](../assets/wait_queue_management_2.png) + +### Nothing new, time fly + +If the first user in queue (WaitQueue 1) don't buy the retirement's place available for him, the system automatically notify the second user in queue in order to find a customer motivated with the product. + +Here we can see that the second user has automatically been notified, he now have a WaitQueuePlaceReserved + +NB: At this step, both users can reserve the WaitQueuePlace and fill the empty seat of the retirement. The second user need to be fast if he don't want to be notified for nothing. + +![Capture](../assets/wait_queue_management_3.png) + +### A second reservation is cancelled + +Let's imagine that a second reservation is now cancelled: + + - The first WaitQueuePlace is not filled for the moment + - A second WaitQueuePlace is created and we need to find a customer + - User 1 & 2 are already notified that they are allowed to buy a place + +Since there is no reason to re-notify user 1 & 2, the system will automatically notify the third user. + +NB: We can see that we create WaitQueuePlaceReserved for user 1 & 2 but without notification (ie: emails) to allow us to reserve the second place but without being spammed with out notification. + +![Capture](../assets/wait_queue_management_4.png) + +### User 2 reserve a place + +Now, let's see what's going on if user 2 want to reserve a place to the retirement: + + - The first WaitQueuePlace is now filled + - WaitingQueuePlaceReserved for user 1 on place 1 is now unavailable + +Since user 1 has already been notify at the beginning and that he is always authorize to reserve a place, there is no need to notify him. + +NB: At this step we now have only one place available for waiting user. + +![Capture](../assets/wait_queue_management_5.png) + + +### Notification interval is done + +After the notification interval (24H), what will be the next step for the automatic system ? : + + - Only one WaitingQueuePlace is available (#2) + - User 1 & 3 are already notified + - User 2 is already participant on the retirement + - We will notify the next user on queue (user 4) + +We are exactly in the same case as when we notified user 2 for the WaitingQueuePlace 1. + +![Capture](../assets/wait_queue_management_6.png) + +### User 3 reserve a place + +Now, let's imagine we are on a good day and user 3 reserve a place (24H): + + - The second WaitQueuePlace is now filled + - WaitingQueuePlaceReserved for all users of WaitingQueuePlace2 are now unavailable + +Since there is no more place available, the waiting queue is just stopped until a new reservation will be cancelled + +![Capture](../assets/wait_queue_management_7.png) + +### An other reservation is cancelled + +If a new retirement cancellation occur: + + - A new WaitingQueuePlace is created (as always) + - A WaitingQueuePlaceReserved is created for user 1 + - User 1 is notify since he have now a new chance to reserve a place + +This step is kind of a new beginning since all the users lost their right to reserve a place before. We need to re-notify all of them, one by one. + +![Capture](../assets/wait_queue_management_8.png) + diff --git a/retirement/admin.py b/retirement/admin.py index ea0884e4..5cb41faa 100644 --- a/retirement/admin.py +++ b/retirement/admin.py @@ -135,6 +135,7 @@ class WaitQueuePlaceReservedInline(admin.StackedInline): model = WaitQueuePlaceReserved can_delete = True show_change_link = True + autocomplete_fields = ('user', 'wait_queue_place') verbose_name_plural = _('Wait Queue places reserved') @@ -160,10 +161,14 @@ class WaitQueuePlaceReservedAdmin(admin.ModelAdmin): 'wait_queue_place', 'user', 'create', - 'notified' + 'notified', + 'used', ) list_filter = ( + 'wait_queue_place__retreat', 'wait_queue_place', + 'notified', + 'used', 'user' ) autocomplete_fields = ('user', 'wait_queue_place') diff --git a/retirement/migrations/0024_auto_20200123_0515.py b/retirement/migrations/0024_auto_20200123_0515.py new file mode 100644 index 00000000..dabc3286 --- /dev/null +++ b/retirement/migrations/0024_auto_20200123_0515.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.8 on 2020-01-23 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0023_auto_20191222_0347'), + ] + + operations = [ + migrations.AddField( + model_name='historicalwaitqueue', + name='used', + field=models.BooleanField(default=False, verbose_name='Used'), + ), + migrations.AddField( + model_name='waitqueue', + name='used', + field=models.BooleanField(default=False, verbose_name='Used'), + ), + migrations.AddField( + model_name='waitqueueplacereserved', + name='used', + field=models.BooleanField(default=False, verbose_name='Used'), + ), + ] diff --git a/retirement/models.py b/retirement/models.py index aa03e30b..6ae104d4 100644 --- a/retirement/models.py +++ b/retirement/models.py @@ -346,24 +346,35 @@ def get_wait_queue_place_reserved(self, user): def check_and_use_reserved_place(self, user): wait_queue_place = self.get_wait_queue_place_reserved(user) if wait_queue_place: - self.wait_queue.filter(user=user).delete() + wait_queues = self.wait_queue.filter(user=user) + for wait_queue in wait_queues: + wait_queue.used = True + wait_queue.save() + wait_queue_place.available = False wait_queue_place.save() - WaitQueuePlaceReserved.objects.filter( + user_places_reserved = WaitQueuePlaceReserved.objects.filter( wait_queue_place__retreat=self, user=user - ).delete() + ) + for user_place_reserved in user_places_reserved: + user_place_reserved.used = True + user_place_reserved.save() def can_order_the_retreat(self, user, invitation=None): - has_raimimng_place = self.has_places_remaining(invitation) + has_remaining_place = self.has_places_remaining(invitation) wait_queue_place = self.get_wait_queue_place_reserved(user) has_reserved_place = wait_queue_place is not None - can_order_the_retreat = has_raimimng_place or has_reserved_place + can_order_the_retreat = has_remaining_place or has_reserved_place return can_order_the_retreat + def get_datetime_refund(self): + return self.start_time - timedelta( + days=self.min_day_refund) + class Picture(models.Model): """Represents pictures representing a retreat place""" @@ -559,6 +570,11 @@ class Meta: related_name='wait_queue', ) + used = models.BooleanField( + verbose_name=_("Used"), + default=False + ) + created_at = models.DateTimeField(auto_now_add=True) history = HistoricalRecords() @@ -665,34 +681,45 @@ class WaitQueuePlace(models.Model): def __str__(self): return f'{self.retreat} {self.pk}' - def notify(self): - # Get all user that have no wait_queue_places_reserved - # for this WaitQueuePlace - + def get_user_without_places_reserved(self): wait_queue_places_reserved_ids = \ self.wait_queue_places_reserved.filter( - notified=True).values('user_id') + used=False).values('user_id') - retreat_wait_queues = self.retreat.wait_queue \ + retreat_wait_queues = self.retreat.wait_queue\ + .filter(used=False) \ .exclude(user_id__in=wait_queue_places_reserved_ids) \ .order_by('created_at') - datetime_refund = self.retreat.start_time - timedelta( - days=self.retreat.min_day_refund) - # if we are after the refund delay, we notify every waiting user - less_than_min_day_refund = timezone.now() >= datetime_refund + return retreat_wait_queues - stop = timezone.now() >= self.retreat.start_time + def notify(self): users_notified = [] + # Stop the notification process if place not available + if not self.available: + return 'Wait queue place not available', True + + stop = timezone.now() >= self.retreat.start_time if stop: - return users_notified, stop + return 'Retreat already started', stop + + # Get all user that have no wait_queue_places_reserved + # for this WaitQueuePlace + retreat_wait_queues = self.get_user_without_places_reserved() + + # if we are after the refund delay, we notify every waiting user + less_than_min_day_refund = \ + timezone.now() >= self.retreat.get_datetime_refund() + for wait_queue in retreat_wait_queues: + # check if the user is already notified for this retreat user_already_notified = WaitQueuePlaceReserved.objects.filter( - wait_queue_place__available=True, user=wait_queue.user, notified=True, + used=False, + wait_queue_place__available=True, wait_queue_place__retreat=self.retreat ).exists() @@ -734,6 +761,11 @@ class WaitQueuePlaceReserved(models.Model): default=False ) + used = models.BooleanField( + verbose_name=_("Used"), + default=False + ) + def __str__(self): return f'{self.wait_queue_place}-{self.user}' diff --git a/retirement/tests/tests_Wait_Queue_Place.py b/retirement/tests/tests_Wait_Queue_Place.py index 04546f5f..7203eee9 100644 --- a/retirement/tests/tests_Wait_Queue_Place.py +++ b/retirement/tests/tests_Wait_Queue_Place.py @@ -19,12 +19,12 @@ class RetreatTests(APITestCase): def setUp(self) -> None: self.admin = AdminFactory() - self.user1 = UserFactory() - self.user2 = UserFactory() - self.user3 = UserFactory() - self.user4 = UserFactory() - self.user5 = UserFactory() - self.user6 = UserFactory() + self.user1 = UserFactory(email='user1@test.com') + self.user2 = UserFactory(email='user2@test.com') + self.user3 = UserFactory(email='user3@test.com') + self.user4 = UserFactory(email='user4@test.com') + self.user5 = UserFactory(email='user5@test.com') + self.user6 = UserFactory(email='user6@test.com') self.user_cancel = UserFactory() self.retreat = RetreatFactory() @@ -36,60 +36,89 @@ def setUp(self) -> None: cancel_by=self.user_cancel ) - self.wait_queue1 = WaitQueue.objects.create( + self.wait_queue_user1 = WaitQueue.objects.create( retreat=self.retreat, user=self.user1 ) - self.wait_queue2 = WaitQueue.objects.create( + self.wait_queue_user2 = WaitQueue.objects.create( retreat=self.retreat, user=self.user2 ) - self.wait_queue3 = WaitQueue.objects.create( + self.wait_queue_user3 = WaitQueue.objects.create( retreat=self.retreat, user=self.user3 ) - self.wait_queue4 = WaitQueue.objects.create( + self.wait_queue_user4 = WaitQueue.objects.create( retreat=self.retreat, user=self.user4 ) - self.wait_queue5 = WaitQueue.objects.create( + self.wait_queue_user5 = WaitQueue.objects.create( retreat=self.retreat, user=self.user5 ) - self.wait_queue6 = WaitQueue.objects.create( + self.wait_queue_user6 = WaitQueue.objects.create( retreat=self.retreat, user=self.user6 ) - def test_notify_wait_queue_place(self): - self.wait_queue_place.notify() - + def check_user_has_reserved_place_notify( + self, + user, + wait_queue_place): self.assertTrue( WaitQueuePlaceReserved.objects.filter( - user=self.user1, + user=user, notified=True, - wait_queue_place=self.wait_queue_place + used=False, + wait_queue_place=wait_queue_place ).exists() ) - self.wait_queue_place.notify() - + def check_user_has_reserved_place( + self, + user, + wait_queue_place): self.assertTrue( WaitQueuePlaceReserved.objects.filter( - user=self.user2, - notified=True, - wait_queue_place=self.wait_queue_place + user=user, + notified=False, + used=False, + wait_queue_place=wait_queue_place ).exists() ) - self.assertTrue( + def check_count_wait_queue_place(self, wait_queue_place, count): + self.assertEquals( WaitQueuePlaceReserved.objects.filter( - user=self.user1, - notified=True, - wait_queue_place=self.wait_queue_place - ).exists() + wait_queue_place=wait_queue_place, + used=False, + ).count(), + count + ) + + def check_place_reserved_used(self, wait_queue_place, user): + self.assertTrue( + WaitQueuePlaceReserved.objects.get( + user=user, + wait_queue_place=wait_queue_place + ).used ) + def test_notify_wait_queue_place(self): + self.wait_queue_place.notify() + + self.check_user_has_reserved_place_notify(self.user1, + self.wait_queue_place) + self.check_count_wait_queue_place(self.wait_queue_place, 1) + + self.wait_queue_place.notify() + + self.check_user_has_reserved_place_notify(self.user2, + self.wait_queue_place) + self.check_user_has_reserved_place_notify(self.user1, + self.wait_queue_place) + self.check_count_wait_queue_place(self.wait_queue_place, 2) + wait_queue_place2 = WaitQueuePlace.objects.create( retreat=self.retreat, cancel_by=self.user_cancel @@ -97,88 +126,38 @@ def test_notify_wait_queue_place(self): wait_queue_place2.notify() - self.assertTrue( - WaitQueuePlaceReserved.objects.filter( - user=self.user3, - notified=True, - wait_queue_place=wait_queue_place2 - ).exists() - ) - - self.assertTrue( - WaitQueuePlaceReserved.objects.filter( - user=self.user2, - notified=False, - wait_queue_place=wait_queue_place2 - ).exists() - ) - - self.assertTrue( - WaitQueuePlaceReserved.objects.filter( - user=self.user1, - notified=False, - wait_queue_place=wait_queue_place2 - ).exists() - ) + self.check_user_has_reserved_place_notify(self.user3, + wait_queue_place2) + self.check_user_has_reserved_place(self.user2, wait_queue_place2) + self.check_user_has_reserved_place(self.user1, wait_queue_place2) + self.check_count_wait_queue_place(wait_queue_place2, 3) self.retreat.check_and_use_reserved_place(self.user2) - self.assertFalse( - WaitQueuePlaceReserved.objects.filter( - user=self.user2, - wait_queue_place=wait_queue_place2 - ).exists() - ) - wait_queue_place2.notify() + self.check_place_reserved_used(wait_queue_place2, self.user2) + self.check_place_reserved_used(self.wait_queue_place, self.user2) + self.wait_queue_user2.refresh_from_db() self.assertTrue( - WaitQueuePlaceReserved.objects.filter( - user=self.user3, - notified=True, - wait_queue_place=wait_queue_place2 - ).exists() - ) - - self.assertTrue( - WaitQueuePlaceReserved.objects.filter( - user=self.user1, - notified=True, - wait_queue_place=wait_queue_place2 - ).exists() + self.wait_queue_user2.used ) + self.wait_queue_place.refresh_from_db() self.assertFalse( - WaitQueuePlaceReserved.objects.filter( - user=self.user4, - wait_queue_place=wait_queue_place2 - ).exists() + self.wait_queue_place.available ) - wait_queue_place2.notify() + detail, stop = self.wait_queue_place.notify() + self.assertTrue(stop) + self.assertEqual(detail, 'Wait queue place not available') - self.assertTrue( - WaitQueuePlaceReserved.objects.filter( - user=self.user3, - notified=True, - wait_queue_place=wait_queue_place2 - ).exists() - ) - - self.assertTrue( - WaitQueuePlaceReserved.objects.filter( - user=self.user1, - notified=True, - wait_queue_place=wait_queue_place2 - ).exists() - ) - - self.assertTrue( - WaitQueuePlaceReserved.objects.filter( - user=self.user4, - notified=True, - wait_queue_place=wait_queue_place2 - ).exists() - ) + wait_queue_place2.notify() + self.check_user_has_reserved_place_notify(self.user4, + wait_queue_place2) + self.check_user_has_reserved_place_notify(self.user3, + wait_queue_place2) + self.check_user_has_reserved_place(self.user1, wait_queue_place2) + self.check_count_wait_queue_place(wait_queue_place2, 3) FIXED_TIME = self.retreat.start_time - timedelta(days=2) @@ -189,12 +168,23 @@ def test_notify_wait_queue_place(self): self.assertIn(self.user6.email, users_notified) self.assertFalse(stop) + self.check_user_has_reserved_place_notify(self.user6, + wait_queue_place2) + self.check_user_has_reserved_place_notify(self.user5, + wait_queue_place2) + self.check_user_has_reserved_place_notify(self.user4, + wait_queue_place2) + self.check_user_has_reserved_place_notify(self.user3, + wait_queue_place2) + self.check_user_has_reserved_place(self.user1, wait_queue_place2) + self.check_count_wait_queue_place(wait_queue_place2, 5) + FIXED_TIME = self.retreat.start_time + timedelta(days=2) with mock.patch( 'django.utils.timezone.now', return_value=FIXED_TIME): - users_notified, stop = wait_queue_place2.notify() - self.assertEqual(len(users_notified), 0) + detail, stop = wait_queue_place2.notify() + self.assertEqual(detail, 'Retreat already started') self.assertTrue(stop) def test_view_notify_wait_queue_place(self): @@ -209,7 +199,7 @@ def test_view_notify_wait_queue_place(self): self.assertEqual( response.status_code, - status.HTTP_200_OK, + status.HTTP_202_ACCEPTED, response.content ) @@ -217,6 +207,7 @@ def test_view_notify_wait_queue_place(self): content = { 'detail': [self.user1.email], + 'wait_queue_place': self.wait_queue_place.id, 'stop': False } @@ -239,7 +230,7 @@ def test_view_notify_wait_queue_place(self): self.assertEqual( response.status_code, - status.HTTP_200_OK, + status.HTTP_429_TOO_MANY_REQUESTS, response.content ) @@ -247,6 +238,7 @@ def test_view_notify_wait_queue_place(self): content = { 'detail': "Last notification was sent less than 24h ago.", + 'wait_queue_place': self.wait_queue_place.id, } self.assertEqual(response_data, content) diff --git a/retirement/tests/tests_viewset_WaitQueue.py b/retirement/tests/tests_viewset_WaitQueue.py index 3383c8b6..79674cb6 100644 --- a/retirement/tests/tests_viewset_WaitQueue.py +++ b/retirement/tests/tests_viewset_WaitQueue.py @@ -89,6 +89,7 @@ def test_create(self): str(self.retreat.id), 'user': ''.join(['http://testserver/users/', str(self.user.id)]), 'created_at': json.loads(response.content)['created_at'], + 'used': False, } response_data = json.loads(response.content) @@ -131,6 +132,7 @@ def test_create_as_admin_for_user(self): 'retreat': 'http://testserver/retreat/retreats/' + str(self.retreat.id), 'user': ''.join(['http://testserver/users/', str(self.user.id)]), + 'used': False, } response_data = json.loads(response.content) @@ -354,6 +356,7 @@ def test_list(self): 'url': 'http://testserver/retreat/wait_queues/' + str(self.wait_queue_subscription.id), + 'used': False, 'user': 'http://testserver/users/' + str(self.user2.id) }] } @@ -404,6 +407,7 @@ def test_read(self): str(self.wait_queue_subscription.id), 'user': ''.join(['http://testserver/users/', str(self.user2.id)]), 'created_at': json.loads(response.content)['created_at'], + 'used': False, } self.assertEqual(json.loads(response.content), content) @@ -457,6 +461,7 @@ def test_read_as_admin(self): str(self.wait_queue_subscription.id), 'user': ''.join(['http://testserver/users/', str(self.user2.id)]), 'created_at': json.loads(response.content)['created_at'], + 'used': False, } self.assertEqual(response_data, content) diff --git a/retirement/views.py b/retirement/views.py index 73c59bff..67bc3517 100644 --- a/retirement/views.py +++ b/retirement/views.py @@ -512,14 +512,21 @@ def notify(self, request, pk=None): detail, stop = wait_queue_place.notify() response_data = { 'detail': detail, + 'wait_queue_place': wait_queue_place.id, 'stop': stop, } - return Response(response_data, status=status.HTTP_200_OK) + if stop: + return Response(response_data, status=status.HTTP_200_OK) + else: + return Response(response_data, status=status.HTTP_202_ACCEPTED) else: response_data = { + 'wait_queue_place': wait_queue_place.id, 'detail': "Last notification was sent less than 24h ago." } - return Response(response_data, status=status.HTTP_200_OK) + return Response( + response_data, + status=status.HTTP_429_TOO_MANY_REQUESTS) class WaitQueuePlaceReservedViewSet(mixins.ListModelMixin, diff --git a/store/models.py b/store/models.py index 36b5b490..e6344eff 100644 --- a/store/models.py +++ b/store/models.py @@ -641,7 +641,7 @@ class Coupon(AbstractCoupon): owner = models.ForeignKey( User, on_delete=models.CASCADE, - verbose_name=_("Owner"), + verbose_name=_("User"), related_name='coupons', )