diff --git a/.travis.yml b/.travis.yml index dcf540f8..ef7aca3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ before_install: - python3 -c 'import os,sys; os.set_blocking(sys.stdout.fileno(), True)' install: - docker-compose build + - pip install --upgrade pip - pip install -r requirements-dev.txt before_script: # We will run a postgres service into docker, so wee need to stop the postgres service diff --git a/blitz_api/factories.py b/blitz_api/factories.py index 5153d647..a70d449a 100644 --- a/blitz_api/factories.py +++ b/blitz_api/factories.py @@ -1,6 +1,7 @@ from datetime import timedelta import factory +from factory.django import DjangoModelFactory import factory.fuzzy from dateutil.tz import tz from django.contrib.auth import get_user_model @@ -15,7 +16,7 @@ fake = Faker() -class UserFactory(factory.DjangoModelFactory): +class UserFactory(DjangoModelFactory): class Meta: model = User django_get_or_create = ('username',) @@ -29,7 +30,7 @@ class Meta: tickets = 1 -class AdminFactory(factory.DjangoModelFactory): +class AdminFactory(DjangoModelFactory): class Meta: model = User @@ -42,28 +43,28 @@ class Meta: tickets = 1 -class OrganizationFactory(factory.DjangoModelFactory): +class OrganizationFactory(DjangoModelFactory): class Meta: model = Organization name = factory.Sequence(lambda n: f'Organization {n}') -class AcademicLevelFactory(factory.DjangoModelFactory): +class AcademicLevelFactory(DjangoModelFactory): class Meta: model = AcademicLevel name = factory.Sequence(lambda n: f'AcademicLevel {n}') -class AcademicFieldFactory(factory.DjangoModelFactory): +class AcademicFieldFactory(DjangoModelFactory): class Meta: model = AcademicField name = factory.Sequence(lambda n: f'AcademicField {n}') -class RetreatFactory(factory.DjangoModelFactory): +class RetreatFactory(DjangoModelFactory): class Meta: model = Retreat django_get_or_create = ('name',) diff --git a/blitz_api/settings.py b/blitz_api/settings.py index 785cc284..6785a05b 100644 --- a/blitz_api/settings.py +++ b/blitz_api/settings.py @@ -56,7 +56,6 @@ 'storages', 'anymail', 'simple_history', - 'rest_framework_filters', 'safedelete', 'import_export', 'django_filters', @@ -387,7 +386,7 @@ ), 'POLICY_URL': config( 'POLICY_URL', - default='http://thesez-vous.org/policy', + default='https://www.thesez-vous.com/politiquesannulation.html', ), 'ACTIVATION_URL': config( 'ACTIVATION_URL', diff --git a/requirements-dev.txt b/requirements-dev.txt index 8eeee5c0..ed154bff 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -pycodestyle==2.5.0 -coveralls==2.0.0 -responses==0.10.14 -awscli==1.18.43 -six==1.14.0 +pycodestyle==2.6.0 +coveralls==2.1.2 +responses==0.12.0 +awscli==1.18.130 +six==1.15.0 diff --git a/requirements.txt b/requirements.txt index bc36363b..4a8e303d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,32 +1,31 @@ # Documentation tools mkdocs==1.1.2 -mkdocs-material==5.2.3 +mkdocs-material==5.5.12 -Django==2.2.12 -djangorestframework==3.11.0 -django-cors-headers==3.2.1 -django-filter==2.2.0 +Django==2.2.13 +djangorestframework==3.11.1 +django-cors-headers==3.5.0 +django-filter==2.3.0 coreapi==2.3.3 -factory-boy==2.12.0 +factory-boy==3.0.1 Pygments==2.6.1 -Markdown==3.2.1 -django-anymail==7.1.0 -Pillow==7.1.1 -django-simple-history==2.8.0 -djangorestframework-filters==0.11.1 +Markdown==3.2.2 +django-anymail==7.2.1 +Pillow==7.2.0 +django-simple-history==2.11.0 python-decouple==3.3 -django-storages==1.9.1 +django-storages==1.10 dj_database_url==0.5.0 zappa==0.51.0 psycopg2-binary==2.8.5 django-safedelete==0.5.2 -e git+https://github.com/Rhumbix/django-request-logging.git@9342ee6064e678fd162418b142d781550d23101c#egg=django_request_logging -e git+https://github.com/deschler/django-modeltranslation.git@c8bda494a8cd36b393811552aeee71faf86d7438#egg=django-modeltranslation -django-import-export==2.0.2 +django-import-export==2.3.0 jsonfield==3.1.0 django-model-utils==4.0.0 -tqdm==4.45.0 +tqdm==4.48.2 colorama==0.4.3 -django-admin-autocomplete-filter==0.5 -mailchimp3==3.0.13 +django-admin-autocomplete-filter==0.6.1 +mailchimp3==3.0.14 babel==2.8.0 \ No newline at end of file diff --git a/retirement/serializers.py b/retirement/serializers.py index 0fc4318c..c9ea61cc 100644 --- a/retirement/serializers.py +++ b/retirement/serializers.py @@ -385,15 +385,22 @@ def validate(self, attrs): active_reservations = active_reservations.all() for reservation in active_reservations: - latest_start = max(reservation.retreat.start_time, start) - shortest_end = min(reservation.retreat.end_time, end) - if latest_start < shortest_end: - raise serializers.ValidationError({ - 'non_field_errors': [_( - "This reservation overlaps with another active " - "reservations for this user." - )] - }) + for date in reservation.retreat.retreat_dates.all(): + latest_start = max( + date.start_time, + start, + ) + shortest_end = min( + date.end_time, + end, + ) + if latest_start < shortest_end: + raise serializers.ValidationError({ + 'non_field_errors': [_( + "This reservation overlaps with another active " + "reservations for this user." + )] + }) return attrs def create(self, validated_data): @@ -585,15 +592,22 @@ def update(self, instance, validated_data): ).exclude(pk=instance.pk) for reservation in active_reservations: - latest_start = max(reservation.retreat.start_time, start) - shortest_end = min(reservation.retreat.end_time, end) - if latest_start < shortest_end: - raise serializers.ValidationError({ - 'non_field_errors': [_( - "This reservation overlaps with another " - "active reservations for this user." - )] - }) + for date in reservation.retreat.retreat_dates.all(): + latest_start = max( + date.start_time, + start, + ) + shortest_end = min( + date.end_time, + end, + ) + if latest_start < shortest_end: + raise serializers.ValidationError({ + 'non_field_errors': [_( + "This reservation overlaps with another " + "active reservations for this user." + )] + }) if need_transaction: order = Order.objects.create( user=user, diff --git a/retirement/tests/tests_viewset_Retreat.py b/retirement/tests/tests_viewset_Retreat.py index 80fd6444..aa2f1795 100644 --- a/retirement/tests/tests_viewset_Retreat.py +++ b/retirement/tests/tests_viewset_Retreat.py @@ -11,10 +11,9 @@ from django.test.utils import override_settings from django.urls import reverse from rest_framework import status -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import APIClient from blitz_api.factories import AdminFactory, UserFactory -from blitz_api.services import remove_translation_fields from blitz_api.testing_tools import CustomAPITestCase from ..models import Retreat, RetreatType, RetreatDate @@ -998,3 +997,226 @@ def test_recap_email_too_early(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(mail.outbox), 0) + + def test_list_retreat_not_finished(self): + """ + Ensure we can filter by end date and start date of retreats + """ + self.retreat.delete() + self.retreat2.delete() + self.retreat_hidden.delete() + + retreat_finished = Retreat.objects.create( + name="retreat_finished", + price=199, + type=self.retreatType, + seats=2, + min_day_refund=2, + min_day_exchange=2, + refund_rate=20 + ) + RetreatDate.objects.create( + start_time='2099-01-01T00:00:00Z', + end_time='2099-01-02T00:00:00Z', + retreat=retreat_finished, + ) + retreat_finished.activate() + + retreat_not_finished = Retreat.objects.create( + name="retreat_not_finished", + price=199, + type=self.retreatType, + seats=2, + min_day_refund=2, + min_day_exchange=2, + refund_rate=20 + ) + RetreatDate.objects.create( + start_time='2101-01-01T00:00:00Z', + end_time='2101-01-02T00:00:00Z', + retreat=retreat_not_finished, + ) + retreat_not_finished.activate() + + response = self.client.get( + reverse('retreat:retreat-list'), + { + 'finish_after': '2100-01-01T00:00:00Z', + }, + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + content = response.json() + + results = content.get('results') + + self.assertEqual(len(results), 1) + + self.assertEqual( + results[0].get('id'), + retreat_not_finished.id + ) + + def test_list_retreat_not_started(self): + """ + Ensure we can filter by end date and start date of retreats + """ + self.retreat.delete() + self.retreat2.delete() + self.retreat_hidden.delete() + + retreat_started = Retreat.objects.create( + name="retreat_started", + price=199, + type=self.retreatType, + seats=2, + min_day_refund=2, + min_day_exchange=2, + refund_rate=20 + ) + RetreatDate.objects.create( + start_time='2099-01-01T00:00:00Z', + end_time='2099-01-02T00:00:00Z', + retreat=retreat_started, + ) + retreat_started.activate() + + retreat_not_started = Retreat.objects.create( + name="retreat_not_started", + price=199, + type=self.retreatType, + seats=2, + min_day_refund=2, + min_day_exchange=2, + refund_rate=20 + ) + RetreatDate.objects.create( + start_time='2101-01-01T00:00:00Z', + end_time='2101-01-02T00:00:00Z', + retreat=retreat_not_started, + ) + retreat_not_started.activate() + + response = self.client.get( + reverse('retreat:retreat-list'), + { + 'start_after': '2100-01-01T00:00:00Z', + }, + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + content = response.json() + + results = content.get('results') + + self.assertEqual(len(results), 1) + + self.assertEqual( + results[0].get('id'), + retreat_not_started.id + ) + + def test_list_by_type(self): + """ + Ensure we can filter by end date and start date of retreats + """ + Retreat.objects.all().delete() + + retreat_type_2 = RetreatType.objects.create( + name="Type 2", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) + + retreat_2 = Retreat.objects.create( + name="retreat_type_2", + price=199, + type=retreat_type_2, + seats=2, + min_day_refund=2, + min_day_exchange=2, + refund_rate=20 + ) + RetreatDate.objects.create( + start_time='2099-01-01T00:00:00Z', + end_time='2099-01-02T00:00:00Z', + retreat=retreat_2, + ) + retreat_2.activate() + + retreat_1 = Retreat.objects.create( + name="retreat_not_started", + price=199, + type=self.retreatType, + seats=2, + min_day_refund=2, + min_day_exchange=2, + refund_rate=20 + ) + RetreatDate.objects.create( + start_time='2101-01-01T00:00:00Z', + end_time='2101-01-02T00:00:00Z', + retreat=retreat_1, + ) + retreat_1.activate() + + response = self.client.get( + reverse('retreat:retreat-list'), + { + 'type': retreat_type_2.id, + }, + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + content = response.json() + + results = content.get('results') + + self.assertEqual(len(results), 1) + + self.assertEqual( + results[0].get('id'), + retreat_2.id + ) + response = self.client.get( + reverse('retreat:retreat-list'), + { + 'type__id': retreat_type_2.id, + }, + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + content = response.json() + + results = content.get('results') + + self.assertEqual(len(results), 1) + + self.assertEqual( + results[0].get('id'), + retreat_2.id + ) diff --git a/retirement/views.py b/retirement/views.py index edefc06d..4b9a7be2 100644 --- a/retirement/views.py +++ b/retirement/views.py @@ -3,8 +3,9 @@ import pytz from django.core.files.base import ContentFile -from django.db.models import F, When, Case -from django_filters import DateTimeFilter, FilterSet +from django.db.models import F, When, Case, Max, Min +from django_filters import DateTimeFilter, FilterSet, IsoDateTimeFilter, \ + NumberFilter from blitz_api.mixins import ExportMixin from django.conf import settings @@ -80,6 +81,23 @@ TAX = settings.LOCAL_SETTINGS['SELLING_TAX'] +class RetreatFilter(FilterSet): + finish_after = IsoDateTimeFilter( + field_name='max_end_date', lookup_expr='gte' + ) + start_after = IsoDateTimeFilter( + field_name='min_start_date', lookup_expr='gte' + ) + + type__id = NumberFilter( + field_name='type', lookup_expr='exact' + ) + + class Meta: + model = Retreat + fields = '__all__' + + class RetreatViewSet(ExportMixin, viewsets.ModelViewSet): """ retrieve: @@ -93,17 +111,16 @@ class RetreatViewSet(ExportMixin, viewsets.ModelViewSet): """ serializer_class = serializers.RetreatSerializer queryset = Retreat.objects.all() - permission_classes = (permissions.IsAdminOrReadOnly,) - filterset_fields = { - 'is_active': ['exact'], - 'hidden': ['exact'], - 'type__id': ['exact'], - 'retreat_dates__end_time': ['exact', 'gte', 'lte'], - } + permission_classes = ( + permissions.IsAdminOrReadOnly, + ) + ordering = [ 'name', ] + filter_class = RetreatFilter + export_resource = RetreatResource() def get_queryset(self): @@ -111,10 +128,18 @@ def get_queryset(self): This viewset should return active retreats except if the currently authenticated user is an admin (is_staff). """ + if self.request.user.is_staff: - return Retreat.objects.all() - return Retreat.objects.filter(is_active=True, - hidden=False) + queryset = Retreat.objects.all() + else: + queryset = Retreat.objects.filter( + is_active=True, + hidden=False + ) + return queryset.annotate( + max_end_date=Max('retreat_dates__end_time'), + min_start_date=Min('retreat_dates__start_time'), + ) def destroy(self, request, *args, **kwargs): instance = self.get_object() diff --git a/store/tests/tests_model_OptionProduct.py b/store/tests/tests_model_OptionProduct.py index 18b6cf59..e051c924 100644 --- a/store/tests/tests_model_OptionProduct.py +++ b/store/tests/tests_model_OptionProduct.py @@ -73,7 +73,7 @@ def setUp(self): self.options_1.available_on_products.add(self.package) self.options_1.save() - self.retreat_option = RetreatFactory() + self.retreat_option = self.retreat self.retreat_option.is_active = True self.retreat_option.available_on_products.add(self.package) self.retreat_option.save()