diff --git a/blitz_api/cron_manager_api.py b/blitz_api/cron_manager_api.py index 2c93c643..7823c9dc 100644 --- a/blitz_api/cron_manager_api.py +++ b/blitz_api/cron_manager_api.py @@ -33,6 +33,28 @@ def create_wait_queue_place_notification(self, wait_queue_place_id): self.create_task(data) + def create_email_task(self, retreat, email, execution_date): + """ + + :param retreat: The Retreat associate with this email + :param email: The AutomaticEmail we want to schedule + :return: None + """ + target_url = self.url_to_call + reverse( + 'retreat:retreat-detail', + args=[retreat.id] + ) + "/execute_automatic_email/?email=" + str(email.id) + + description = "Automatic email #" + str(email.id) + \ + " for retreat #" + str(retreat.id) + data = { + "execution_datetime": execution_date, + "url": target_url, + "description": description + } + + self.create_task(data) + def create_remind_user(self, retreat_id, reminder_date): remind_users_url = self.url_to_call + reverse( 'retreat:retreat-detail', diff --git a/blitz_api/factories.py b/blitz_api/factories.py index a3cd65c0..5153d647 100644 --- a/blitz_api/factories.py +++ b/blitz_api/factories.py @@ -85,12 +85,6 @@ class Meta: notification_interval = timedelta(hours=24) activity_language = factory.fuzzy.FuzzyChoice(Retreat.ACTIVITY_LANGUAGE) price = factory.fuzzy.FuzzyDecimal(0, 9999, 2) - start_time = factory.Faker('date_time_between', - start_date="+10d", end_date="+30d", - tzinfo=tz.tzutc()) - end_time = factory.Faker('date_time_between', - start_date="+31d", end_date="+600d", - tzinfo=tz.tzutc()) min_day_refund = factory.fuzzy.FuzzyInteger(0) refund_rate = factory.fuzzy.FuzzyInteger(0) min_day_exchange = factory.fuzzy.FuzzyInteger(0) diff --git a/blitz_api/services.py b/blitz_api/services.py index f59ef3b3..2d2e2fe8 100644 --- a/blitz_api/services.py +++ b/blitz_api/services.py @@ -28,11 +28,26 @@ def send_mail(users, context, template): Uses Anymail to send templated emails. Returns a list of email addresses to which emails failed to be delivered. """ + MAIL_SERVICE = settings.ANYMAIL + template = MAIL_SERVICE["TEMPLATES"].get(template) + + return send_email_from_template_id(users, context, template) + + +def send_email_from_template_id(users, context, template): + """ + Uses Anymail to send templated emails. + Returns a list of email addresses to which emails failed to be delivered. + :param users: The list of users to notify + :param context: The context variables of the template + :param template: The template of the ESP + :return: A list of email addresses to which emails failed to be delivered + """ + if settings.LOCAL_SETTINGS['EMAIL_SERVICE'] is False: raise MailServiceError(_( "Email service is disabled." )) - MAIL_SERVICE = settings.ANYMAIL failed_emails = list() for user in users: @@ -43,17 +58,21 @@ def send_mail(users, context, template): ) message.from_email = None # required for SendinBlue templates # use this SendinBlue template - message.template_id = MAIL_SERVICE["TEMPLATES"].get(template) + message.template_id = int(template) message.merge_global_data = context try: # return number of successfully sent emails response = message.send() - EmailLog.add(user.email, template, response) + EmailLog.add( + user.email, + "Template #" + str(template), + response + ) except Exception as err: additional_data = { 'email': user.email, 'context': context, - 'template': template + 'template': "Template #" + str(template) } Log.error( source='SENDING_BLUE_TEMPLATE', diff --git a/blitz_api/testing_tools.py b/blitz_api/testing_tools.py new file mode 100644 index 00000000..d627240f --- /dev/null +++ b/blitz_api/testing_tools.py @@ -0,0 +1,22 @@ +from rest_framework.test import APITestCase + + +class CustomAPITestCase(APITestCase): + ATTRIBUTES = [] + + def check_attributes(self, content, attrs=None): + if attrs is None: + attrs = self.ATTRIBUTES + + missing_keys = list(set(attrs) - set(content.keys())) + extra_keys = list(set(content.keys()) - set(attrs)) + self.assertEqual( + len(missing_keys), + 0, + 'You miss some attributes: ' + str(missing_keys) + ) + self.assertEqual( + len(extra_keys), + 0, + 'You have some extra attributes: ' + str(extra_keys) + ) diff --git a/retirement/admin.py b/retirement/admin.py index 5f277cbc..55889e7b 100644 --- a/retirement/admin.py +++ b/retirement/admin.py @@ -3,15 +3,32 @@ from django.utils.translation import ugettext_lazy as _ from import_export.admin import ExportActionModelAdmin from modeltranslation.admin import TranslationAdmin -from safedelete.admin import SafeDeleteAdmin, highlight_deleted +from safedelete.admin import ( + SafeDeleteAdmin, + highlight_deleted, +) from simple_history.admin import SimpleHistoryAdmin from blitz_api.admin import UserFilter from store.admin import CouponFilter -from .models import (Picture, Reservation, Retreat, WaitQueue, - RetreatInvitation, WaitQueuePlace, WaitQueuePlaceReserved) -from .resources import (ReservationResource, RetreatResource, - WaitQueueResource) +from .models import ( + Picture, + Reservation, + Retreat, + WaitQueue, + RetreatInvitation, + WaitQueuePlace, + WaitQueuePlaceReserved, + RetreatType, + AutomaticEmail, + AutomaticEmailLog, + RetreatDate, +) +from .resources import ( + ReservationResource, + RetreatResource, + WaitQueueResource +) class RetreatFilter(AutocompleteFilter): @@ -74,8 +91,6 @@ class RetreatAdmin(SimpleHistoryAdmin, list_filter = ( 'name', 'seats', - 'start_time', - 'end_time', 'price', ) + SafeDeleteAdmin.list_filter @@ -247,7 +262,19 @@ class Media: pass +class RetreatDateAdmin(admin.ModelAdmin): + list_display = ( + 'retreat', + 'start_time', + 'end_time', + ) + + admin.site.register(Retreat, RetreatAdmin) +admin.site.register(RetreatType) +admin.site.register(RetreatDate, RetreatDateAdmin) +admin.site.register(AutomaticEmail) +admin.site.register(AutomaticEmailLog) admin.site.register(Picture, PictureAdmin) admin.site.register(Reservation, ReservationAdmin) admin.site.register(WaitQueue, WaitQueueAdmin) diff --git a/retirement/migrations/0029_auto_20200720_1127.py b/retirement/migrations/0029_auto_20200720_1127.py new file mode 100644 index 00000000..ea5eaf8a --- /dev/null +++ b/retirement/migrations/0029_auto_20200720_1127.py @@ -0,0 +1,88 @@ +# Generated by Django 2.2.12 on 2020-07-20 15:27 + +from django.db import migrations, models +import django.db.models.deletion +from django.conf import settings +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('retirement', '0028_auto_20200601_1613'), + ] + + operations = [ + migrations.CreateModel( + name='RetreatType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=253, verbose_name='Name')), + ( + 'name_en', + models.CharField( + max_length=253, + null=True, + verbose_name='Name', + ) + ), + ( + 'name_fr', + models.CharField( + max_length=253, + null=True, + verbose_name='Name', + ) + ), + ('minutes_before_display_link', models.IntegerField(verbose_name='Minute before displaying the link')), + ], + options={ + 'verbose_name': 'Type of retreat', + 'verbose_name_plural': 'Types of retreat', + }, + ), + migrations.AddField( + model_name='historicalretreat', + name='type_new', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='retirement.RetreatType'), + ), + migrations.AddField( + model_name='retreat', + name='type_new', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='retirement.RetreatType'), + ), + migrations.CreateModel( + name='HistoricalRetreatType', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, + db_index=True, verbose_name='ID')), + ( + 'name', models.CharField(max_length=253, verbose_name='Name')), + ('name_fr', models.CharField(max_length=253, null=True, + verbose_name='Name')), + ('name_en', models.CharField(max_length=253, null=True, + verbose_name='Name')), + ('minutes_before_display_link', models.IntegerField( + verbose_name='Minute before displaying the link')), + ('history_id', + models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', + models.CharField(max_length=100, null=True)), + ('history_type', models.CharField( + choices=[('+', 'Created'), ('~', 'Changed'), + ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'historical Type of retreat', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/retirement/migrations/0030_migration_of_retreat_type.py b/retirement/migrations/0030_migration_of_retreat_type.py new file mode 100644 index 00000000..2d577362 --- /dev/null +++ b/retirement/migrations/0030_migration_of_retreat_type.py @@ -0,0 +1,41 @@ +# Generated by Django 2.2.12 on 2020-07-20 15:17 + +from django.db import migrations, models +import django.db.models.deletion + + +def migrate_type_of_retreat(apps, schema_editor): + Retreat = apps.get_model('retirement', 'Retreat') + RetreatType = apps.get_model('retirement', 'RetreatType') + + physical, created = RetreatType.objects.get_or_create( + name_fr='Physique', + name_en='Physical', + minutes_before_display_link=0 + ) + + virtual, created = RetreatType.objects.get_or_create( + name_fr='Virtuelle', + name_en='Virtual', + minutes_before_display_link=30 + ) + + for retreat in Retreat.objects.all(): + if retreat.type == 'V': + retreat.type_new = virtual + else: + retreat.type_new = physical + + retreat.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('retirement', '0029_auto_20200720_1127'), + ] + + operations = [ + migrations.RunPython(migrate_type_of_retreat), + ] diff --git a/retirement/migrations/0031_auto_20200720_1135.py b/retirement/migrations/0031_auto_20200720_1135.py new file mode 100644 index 00000000..c512ae14 --- /dev/null +++ b/retirement/migrations/0031_auto_20200720_1135.py @@ -0,0 +1,21 @@ +# Generated by Django 2.2.12 on 2020-07-20 15:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0030_migration_of_retreat_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalretreat', + name='type', + ), + migrations.RemoveField( + model_name='retreat', + name='type', + ), + ] diff --git a/retirement/migrations/0032_auto_20200720_1159.py b/retirement/migrations/0032_auto_20200720_1159.py new file mode 100644 index 00000000..e70524da --- /dev/null +++ b/retirement/migrations/0032_auto_20200720_1159.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.12 on 2020-07-20 15:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0031_auto_20200720_1135'), + ] + + operations = [ + migrations.RenameField( + model_name='historicalretreat', + old_name='type_new', + new_name='type', + ), + migrations.RenameField( + model_name='retreat', + old_name='type_new', + new_name='type', + ), + ] diff --git a/retirement/migrations/0033_auto_20200720_1202.py b/retirement/migrations/0033_auto_20200720_1202.py new file mode 100644 index 00000000..e54806a0 --- /dev/null +++ b/retirement/migrations/0033_auto_20200720_1202.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.12 on 2020-07-20 16:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0032_auto_20200720_1159'), + ] + + operations = [ + migrations.AlterField( + model_name='retreat', + name='type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='retreats', to='retirement.RetreatType'), + ), + ] diff --git a/retirement/migrations/0034_auto_20200720_1339.py b/retirement/migrations/0034_auto_20200720_1339.py new file mode 100644 index 00000000..a6362106 --- /dev/null +++ b/retirement/migrations/0034_auto_20200720_1339.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.12 on 2020-07-20 17:39 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('retirement', '0033_auto_20200720_1202'), + ] + + operations = [] diff --git a/retirement/migrations/0035_auto_20200721_0947.py b/retirement/migrations/0035_auto_20200721_0947.py new file mode 100644 index 00000000..4ac69370 --- /dev/null +++ b/retirement/migrations/0035_auto_20200721_0947.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.12 on 2020-07-21 13:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0034_auto_20200720_1339'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='number_of_tomatoes', + field=models.PositiveIntegerField(default=0, verbose_name='Number of tomatoes'), + preserve_default=False, + ), + migrations.AddField( + model_name='retreattype', + name='number_of_tomatoes', + field=models.PositiveIntegerField(default=0, verbose_name='Number of tomatoes'), + preserve_default=False, + ), + ] diff --git a/retirement/migrations/0036_automaticemail.py b/retirement/migrations/0036_automaticemail.py new file mode 100644 index 00000000..ac1b88b1 --- /dev/null +++ b/retirement/migrations/0036_automaticemail.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.12 on 2020-07-23 14:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0035_auto_20200721_0947'), + ] + + operations = [ + migrations.CreateModel( + name='AutomaticEmail', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('minutes_delta', models.BigIntegerField(verbose_name='Time delta in minutes')), + ('time_base', models.CharField(choices=[('before_start', 'Before start'), ('after_end', 'After end')], max_length=253, verbose_name='Time base')), + ('template_id', models.CharField(max_length=253, verbose_name='Template ID')), + ('context', models.TextField(default='{}', max_length=253, verbose_name='Context')), + ('retreat_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='automatic_emails', to='retirement.RetreatType')), + ], + options={ + 'verbose_name': 'Automatic email', + 'verbose_name_plural': 'Automatic emails', + }, + ), + ] diff --git a/retirement/migrations/0037_automaticemaillog.py b/retirement/migrations/0037_automaticemaillog.py new file mode 100644 index 00000000..c2cc0d9e --- /dev/null +++ b/retirement/migrations/0037_automaticemaillog.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.12 on 2020-07-23 17:55 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0036_automaticemail'), + ] + + operations = [ + migrations.CreateModel( + name='AutomaticEmailLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sent_at', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Sent date')), + ('email', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='automatic_email_logs', to='retirement.AutomaticEmail')), + ('reservation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='automatic_email_logs', to='retirement.Reservation')), + ], + options={ + 'verbose_name': 'Automatic email log', + 'verbose_name_plural': 'Automatic email logs', + }, + ), + ] diff --git a/retirement/migrations/0038_retreatdate.py b/retirement/migrations/0038_retreatdate.py new file mode 100644 index 00000000..5a203d8f --- /dev/null +++ b/retirement/migrations/0038_retreatdate.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.12 on 2020-07-23 18:02 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0037_automaticemaillog'), + ] + + operations = [ + migrations.CreateModel( + name='RetreatDate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.DateTimeField(verbose_name='Start time')), + ('end_time', models.DateTimeField(verbose_name='End time')), + ('retreat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='retreat_dates', to='retirement.Retreat', verbose_name='Retreat')), + ], + options={ + 'verbose_name': 'Retreat date', + 'verbose_name_plural': 'Retreat dates', + }, + ), + ] diff --git a/retirement/migrations/0039_auto_20200723_1403.py b/retirement/migrations/0039_auto_20200723_1403.py new file mode 100644 index 00000000..b32f8445 --- /dev/null +++ b/retirement/migrations/0039_auto_20200723_1403.py @@ -0,0 +1,26 @@ +# Generated by Django 2.2.12 on 2020-07-23 18:03 + +from django.db import migrations + + +def migrate_retreat_date_intervals(apps, schema_editor): + Retreat = apps.get_model('retirement', 'Retreat') + RetreatDate = apps.get_model('retirement', 'RetreatDate') + + for retreat in Retreat.objects.all(): + RetreatDate.objects.create( + retreat=retreat, + start_time=retreat.start_time, + end_time=retreat.end_time, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0038_retreatdate'), + ] + + operations = [ + migrations.RunPython(migrate_retreat_date_intervals), + ] diff --git a/retirement/migrations/0040_auto_20200724_1400.py b/retirement/migrations/0040_auto_20200724_1400.py new file mode 100644 index 00000000..7cb24fe4 --- /dev/null +++ b/retirement/migrations/0040_auto_20200724_1400.py @@ -0,0 +1,39 @@ +# Generated by Django 2.2.12 on 2020-07-24 18:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0039_auto_20200723_1403'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalretreat', + name='end_time', + ), + migrations.RemoveField( + model_name='historicalretreat', + name='start_time', + ), + migrations.RemoveField( + model_name='retreat', + name='end_time', + ), + migrations.RemoveField( + model_name='retreat', + name='start_time', + ), + migrations.AlterField( + model_name='historicalretreat', + name='is_active', + field=models.BooleanField(default=False, verbose_name='Active'), + ), + migrations.AlterField( + model_name='retreat', + name='is_active', + field=models.BooleanField(default=False, verbose_name='Active'), + ), + ] diff --git a/retirement/migrations/0041_auto_20200813_1411.py b/retirement/migrations/0041_auto_20200813_1411.py new file mode 100644 index 00000000..6be5c18f --- /dev/null +++ b/retirement/migrations/0041_auto_20200813_1411.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.12 on 2020-08-13 18:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0040_auto_20200724_1400'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='average_duration_in_minute', + field=models.PositiveIntegerField(default=1, verbose_name='Average duration in minute'), + preserve_default=False, + ), + migrations.AddField( + model_name='historicalretreattype', + name='description', + field=models.TextField(default='placeholder', verbose_name='Description'), + preserve_default=False, + ), + migrations.AddField( + model_name='retreattype', + name='average_duration_in_minute', + field=models.PositiveIntegerField(default=1, verbose_name='Average duration in minute'), + preserve_default=False, + ), + migrations.AddField( + model_name='retreattype', + name='description', + field=models.TextField(default='placeholder', verbose_name='Description'), + preserve_default=False, + ), + ] diff --git a/retirement/migrations/0042_auto_20200813_1415.py b/retirement/migrations/0042_auto_20200813_1415.py new file mode 100644 index 00000000..12e60a15 --- /dev/null +++ b/retirement/migrations/0042_auto_20200813_1415.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-08-13 18:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0041_auto_20200813_1411'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='icon', + field=models.TextField(blank=True, max_length=100, null=True, verbose_name='icon'), + ), + migrations.AddField( + model_name='retreattype', + name='icon', + field=models.ImageField(blank=True, null=True, upload_to='retreat-type-icon', verbose_name='icon'), + ), + ] diff --git a/retirement/migrations/0043_auto_20200813_1451.py b/retirement/migrations/0043_auto_20200813_1451.py new file mode 100644 index 00000000..ec0c5152 --- /dev/null +++ b/retirement/migrations/0043_auto_20200813_1451.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-08-13 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0042_auto_20200813_1415'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='is_virtual', + field=models.BooleanField(default=False, verbose_name='Is virtual'), + ), + migrations.AddField( + model_name='retreattype', + name='is_virtual', + field=models.BooleanField(default=False, verbose_name='Is virtual'), + ), + ] diff --git a/retirement/migrations/0044_auto_20200813_1523.py b/retirement/migrations/0044_auto_20200813_1523.py new file mode 100644 index 00000000..d7276163 --- /dev/null +++ b/retirement/migrations/0044_auto_20200813_1523.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.12 on 2020-08-13 19:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0043_auto_20200813_1451'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='short_description', + field=models.TextField(default='placeholder', verbose_name='Short description'), + preserve_default=False, + ), + migrations.AddField( + model_name='retreattype', + name='short_description', + field=models.TextField(default='placeholder', verbose_name='Short description'), + preserve_default=False, + ), + ] diff --git a/retirement/migrations/0045_auto_20200813_1548.py b/retirement/migrations/0045_auto_20200813_1548.py new file mode 100644 index 00000000..1533b665 --- /dev/null +++ b/retirement/migrations/0045_auto_20200813_1548.py @@ -0,0 +1,53 @@ +# Generated by Django 2.2.12 on 2020-08-13 19:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0044_auto_20200813_1523'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalretreat', + name='min_day_exchange', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Minimum days before the event for exchange'), + ), + migrations.AlterField( + model_name='historicalretreat', + name='min_day_refund', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Minimum days before the event for refund'), + ), + migrations.AlterField( + model_name='historicalretreat', + name='refund_rate', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Refund rate'), + ), + migrations.AlterField( + model_name='historicalretreat', + name='seats', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Seats'), + ), + migrations.AlterField( + model_name='retreat', + name='min_day_exchange', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Minimum days before the event for exchange'), + ), + migrations.AlterField( + model_name='retreat', + name='min_day_refund', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Minimum days before the event for refund'), + ), + migrations.AlterField( + model_name='retreat', + name='refund_rate', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Refund rate'), + ), + migrations.AlterField( + model_name='retreat', + name='seats', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Seats'), + ), + ] diff --git a/retirement/migrations/0046_auto_20200813_1551.py b/retirement/migrations/0046_auto_20200813_1551.py new file mode 100644 index 00000000..c3af9b98 --- /dev/null +++ b/retirement/migrations/0046_auto_20200813_1551.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-08-13 19:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0045_auto_20200813_1548'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalretreat', + name='seats', + field=models.PositiveIntegerField(default=0, verbose_name='Seats'), + ), + migrations.AlterField( + model_name='retreat', + name='seats', + field=models.PositiveIntegerField(default=0, verbose_name='Seats'), + ), + ] diff --git a/retirement/migrations/0047_auto_20200814_1151.py b/retirement/migrations/0047_auto_20200814_1151.py new file mode 100644 index 00000000..1de9d959 --- /dev/null +++ b/retirement/migrations/0047_auto_20200814_1151.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.12 on 2020-08-14 15:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0046_auto_20200813_1551'), + ] + + operations = [ + migrations.AlterModelOptions( + name='retreatdate', + options={'ordering': ['start_time'], 'verbose_name': 'Retreat date', 'verbose_name_plural': 'Retreat dates'}, + ), + migrations.AddField( + model_name='historicalretreat', + name='animator', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='animator'), + ), + migrations.AddField( + model_name='retreat', + name='animator', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='animator'), + ), + ] diff --git a/retirement/migrations/0048_auto_20200814_1354.py b/retirement/migrations/0048_auto_20200814_1354.py new file mode 100644 index 00000000..9ffd14c8 --- /dev/null +++ b/retirement/migrations/0048_auto_20200814_1354.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.12 on 2020-08-14 17:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0047_auto_20200814_1151'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalretreattype', + name='average_duration_in_minute', + ), + migrations.RemoveField( + model_name='retreattype', + name='average_duration_in_minute', + ), + migrations.AddField( + model_name='historicalretreattype', + name='duration_description', + field=models.TextField(default='placeholder', verbose_name='Description of duration'), + preserve_default=False, + ), + migrations.AddField( + model_name='retreattype', + name='duration_description', + field=models.TextField(default='placeholder', verbose_name='Description of duration'), + preserve_default=False, + ), + ] diff --git a/retirement/migrations/0049_auto_20200814_1358.py b/retirement/migrations/0049_auto_20200814_1358.py new file mode 100644 index 00000000..cffe7e33 --- /dev/null +++ b/retirement/migrations/0049_auto_20200814_1358.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-08-14 17:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0048_auto_20200814_1354'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='index_ordering', + field=models.PositiveIntegerField(default=1, verbose_name='Index for display'), + ), + migrations.AddField( + model_name='retreattype', + name='index_ordering', + field=models.PositiveIntegerField(default=1, verbose_name='Index for display'), + ), + ] diff --git a/retirement/migrations/0050_auto_20200814_1359.py b/retirement/migrations/0050_auto_20200814_1359.py new file mode 100644 index 00000000..c99ddf38 --- /dev/null +++ b/retirement/migrations/0050_auto_20200814_1359.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.12 on 2020-08-14 17:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0049_auto_20200814_1358'), + ] + + operations = [ + migrations.AlterModelOptions( + name='retreattype', + options={'ordering': ['index_ordering'], 'verbose_name': 'Type of retreat', 'verbose_name_plural': 'Types of retreat'}, + ), + ] diff --git a/retirement/migrations/0051_auto_20200814_1413.py b/retirement/migrations/0051_auto_20200814_1413.py new file mode 100644 index 00000000..435144e4 --- /dev/null +++ b/retirement/migrations/0051_auto_20200814_1413.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.12 on 2020-08-14 18:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0050_auto_20200814_1359'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='cancellation_policies', + field=models.TextField(default='placeholder', verbose_name='Cancellation policies'), + preserve_default=False, + ), + migrations.AddField( + model_name='retreattype', + name='cancellation_policies', + field=models.TextField(default='placeholder', verbose_name='Cancellation policies'), + preserve_default=False, + ), + ] diff --git a/retirement/migrations/0052_auto_20200814_1432.py b/retirement/migrations/0052_auto_20200814_1432.py new file mode 100644 index 00000000..3a993271 --- /dev/null +++ b/retirement/migrations/0052_auto_20200814_1432.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.12 on 2020-08-14 18:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0051_auto_20200814_1413'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='know_more_link', + field=models.TextField(default='placeholder', verbose_name='Know more link'), + preserve_default=False, + ), + migrations.AddField( + model_name='retreattype', + name='know_more_link', + field=models.TextField(default='placeholder', verbose_name='Know more link'), + preserve_default=False, + ), + ] diff --git a/retirement/migrations/0053_auto_20200814_1434.py b/retirement/migrations/0053_auto_20200814_1434.py new file mode 100644 index 00000000..1c624aff --- /dev/null +++ b/retirement/migrations/0053_auto_20200814_1434.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.12 on 2020-08-14 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0052_auto_20200814_1432'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalretreattype', + name='know_more_link', + field=models.TextField(blank=True, null=True, verbose_name='Know more link'), + ), + migrations.AlterField( + model_name='retreattype', + name='know_more_link', + field=models.TextField(blank=True, null=True, verbose_name='Know more link'), + ), + ] diff --git a/retirement/migrations/0054_auto_20200827_1151.py b/retirement/migrations/0054_auto_20200827_1151.py new file mode 100644 index 00000000..e9f59e7d --- /dev/null +++ b/retirement/migrations/0054_auto_20200827_1151.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.12 on 2020-08-27 15:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('retirement', '0053_auto_20200814_1434'), + ] + + operations = [ + migrations.AddField( + model_name='historicalretreattype', + name='context_for_welcome_message', + field=models.TextField(blank=True, default='{}', null=True, verbose_name='Context for welcome message'), + ), + migrations.AddField( + model_name='historicalretreattype', + name='template_id_for_welcome_message', + field=models.CharField(blank=True, max_length=253, null=True, verbose_name='Template ID for welcome message'), + ), + migrations.AddField( + model_name='retreattype', + name='context_for_welcome_message', + field=models.TextField(blank=True, default='{}', null=True, verbose_name='Context for welcome message'), + ), + migrations.AddField( + model_name='retreattype', + name='template_id_for_welcome_message', + field=models.CharField(blank=True, max_length=253, null=True, verbose_name='Template ID for welcome message'), + ), + ] diff --git a/retirement/models.py b/retirement/models.py index a67e74fb..ec9b1ae9 100644 --- a/retirement/models.py +++ b/retirement/models.py @@ -6,6 +6,8 @@ import requests from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.mail import mail_admins, send_mail from django.template.loader import render_to_string from django.urls import reverse @@ -29,6 +31,134 @@ TAX_RATE = settings.LOCAL_SETTINGS['SELLING_TAX'] +class RetreatType(models.Model): + + class Meta: + verbose_name = _("Type of retreat") + verbose_name_plural = _("Types of retreat") + ordering = ['index_ordering'] + + name = models.CharField( + verbose_name=_("Name"), + max_length=253, + ) + + # Timedelta to show the videoconferencing link before the retreat + minutes_before_display_link = models.IntegerField( + verbose_name=_("Minute before displaying the link"), + ) + + number_of_tomatoes = models.PositiveIntegerField( + verbose_name=_("Number of tomatoes"), + ) + + description = models.TextField( + verbose_name=_("Description"), + ) + + short_description = models.TextField( + verbose_name=_("Short description"), + ) + + duration_description = models.TextField( + verbose_name=_("Description of duration") + ) + + cancellation_policies = models.TextField( + verbose_name=_("Cancellation policies") + ) + + icon = models.ImageField( + _('icon'), + upload_to='retreat-type-icon', + null=True, + blank=True, + ) + + is_virtual = models.BooleanField( + verbose_name=_("Is virtual"), + default=False, + ) + + index_ordering = models.PositiveIntegerField( + verbose_name=_('Index for display'), + default=1, + ) + + know_more_link = models.TextField( + verbose_name=_("Know more link"), + blank=True, + null=True, + ) + + template_id_for_welcome_message = models.CharField( + verbose_name=_("Template ID for welcome message"), + max_length=253, + null=True, + blank=True, + ) + + context_for_welcome_message = models.TextField( + verbose_name=_("Context for welcome message"), + default='{}', + null=True, + blank=True, + ) + + def __str__(self): + return self.name + + +class AutomaticEmail(models.Model): + """ + Define the automation emails that need to be automatically add to our + cron-task when creating new instance of retreat. + + These emails will be sent to all the customer who have an active + reservation at the specified time and with the specified template of + email and context. + """ + + TIME_BASE_BEFORE_START = 'before_start' + TIME_BASE_AFTER_END = 'after_end' + + TIME_BASE_CHOICES = ( + (TIME_BASE_BEFORE_START, _("Before start")), + (TIME_BASE_AFTER_END, _("After end")), + ) + + class Meta: + verbose_name = _("Automatic email") + verbose_name_plural = _("Automatic emails") + + minutes_delta = models.BigIntegerField( + verbose_name=_("Time delta in minutes"), + ) + + time_base = models.CharField( + verbose_name=_("Time base"), + max_length=253, + choices=TIME_BASE_CHOICES, + ) + + template_id = models.CharField( + verbose_name=_("Template ID"), + max_length=253, + ) + + context = models.TextField( + verbose_name=_("Context"), + max_length=253, + default='{}' + ) + + retreat_type = models.ForeignKey( + RetreatType, + on_delete=models.CASCADE, + related_name='automatic_emails', + ) + + class Retreat(Address, SafeDeleteModel, BaseProduct): """Represents a retreat physical place.""" DOUBLE_OCCUPATION = 'double_occupation' @@ -47,14 +177,6 @@ class Retreat(Address, SafeDeleteModel, BaseProduct): (DOUBLE_SINGLE_OCCUPATION, _("Single and double occupation")), ) - TYPE_VIRTUAL = 'V' - TYPE_PHYSICAL = 'P' - - TYPE_CHOICES = ( - (TYPE_VIRTUAL, _("Virtual")), - (TYPE_PHYSICAL, _("Physical")), - ) - class Meta: verbose_name = _("Retreat") verbose_name_plural = _("Retreats") @@ -78,7 +200,10 @@ class Meta: blank=True ) - seats = models.PositiveIntegerField(verbose_name=_("Seats"), ) + seats = models.PositiveIntegerField( + verbose_name=_("Seats"), + default=0, + ) @property def reserved_seats(self): @@ -105,11 +230,10 @@ def reserved_seats(self): verbose_name=_("Activity language"), ) - type = models.CharField( - max_length=100, - default=TYPE_PHYSICAL, - choices=TYPE_CHOICES, - verbose_name=_("Type of retreat"), + type = models.ForeignKey( + RetreatType, + on_delete=models.CASCADE, + related_name='retreats', ) videoconference_tool = models.CharField( @@ -133,17 +257,23 @@ def reserved_seats(self): verbose_name=_("Room Type"), ) - start_time = models.DateTimeField(verbose_name=_("Start time"), ) - - end_time = models.DateTimeField(verbose_name=_("End time"), ) - min_day_refund = models.PositiveIntegerField( - verbose_name=_("Minimum days before the event for refund"), ) + verbose_name=_("Minimum days before the event for refund"), + blank=True, + null=True, + ) - refund_rate = models.PositiveIntegerField(verbose_name=_("Refund rate"), ) + refund_rate = models.PositiveIntegerField( + verbose_name=_("Refund rate"), + blank=True, + null=True, + ) min_day_exchange = models.PositiveIntegerField( - verbose_name=_("Minimum days before the event for exchange"), ) + verbose_name=_("Minimum days before the event for exchange"), + blank=True, + null=True, + ) users = models.ManyToManyField( User, @@ -160,7 +290,10 @@ def reserved_seats(self): related_name='retreats', ) - is_active = models.BooleanField(verbose_name=_("Active"), ) + is_active = models.BooleanField( + verbose_name=_("Active"), + default=False, + ) email_content = models.TextField( verbose_name=_("Email content"), @@ -227,6 +360,13 @@ def reserved_seats(self): blank=True, ) + animator = models.CharField( + verbose_name=_("animator"), + max_length=100, + null=True, + blank=True, + ) + food_vege = models.BooleanField( verbose_name=_("Food vege"), default=False @@ -250,6 +390,22 @@ def reserved_seats(self): # History is registered in translation.py # history = HistoricalRecords() + @property + def start_time(self): + dates = self.retreat_dates.all().order_by('start_time') + if dates.count(): + return dates[0].start_time + else: + return None + + @property + def end_time(self): + dates = self.retreat_dates.all().order_by('-end_time') + if dates.count(): + return dates[0].end_time + else: + return None + @property def total_reservations(self): return self.reservations.filter(is_active=True).count() @@ -430,6 +586,79 @@ def get_datetime_refund(self): return self.start_time - timedelta( days=self.min_day_refund) + def activate(self): + if not self.start_time: + raise ValueError( + _("Retreat need to have a start time before activate it") + ) + + if not self.end_time: + raise ValueError( + _("Retreat need to have a end time before activate it") + ) + + if self.seats <= 0: + raise ValueError( + _("Retreat need to have at least one seat available") + ) + + if self.min_day_refund is None: + raise ValueError( + _("Retreat need to have a minimum day refund policy") + ) + + if self.min_day_exchange is None: + raise ValueError( + _("Retreat need to have a minimum day exchange policy") + ) + + if self.refund_rate is None: + raise ValueError( + _("Retreat need to have a refund rate policy") + ) + + cron_manager = CronManager() + + for email in self.type.automatic_emails.all(): + if email.time_base == AutomaticEmail.TIME_BASE_BEFORE_START: + execution_date = self.start_time + else: + execution_date = self.end_time + + execution_date += timedelta(minutes=email.minutes_delta) + + cron_manager.create_email_task( + self, + email, + execution_date + ) + + self.is_active = True + self.save() + + +class RetreatDate(models.Model): + + class Meta: + verbose_name = _("Retreat date") + verbose_name_plural = _("Retreat dates") + ordering = ["start_time"] + + retreat = models.ForeignKey( + Retreat, + on_delete=models.CASCADE, + verbose_name=_("Retreat"), + related_name='retreat_dates', + ) + + start_time = models.DateTimeField( + verbose_name=_("Start time"), + ) + + end_time = models.DateTimeField( + verbose_name=_("End time"), + ) + class Picture(models.Model): """Represents pictures representing a retreat place""" @@ -608,6 +837,32 @@ def make_refund(self, refund_reason, total_refund=False): return refund +class AutomaticEmailLog(models.Model): + + reservation = models.ForeignKey( + Reservation, + on_delete=models.CASCADE, + related_name='automatic_email_logs', + ) + + email = models.ForeignKey( + AutomaticEmail, + on_delete=models.CASCADE, + related_name='automatic_email_logs', + ) + + sent_at = models.DateTimeField( + blank=True, + null=True, + verbose_name=_("Sent date"), + auto_now_add=True, + ) + + class Meta: + verbose_name = _("Automatic email log") + verbose_name_plural = _("Automatic email logs") + + class WaitQueue(models.Model): """ Represents element of a FIFO waiting queue to which users register diff --git a/retirement/serializers.py b/retirement/serializers.py index 06771df9..0fc4318c 100644 --- a/retirement/serializers.py +++ b/retirement/serializers.py @@ -1,13 +1,10 @@ from datetime import timedelta from decimal import Decimal import json -import requests -import traceback from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.core.mail import mail_admins from django.core.mail import send_mail from django.db import transaction from django.template.loader import render_to_string @@ -17,27 +14,45 @@ from rest_framework.reverse import reverse from rest_framework.validators import UniqueValidator -from blitz_api.cron_manager_api import CronManager -from blitz_api.services import (check_if_translated_field, - remove_translation_fields, - getMessageTranslate) +from blitz_api.services import ( + check_if_translated_field, + remove_translation_fields, + getMessageTranslate, +) from log_management.models import Log, EmailLog -from retirement.services import refund_retreat +from retirement.services import refund_retreat, send_retreat_confirmation_email from store.exceptions import PaymentAPIError -from store.models import Order, OrderLine, PaymentProfile, Refund -from store.serializers import BaseProductSerializer, CouponSerializer -from store.services import (charge_payment, - create_external_payment_profile, - create_external_card, - PAYSAFE_CARD_TYPE, - PAYSAFE_EXCEPTION, - refund_amount, ) +from store.models import ( + Order, + OrderLine, + PaymentProfile, + Refund, +) +from store.serializers import ( + BaseProductSerializer, + CouponSerializer, +) +from store.services import ( + charge_payment, + create_external_payment_profile, + create_external_card, + PAYSAFE_CARD_TYPE, + PAYSAFE_EXCEPTION, + refund_amount, +) from .fields import TimezoneField from .models import ( - Picture, Reservation, Retreat, WaitQueue, - RetreatInvitation, WaitQueuePlace, - WaitQueuePlaceReserved + Picture, + Reservation, + Retreat, + WaitQueue, + RetreatInvitation, + WaitQueuePlace, + WaitQueuePlaceReserved, + RetreatType, + AutomaticEmail, + RetreatDate, ) User = get_user_model() @@ -45,9 +60,84 @@ TAX_RATE = settings.LOCAL_SETTINGS['SELLING_TAX'] +class RetreatDateSerializer(serializers.HyperlinkedModelSerializer): + id = serializers.ReadOnlyField() + + class Meta: + model = RetreatDate + fields = '__all__' + extra_kwargs = { + 'url': { + 'view_name': 'retreat:retreatdate-detail', + }, + 'retreat': { + 'view_name': 'retreat:retreat-detail', + } + } + + +class RetreatTypeSerializer(serializers.HyperlinkedModelSerializer): + id = serializers.ReadOnlyField() + + name = serializers.CharField( + required=False, + validators=[UniqueValidator(queryset=RetreatType.objects.all())], + ) + name_fr = serializers.CharField( + required=False, + allow_null=True, + validators=[UniqueValidator(queryset=RetreatType.objects.all())], + ) + name_en = serializers.CharField( + required=False, + allow_null=True, + validators=[UniqueValidator(queryset=RetreatType.objects.all())], + ) + + class Meta: + model = RetreatType + fields = '__all__' + extra_kwargs = { + 'url': { + 'view_name': 'retreat:retreattype-detail', + }, + 'name': { + 'help_text': _("Name of the retreat type."), + 'validators': + [UniqueValidator(queryset=RetreatType.objects.all())], + }, + } + + def validate(self, attr): + err = {} + + if not check_if_translated_field('name', attr): + err.update(getMessageTranslate('name', attr, True)) + if err: + raise serializers.ValidationError(err) + + return super(RetreatTypeSerializer, self).validate(attr) + + +class AutomaticEmailSerializer(serializers.HyperlinkedModelSerializer): + id = serializers.ReadOnlyField() + + class Meta: + model = AutomaticEmail + fields = '__all__' + extra_kwargs = { + 'url': { + 'view_name': 'retreat:automaticemail-detail', + }, + } + + class RetreatSerializer(BaseProductSerializer): + start_time = serializers.ReadOnlyField() + end_time = serializers.ReadOnlyField() places_remaining = serializers.ReadOnlyField() total_reservations = serializers.ReadOnlyField() + is_active = serializers.BooleanField(read_only=True) reserved_seats = serializers.ReadOnlyField() reservations = serializers.SerializerMethodField() reservations_canceled = serializers.SerializerMethodField() @@ -86,8 +176,14 @@ class RetreatSerializer(BaseProductSerializer): # Note: this is a read-only field so it isn't used for Workplace creation. pictures = serializers.SerializerMethodField() + dates = RetreatDateSerializer( + source='retreat_dates', + many=True, + read_only=True, + ) + def validate_refund_rate(self, value): - if value > 100: + if value is None or value > 100: raise serializers.ValidationError(_( "Refund rate must be between 0 and 100 (%)." )) @@ -132,34 +228,11 @@ def get_reservations_canceled(self, obj): def validate(self, attr): err = {} - if attr.get('type', Retreat.TYPE_PHYSICAL) == Retreat.TYPE_PHYSICAL: - required_attrs = [ - 'accessibility', - 'postal_code', - 'has_shared_rooms' - ] - for key in required_attrs: - if attr.get(key, None) is None: - err.update( - { - key: _("This field is required.") - } - ) - if not check_if_translated_field('name', attr): - err.update(getMessageTranslate('name', attr, True)) - if not check_if_translated_field('details', attr): - err.update(getMessageTranslate('details', attr, True)) - if not check_if_translated_field('country', attr): - err.update(getMessageTranslate('country', attr, True)) - if not check_if_translated_field('state_province', attr): - err.update(getMessageTranslate('state_province', attr, True)) - if not check_if_translated_field('city', attr): - err.update(getMessageTranslate('city', attr, True)) - if not check_if_translated_field('address_line1', attr): - err.update(getMessageTranslate('address_line1', attr, True)) - if err: - raise serializers.ValidationError(err) + if not check_if_translated_field('name', attr): + err.update(getMessageTranslate('name', attr, True)) + if err: + raise serializers.ValidationError(err) return super(RetreatSerializer, self).validate(attr) def create(self, validated_data): @@ -169,24 +242,6 @@ def create(self, validated_data): """ retreat = super().create(validated_data) - cron_manager = CronManager() - # Set reminder email - # 24H for virtual retreat - # 7 Days for physical retreat - if retreat.type == Retreat.TYPE_VIRTUAL: - reminder_date = validated_data['start_time'] - timedelta(days=1) - else: - reminder_date = validated_data['start_time'] - timedelta(days=7) - - cron_manager.create_remind_user( - retreat.id, reminder_date - ) - - # Set post-event email - throwback_date = validated_data['end_time'] + timedelta(days=1) - cron_manager.create_recap( - retreat.id, throwback_date) - return retreat def to_representation(self, instance): @@ -203,6 +258,11 @@ def to_representation(self, instance): # TODO put back available after migration from is_active data.pop("available") + data['type'] = RetreatTypeSerializer( + instance.type, + context=self.context + ).data + if is_staff: return data return remove_translation_fields(data) @@ -225,6 +285,9 @@ class Meta: 'url': { 'view_name': 'retreat:retreat-detail', }, + 'type': { + 'view_name': 'retreat:retreattype-detail', + }, } @@ -319,14 +382,12 @@ def validate(self, attrs): is_active=True, ) - active_reservations = active_reservations.values_list( - 'retreat__start_time', - 'retreat__end_time', + active_reservations = active_reservations.all() - ) - - for retreats in active_reservations: - if max(retreats[0], start) < min(retreats[1], end): + 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 " @@ -521,13 +582,12 @@ def update(self, instance, validated_data): active_reservations = Reservation.objects.filter( user=user, is_active=True, - ).exclude(pk=instance.pk).values_list( - 'retreat__start_time', - 'retreat__end_time', - ) + ).exclude(pk=instance.pk) - for retreats in active_reservations: - if max(retreats[0], start) < min(retreats[1], end): + 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 " @@ -805,43 +865,10 @@ def update(self, instance, validated_data): ) raise - merge_data = { - 'RETREAT': new_retreat, - 'USER': instance.user, - } - - plain_msg = render_to_string( - "retreat_info.txt", - merge_data + send_retreat_confirmation_email( + instance.user, + new_retreat ) - msg_html = render_to_string( - "retreat_info.html", - merge_data - ) - - try: - response_send_mail = send_mail( - "Confirmation d'inscription à la retraite", - plain_msg, - settings.DEFAULT_FROM_EMAIL, - [instance.user.email], - html_message=msg_html, - ) - EmailLog.add(user.email, 'retreat_info', response_send_mail) - except Exception as err: - additional_data = { - 'title': "Confirmation d'inscription à la retraite", - 'default_from': settings.DEFAULT_FROM_EMAIL, - 'user_email': user.email, - 'merge_data': merge_data, - 'template': 'retreat_info' - } - Log.error( - source='SENDING_BLUE_TEMPLATE', - message=err, - additional_data=json.dumps(additional_data) - ) - raise return Reservation.objects.get(id=instance_pk) diff --git a/retirement/services.py b/retirement/services.py index e5adf261..72dbf6f7 100644 --- a/retirement/services.py +++ b/retirement/services.py @@ -5,7 +5,8 @@ from django.conf import settings from django.core.mail import send_mail -from blitz_api.services import send_mail as send_templated_email +from blitz_api.services import send_mail as send_templated_email, \ + send_email_from_template_id from django.template.loader import render_to_string from django.utils import timezone @@ -76,95 +77,55 @@ def send_retreat_confirmation_email(user, retreat): :param retreat: The retreat that the user just bought :return: """ - if retreat.type == retreat.TYPE_VIRTUAL: - return send_virtual_retreat_confirmation_email(user, retreat) - else: - return send_physical_retreat_confirmation_email(user, retreat) - - -def send_virtual_retreat_confirmation_email(user, retreat): - """ - This function sends an email to notify a user that a virtual retreat in - which he has bought a seat is starting soon. - """ - - start_time = retreat.start_time - start_time = start_time.astimezone(pytz.timezone('US/Eastern')) - - end_time = retreat.end_time - end_time = end_time.astimezone(pytz.timezone('US/Eastern')) - context = { - 'USER_FIRST_NAME': user.first_name, - 'USER_LAST_NAME': user.last_name, - 'USER_EMAIL': user.email, - 'RETREAT_NAME': retreat.name, - 'RETREAT_START_DATE': format_date( - start_time, - format='long', - locale='fr' - ), - 'RETREAT_START_TIME': start_time.strftime('%-Hh%M'), - 'RETREAT_END_DATE': format_date( - end_time, - format='long', - locale='fr' - ), - 'RETREAT_END_TIME': end_time.strftime('%-Hh%M'), - 'LINK_TO_BE_PREPARED': settings.LOCAL_SETTINGS[ - 'FRONTEND_INTEGRATION'][ - 'LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT'], - 'LINK_TO_USER_PROFILE': settings.LOCAL_SETTINGS[ - 'FRONTEND_INTEGRATION']['PROFILE_URL'], - } - if len(retreat.pictures.all()): - context['RETREAT_PICTURE'] = "{0}{1}".format( - settings.MEDIA_URL, - retreat.pictures.first().picture.url - ) - - response_send_mail = send_templated_email( - [user], - context, - 'WELCOME_VIRTUAL_RETREAT' - ) - return response_send_mail - - -def send_physical_retreat_confirmation_email(user, retreat): - """ - This function sends an email to notify a user that a physical retreat in - which he has bought a seat is starting soon. - """ - - start_time = retreat.start_time - start_time = start_time.astimezone(pytz.timezone('US/Eastern')) - - end_time = retreat.end_time - end_time = end_time.astimezone(pytz.timezone('US/Eastern')) - context = { - 'USER_FIRST_NAME': user.first_name, - 'USER_LAST_NAME': user.last_name, - 'USER_EMAIL': user.email, - 'RETREAT_NAME': retreat.name, - 'RETREAT_START_TIME': start_time.strftime('%Y-%m-%d %H:%M'), - 'RETREAT_END_TIME': end_time.strftime('%Y-%m-%d %H:%M'), - 'RETREAT_VIDEOCONFERENCE_TOOL': retreat.videoconference_tool, - 'RETREAT_VIDEOCONFERENCE_LINK': retreat.videoconference_link - } - - if len(retreat.pictures.all()): - context['RETREAT_PICTURE'] = "{0}{1}".format( - settings.MEDIA_URL, - retreat.pictures.first().picture.url + if retreat.type.template_id_for_welcome_message: + start_time = retreat.start_time + start_time = start_time.astimezone(pytz.timezone('US/Eastern')) + + end_time = retreat.end_time + end_time = end_time.astimezone(pytz.timezone('US/Eastern')) + context = { + 'CUSTOM': json.loads(retreat.type.context_for_welcome_message), + 'USER_FIRST_NAME': user.first_name, + 'USER_LAST_NAME': user.last_name, + 'USER_EMAIL': user.email, + 'RETREAT_NAME': retreat.name, + 'RETREAT_START_DATE': format_date( + start_time, + format='long', + locale='fr' + ), + 'RETREAT_START_TIME': start_time.strftime('%-Hh%M'), + 'RETREAT_END_DATE': format_date( + end_time, + format='long', + locale='fr' + ), + 'RETREAT_TYPE': retreat.type.name, + 'RETREAT_END_TIME': end_time.strftime('%-Hh%M'), + 'RETREAT_START': start_time.strftime('%Y-%m-%d %H:%M'), + 'RETREAT_END': end_time.strftime('%Y-%m-%d %H:%M'), + 'RETREAT_VIDEOCONFERENCE_TOOL': retreat.videoconference_tool, + 'RETREAT_VIDEOCONFERENCE_LINK': retreat.videoconference_link, + 'LINK_TO_BE_PREPARED': settings.LOCAL_SETTINGS[ + 'FRONTEND_INTEGRATION'][ + 'LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT'], + 'LINK_TO_USER_PROFILE': settings.LOCAL_SETTINGS[ + 'FRONTEND_INTEGRATION']['PROFILE_URL'], + } + if len(retreat.pictures.all()): + context['RETREAT_PICTURE'] = "{0}{1}".format( + settings.MEDIA_URL, + retreat.pictures.first().picture.url + ) + + response_send_mail = send_email_from_template_id( + [user], + context, + retreat.type.template_id_for_welcome_message ) - - response_send_mail = send_templated_email( - [user], - context, - 'WELCOME_PHYSICAL_RETREAT' - ) - - return response_send_mail + return response_send_mail + else: + return [] def send_retreat_reminder_email(user, retreat): @@ -175,7 +136,7 @@ def send_retreat_reminder_email(user, retreat): :param retreat: The retreat that will begin soon :return: """ - if retreat.type == retreat.TYPE_VIRTUAL: + if retreat.type.name_fr == 'Virtuelle': return send_virtual_retreat_reminder_email(user, retreat) else: return send_physical_retreat_reminder_email(user, retreat) @@ -262,7 +223,7 @@ def send_post_retreat_email(user, retreat): :param retreat: The ended retreat :return: """ - if retreat.type == retreat.TYPE_VIRTUAL: + if retreat.type.name_fr == 'Virtuelle': return send_post_virtual_retreat_email(user, retreat) else: return send_post_physical_retreat_email(user, retreat) @@ -384,3 +345,48 @@ def refund_retreat(reservation, refund_rate, refund_reason): ) return refund_instance + + +def send_automatic_email(user, retreat, email): + """ + This function sends an automatic email to notify a user that has an + active reservation on a retreat. + """ + + start_time = retreat.start_time + start_time = start_time.astimezone(pytz.timezone('US/Eastern')) + + end_time = retreat.end_time + end_time = end_time.astimezone(pytz.timezone('US/Eastern')) + + context = { + 'CUSTOM': json.load(email.context), + 'USER_FIRST_NAME': user.first_name, + 'USER_LAST_NAME': user.last_name, + 'USER_EMAIL': user.email, + 'RETREAT_NAME': retreat.name, + 'RETREAT_START_DATE': format_date( + start_time, + format='long', + locale='fr' + ), + 'RETREAT_START_TIME': start_time.strftime('%-Hh%M'), + 'RETREAT_END_DATE': format_date( + end_time, + format='long', + locale='fr' + ), + 'RETREAT_END_TIME': end_time.strftime('%-Hh%M'), + 'LINK_TO_BE_PREPARED': settings.LOCAL_SETTINGS[ + 'FRONTEND_INTEGRATION'][ + 'LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT'], + 'LINK_TO_USER_PROFILE': settings.LOCAL_SETTINGS[ + 'FRONTEND_INTEGRATION']['PROFILE_URL'], + } + + response_send_mail = send_email_from_template_id( + [user], + context, + email.template_id + ) + return response_send_mail diff --git a/retirement/tests/tests_model_Picture.py b/retirement/tests/tests_model_Picture.py index 1a838e9f..4e284d31 100644 --- a/retirement/tests/tests_model_Picture.py +++ b/retirement/tests/tests_model_Picture.py @@ -1,13 +1,18 @@ import shutil import tempfile -from datetime import datetime, timedelta +from datetime import datetime import pytz from django.conf import settings from django.test import override_settings from rest_framework.test import APITestCase -from ..models import Picture, Retreat +from retirement.models import ( + Picture, + Retreat, + RetreatDate, + RetreatType, +) def get_test_image_file(): @@ -25,27 +30,39 @@ class PictureTests(APITestCase): @classmethod def setUpClass(cls): super(PictureTests, cls).setUpClass() + + cls.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) cls.retreat = Retreat.objects.create( - name="random_retreat", - details="This is a description of the retreat.", - seats=40, + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", - price=3, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + price=199, min_day_refund=7, min_day_exchange=7, - refund_rate=100, - is_active=True, + refund_rate=50, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', - has_shared_rooms=True + has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=cls.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=cls.retreat, ) + cls.retreat.activate() @classmethod def tearDownClass(cls): diff --git a/retirement/tests/tests_model_Reservation.py b/retirement/tests/tests_model_Reservation.py index dc79c274..6c2cc4ed 100644 --- a/retirement/tests/tests_model_Reservation.py +++ b/retirement/tests/tests_model_Reservation.py @@ -8,9 +8,18 @@ from blitz_api.factories import UserFactory -from store.models import Order, OrderLine, Coupon - -from ..models import Reservation, Retreat +from store.models import ( + Order, + OrderLine, + Coupon, +) + +from retirement.models import ( + Reservation, + Retreat, + RetreatDate, + RetreatType, +) LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) @@ -22,27 +31,38 @@ class ReservationTests(APITestCase): def setUp(self): self.user = UserFactory() self.retreat_type = ContentType.objects.get_for_model(Retreat) + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) self.retreat = Retreat.objects.create( - name="random_retreat", - details="This is a description of the retreat.", - seats=40, + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", - price=3, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + price=199, min_day_refund=7, min_day_exchange=7, - refund_rate=100, - is_active=True, + refund_rate=50, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', - has_shared_rooms=True + has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, + ) + self.retreat.activate() self.order = Order.objects.create( user=self.user, transaction_date=timezone.now(), @@ -70,28 +90,33 @@ def test_create(self): self.assertEqual(str(reservation), str(self.user)) def test_refund_value_with_coupon(self): - retreat = Retreat.objects.create( - name="random_retreat", - details="This is a description of the retreat.", - seats=40, + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", price=100, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=90, - is_active=True, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', - has_shared_rooms=True + has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=retreat, + ) + retreat.activate() order = Order.objects.create( user=self.user, @@ -133,28 +158,33 @@ def test_refund_value_with_coupon(self): self.assertEqual(refund_value, round(72 * (TAX_RATE + 1.0), 2)) def test_refund_value_100(self): - retreat = Retreat.objects.create( - name="random_retreat", - details="This is a description of the retreat.", - seats=40, + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", price=100, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=100, - is_active=True, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', - has_shared_rooms=True + has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=retreat, ) + retreat.activate() order = Order.objects.create( user=self.user, diff --git a/retirement/tests/tests_model_Retreat.py b/retirement/tests/tests_model_Retreat.py index 1f01e9a4..46820ddf 100644 --- a/retirement/tests/tests_model_Retreat.py +++ b/retirement/tests/tests_model_Retreat.py @@ -4,39 +4,52 @@ from django.conf import settings from rest_framework.test import APITestCase -from ..models import Retreat +from retirement.models import ( + Retreat, + RetreatDate, + RetreatType, +) LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) class RetreatTests(APITestCase): + def test_create(self): """ Ensure that we can create a retreat. """ - retreat = Retreat.objects.create( - name="random_retreat", - details="This is a description of the retreat.", - seats=40, + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) + self.retreat = Retreat.objects.create( + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", - timezone="America/Montreal", - price=3, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + price=199, min_day_refund=7, min_day_exchange=7, - refund_rate=100, - is_active=True, + refund_rate=50, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', has_shared_rooms=True, - room_type=Retreat.DOUBLE_OCCUPATION, - toilet_gendered=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, ) + self.retreat.activate() - self.assertEqual(retreat.__str__(), "random_retreat") + self.assertEqual(self.retreat.__str__(), "mega_retreat") diff --git a/retirement/tests/tests_model_WaitQueue.py b/retirement/tests/tests_model_WaitQueue.py index 6610e2bc..75145926 100644 --- a/retirement/tests/tests_model_WaitQueue.py +++ b/retirement/tests/tests_model_WaitQueue.py @@ -6,7 +6,7 @@ from blitz_api.factories import UserFactory -from ..models import Retreat, WaitQueue +from ..models import Retreat, WaitQueue, RetreatType, RetreatDate LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) @@ -16,27 +16,38 @@ class WaitQueueTests(APITestCase): def setUpClass(cls): super(WaitQueueTests, cls).setUpClass() cls.user = UserFactory() + cls.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) cls.retreat = Retreat.objects.create( - name="random_retreat", - details="This is a description of the retreat.", - seats=40, + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", - price=3, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + price=199, min_day_refund=7, min_day_exchange=7, - refund_rate=100, - is_active=True, + refund_rate=50, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=cls.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=cls.retreat, ) + cls.retreat.activate() def test_create(self): """ @@ -49,5 +60,5 @@ def test_create(self): self.assertEqual( wait_queue.__str__(), - ', '.join(["random_retreat", str(self.user)]) + ', '.join(["mega_retreat", str(self.user)]) ) diff --git a/retirement/tests/test_viewset_Invitation.py b/retirement/tests/tests_viewset_Invitation.py similarity index 87% rename from retirement/tests/test_viewset_Invitation.py rename to retirement/tests/tests_viewset_Invitation.py index 574d9ddb..54b65439 100644 --- a/retirement/tests/test_viewset_Invitation.py +++ b/retirement/tests/tests_viewset_Invitation.py @@ -8,12 +8,22 @@ from django.contrib.contenttypes.models import ContentType from rest_framework import status from rest_framework.reverse import reverse -from rest_framework.test import APIClient, APITestCase, APIRequestFactory - -from blitz_api.factories import AdminFactory, UserFactory +from rest_framework.test import ( + APIClient, + APITestCase, + APIRequestFactory, +) + +from blitz_api.factories import ( + AdminFactory, + UserFactory, +) from store.models import Coupon -from ..models import Retreat +from retirement.models import ( + Retreat, + RetreatType, +) User = get_user_model() @@ -31,7 +41,12 @@ def setUpClass(cls): def setUp(self): - self.retreat_type = ContentType.objects.get_for_model(Retreat) + self.retreat_content_type = ContentType.objects.get_for_model(Retreat) + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) self.retreat = Retreat.objects.create( name="mega_retreat", details="This is a description of the mega retreat.", @@ -41,8 +56,6 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, @@ -53,6 +66,7 @@ def setUp(self): carpool_url='example2.com', review_url='example3.com', has_shared_rooms=True, + type=self.retreatType, ) self.coupon = Coupon.objects.create( @@ -66,7 +80,7 @@ def setUp(self): owner=self.user, ) self.coupon.applicable_retreats.add(self.retreat) - self.coupon.applicable_product_types.add(self.retreat_type) + self.coupon.applicable_product_types.add(self.retreat_content_type) factory = APIRequestFactory() self.request = factory.get('/') diff --git a/retirement/tests/tests_viewset_Picture.py b/retirement/tests/tests_viewset_Picture.py index 75ac3b3e..709ed78c 100644 --- a/retirement/tests/tests_viewset_Picture.py +++ b/retirement/tests/tests_viewset_Picture.py @@ -13,7 +13,11 @@ from blitz_api.factories import AdminFactory, UserFactory from blitz_api.services import remove_translation_fields -from ..models import Picture, Retreat +from retirement.models import ( + Picture, + Retreat, + RetreatType, +) User = get_user_model() MEDIA_ROOT = tempfile.mkdtemp() @@ -45,6 +49,11 @@ def setUpClass(cls): cls.admin = AdminFactory() def setUp(self): + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) self.retreat = Retreat.objects.create( name="random_retreat", details="This is a description of the retreat.", @@ -54,8 +63,6 @@ def setUp(self): state_province="Random state", country="Random country", price=3, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=100, @@ -65,6 +72,7 @@ def setUp(self): carpool_url='example2.com', review_url='example3.com', has_shared_rooms=True, + type=self.retreatType, ) self.picture = Picture.objects.create( name="random_picture", diff --git a/retirement/tests/tests_viewset_Reservation.py b/retirement/tests/tests_viewset_Reservation.py index 56217e91..d3e7e827 100644 --- a/retirement/tests/tests_viewset_Reservation.py +++ b/retirement/tests/tests_viewset_Reservation.py @@ -2,11 +2,10 @@ import pytz import responses -from datetime import datetime, timedelta -from decimal import Decimal, ROUND_HALF_UP +from datetime import datetime from rest_framework import status -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import APIClient from django.urls import reverse from django.utils import timezone @@ -18,19 +17,27 @@ from unittest import mock -from blitz_api.factories import UserFactory, AdminFactory -from blitz_api.services import remove_translation_fields +from blitz_api.factories import ( + UserFactory, + AdminFactory, +) +from blitz_api.testing_tools import CustomAPITestCase from log_management.models import EmailLog -from store.models import Order, OrderLine, Refund -from store.tests.paysafe_sample_responses import (SAMPLE_REFUND_RESPONSE, - SAMPLE_NO_AMOUNT_TO_REFUND, - SAMPLE_PAYMENT_RESPONSE, - SAMPLE_PROFILE_RESPONSE, - SAMPLE_CARD_RESPONSE, - UNKNOWN_EXCEPTION, ) +from store.models import ( + Order, + OrderLine, +) +from store.tests.paysafe_sample_responses import ( + SAMPLE_REFUND_RESPONSE, + SAMPLE_NO_AMOUNT_TO_REFUND, + UNKNOWN_EXCEPTION, +) -from ..models import Retreat, Reservation +from retirement.models import ( + Retreat, + Reservation, RetreatType, RetreatDate, +) User = get_user_model() @@ -48,14 +55,39 @@ 'CARD_URL': "cardpayments/v1/" } ) -class ReservationTests(APITestCase): +class ReservationTests(CustomAPITestCase): + ATTRIBUTES = [ + 'id', + 'url', + 'inscription_date', + 'is_active', + 'is_present', + 'user', + 'cancelation_action', + 'cancelation_date', + 'cancelation_reason', + 'refundable', + 'exchangeable', + 'retreat', + 'order_line', + 'invitation', + 'post_event_send', + 'pre_event_send', + 'retreat_details', + 'user_details', + ] def setUp(self): self.client = APIClient() self.user = UserFactory() self.user2 = UserFactory() self.admin = AdminFactory() - self.retreat_type = ContentType.objects.get_for_model(Retreat) + self.retreat_content_type = ContentType.objects.get_for_model(Retreat) + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) self.retreat = Retreat.objects.create( name="mega_retreat", details="This is a description of the mega retreat.", @@ -65,12 +97,9 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, accessibility=True, form_url="example.com", carpool_url='example2.com', @@ -78,7 +107,14 @@ def setUp(self): has_shared_rooms=True, toilet_gendered=False, room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, ) + self.retreat.activate() self.retreat.add_wait_queue_place(self.user, generate_cron=False) self.retreat2 = Retreat.objects.create( @@ -90,12 +126,9 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 2, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 2, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=100, - is_active=False, accessibility=True, form_url="example.com", carpool_url='example2.com', @@ -103,6 +136,12 @@ def setUp(self): has_shared_rooms=True, toilet_gendered=False, room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 2, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 2, 17, 12)), + retreat=self.retreat2, ) self.retreat_overlap = Retreat.objects.create( name="ultra_retreat", @@ -113,12 +152,9 @@ def setUp(self): state_province="Random state 2", country="Random country 2", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, accessibility=True, form_url="example.com", carpool_url='example2.com', @@ -126,7 +162,14 @@ def setUp(self): has_shared_rooms=True, toilet_gendered=False, room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat_overlap, ) + self.retreat_overlap.activate() self.order = Order.objects.create( user=self.user, transaction_date=timezone.now(), @@ -136,7 +179,7 @@ def setUp(self): self.order_line = OrderLine.objects.create( order=self.order, quantity=1, - content_type=self.retreat_type, + content_type=self.retreat_content_type, object_id=self.retreat.id, cost=self.retreat.price ) @@ -220,140 +263,19 @@ def test_create(self): self.assertEqual( response.status_code, - status.HTTP_201_CREATED, - msg=response.content.decode("utf-8") + status.HTTP_201_CREATED ) - response_data = json.loads(response.content) - response_data['retreat_details'] = remove_translation_fields( - response_data['retreat_details'] - ) - response_data['user_details'] = remove_translation_fields( - response_data['user_details'] - ) - del response_data['user_details']["first_name"] - del response_data['user_details']["last_name"] - del response_data['user_details']["email"] - del response_data['user_details']['date_joined'] - del response_data['retreat_details']['reservations'] - del response_data['id'] - del response_data['url'] - del response_data['inscription_date'] + content = json.loads(response.content) - content = { - 'is_active': True, - 'is_present': False, - 'user': 'http://testserver/users/' + str(self.user.id), - 'cancelation_action': None, - 'cancelation_date': None, - 'cancelation_reason': None, - 'refundable': False, - 'exchangeable': False, - 'retreat': 'http://testserver/retreat/retreats/' + - str(self.retreat2.id), - 'order_line': None, - 'invitation': None, - 'post_event_send': False, - 'pre_event_send': False, - 'retreat_details': { - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'activity_language': None, - 'end_time': '2130-02-17T12:00:00-05:00', - 'id': self.retreat2.id, - 'exclusive_memberships': [], - 'places_remaining': 38, - 'notification_interval': '1 00:00:00', - 'price': '199.00', - 'start_time': '2130-02-15T08:00:00-05:00', - 'users': [ - 'http://testserver/users/' + str(self.admin.id), - 'http://testserver/users/' + str(self.user.id) - ], - 'address_line1': '123 random street', - 'address_line2': None, - 'city': None, - 'country': 'Random country', - 'details': 'This is a description of the retreat.', - 'email_content': None, - 'latitude': None, - 'longitude': None, - 'name': 'random_retreat', - 'pictures': [], - 'postal_code': '123 456', - 'reserved_seats': 0, - 'seats': 40, - 'state_province': 'Random state', - 'timezone': None, - 'reservations_canceled': [], - 'total_reservations': 2, - 'refund_rate': 100, - 'min_day_refund': 7, - 'min_day_exchange': 7, - 'is_active': False, - 'accessibility': True, - 'form_url': 'example.com', - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'url': 'http://testserver/retreat/retreats/' + - str(self.retreat2.id), - 'has_shared_rooms': True, - 'hidden': False, - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - 'room_type': Retreat.SINGLE_OCCUPATION, - 'toilet_gendered': False, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - }, - 'user_details': { - 'academic_field': None, - 'academic_level': None, - 'birthdate': None, - 'gender': None, - 'language': User.LANGUAGE_FR, - 'groups': [], - 'id': self.user.id, - 'is_active': True, - 'is_staff': False, - 'is_superuser': False, - 'last_login': None, - 'membership': None, - 'membership_end': None, - 'other_phone': None, - 'phone': None, - 'tickets': 1, - 'university': None, - 'url': 'http://testserver/users/' + str(self.user.id), - 'user_permissions': [], - 'city': None, - 'personnal_restrictions': None, - 'academic_program_code': None, - 'faculty': None, - 'student_number': None, - 'volunteer_for_workplace': [], - 'hide_newsletter': False, - 'is_in_newsletter': False, - 'number_of_free_virtual_retreat': 0, - } - } - - self.assertCountEqual(response_data['retreat_details']['users'], - content['retreat_details']['users']) - - del response_data['retreat_details']['users'] - del content['retreat_details']['users'] - - self.assertEqual(response_data, content) + self.assertCountEqual( + content['retreat_details']['users'], + [ + 'http://testserver/users/' + str(self.admin.id), + 'http://testserver/users/' + str(self.user.id) + ] + ) + self.check_attributes(content) def test_create_without_permission(self): """ @@ -1307,17 +1229,19 @@ def test_remind_users(self): response.content ) + MAIL_SERVICE = settings.ANYMAIL + template = MAIL_SERVICE["TEMPLATES"].get('REMINDER_PHYSICAL_RETREAT') self.assertTrue( EmailLog.objects.filter( user_email=self.user.email, - type_email='REMINDER_PHYSICAL_RETREAT' + type_email='Template #' + str(template) ) ) self.assertEqual( EmailLog.objects.filter( user_email=self.user.email, - type_email='REMINDER_PHYSICAL_RETREAT' + type_email='Template #' + str(template) )[0].nb_email_sent, 1 ) diff --git a/retirement/tests/tests_viewset_Reservation_update.py b/retirement/tests/tests_viewset_Reservation_update.py index 1ff7a5fc..5828ca4c 100644 --- a/retirement/tests/tests_viewset_Reservation_update.py +++ b/retirement/tests/tests_viewset_Reservation_update.py @@ -2,7 +2,7 @@ import pytz import responses -from datetime import datetime, timedelta +from datetime import datetime from decimal import Decimal, ROUND_HALF_UP from rest_framework import status @@ -19,17 +19,16 @@ from unittest import mock from blitz_api.factories import UserFactory, AdminFactory -from blitz_api.services import remove_translation_fields from store.models import Order, OrderLine, Refund -from store.tests.paysafe_sample_responses import (SAMPLE_REFUND_RESPONSE, - SAMPLE_NO_AMOUNT_TO_REFUND, - SAMPLE_PAYMENT_RESPONSE, - SAMPLE_PROFILE_RESPONSE, - SAMPLE_CARD_RESPONSE, - UNKNOWN_EXCEPTION, ) +from store.tests.paysafe_sample_responses import ( + SAMPLE_REFUND_RESPONSE, + SAMPLE_PAYMENT_RESPONSE, + SAMPLE_PROFILE_RESPONSE, + SAMPLE_CARD_RESPONSE, +) -from ..models import Retreat, Reservation +from ..models import Retreat, Reservation, RetreatType, RetreatDate User = get_user_model() @@ -53,7 +52,13 @@ def setUp(self): self.client = APIClient() self.user = UserFactory() self.admin = AdminFactory() - self.retreat_type = ContentType.objects.get_for_model(Retreat) + self.retreat_content_type = ContentType.objects.get_for_model(Retreat) + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + template_id_for_welcome_message=1, + ) self.retreat = Retreat.objects.create( name="mega_retreat", details="This is a description of the mega retreat.", @@ -63,18 +68,22 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', has_shared_rooms=True, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, ) + self.retreat.activate() self.retreat.add_wait_queue_place(self.user, generate_cron=False) self.retreat2 = Retreat.objects.create( @@ -86,18 +95,22 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 2, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 2, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=100, - is_active=False, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', has_shared_rooms=True, + type=self.retreatType, ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 2, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 2, 17, 12)), + retreat=self.retreat2, + ) + self.retreat_overlap = Retreat.objects.create( name="ultra_retreat", details="This is a description of the ultra retreat.", @@ -107,18 +120,23 @@ def setUp(self): state_province="Random state 2", country="Random country 2", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', has_shared_rooms=True, + type=self.retreatType, ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat_overlap, + ) + self.retreat_overlap.activate() + self.order = Order.objects.create( user=self.user, transaction_date=timezone.now(), @@ -128,7 +146,7 @@ def setUp(self): self.order_line = OrderLine.objects.create( order=self.order, quantity=1, - content_type=self.retreat_type, + content_type=self.retreat_content_type, object_id=self.retreat.id, cost=self.retreat.price, ) @@ -195,6 +213,16 @@ def test_update(self): status.HTTP_405_METHOD_NOT_ALLOWED ) + @override_settings( + LOCAL_SETTINGS={ + "EMAIL_SERVICE": True, + "FRONTEND_INTEGRATION": { + "POLICY_URL": "fake_url", + "LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT": "fake_url", + "PROFILE_URL": "fake_url" + } + } + ) def test_update_partial(self): """ Ensure we can partially update a reservation (is_present field and @@ -611,6 +639,16 @@ def test_update_partial_more_expensive_retreat_missing_info(self): self.retreat2.save() @responses.activate + @override_settings( + LOCAL_SETTINGS={ + "EMAIL_SERVICE": True, + "FRONTEND_INTEGRATION": { + "POLICY_URL": "fake_url", + "LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT": "fake_url", + "PROFILE_URL": "fake_url" + } + } + ) def test_update_partial_more_expensive_retreat(self): """ Ensure we can change retreat if the new one is more expensive and @@ -731,6 +769,16 @@ def test_update_partial_more_expensive_retreat(self): self.retreat2.save() @responses.activate + @override_settings( + LOCAL_SETTINGS={ + "EMAIL_SERVICE": True, + "FRONTEND_INTEGRATION": { + "POLICY_URL": "fake_url", + "LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT": "fake_url", + "PROFILE_URL": "fake_url" + } + } + ) def test_update_partial_more_expensive_retreat_single_use_token(self): """ Ensure we can change retreat if the new one is more expensive and @@ -862,6 +910,16 @@ def test_update_partial_more_expensive_retreat_single_use_token(self): self.retreat2.save() @responses.activate + @override_settings( + LOCAL_SETTINGS={ + "EMAIL_SERVICE": True, + "FRONTEND_INTEGRATION": { + "POLICY_URL": "fake_url", + "LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT": "fake_url", + "PROFILE_URL": "fake_url" + } + } + ) def test_update_partial_less_expensive_retreat(self): """ Ensure we can change retreat if the new one is less expensive. A diff --git a/retirement/tests/tests_viewset_Retreat.py b/retirement/tests/tests_viewset_Retreat.py index 8faccde1..80fd6444 100644 --- a/retirement/tests/tests_viewset_Retreat.py +++ b/retirement/tests/tests_viewset_Retreat.py @@ -15,15 +15,75 @@ 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 +from ..models import Retreat, RetreatType, RetreatDate User = get_user_model() LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) -class RetreatTests(APITestCase): +class RetreatTests(CustomAPITestCase): + ATTRIBUTES = [ + 'id', + 'url', + 'details', + 'email_content', + 'address_line1', + 'address_line2', + 'available_on_product_types', + 'available_on_products', + 'city', + 'country', + 'postal_code', + 'state_province', + 'latitude', + 'longitude', + 'name', + 'notification_interval', + 'pictures', + 'start_time', + 'end_time', + 'seats', + 'reserved_seats', + 'activity_language', + 'price', + 'exclusive_memberships', + 'timezone', + 'is_active', + 'places_remaining', + 'min_day_exchange', + 'min_day_refund', + 'refund_rate', + 'reservations', + 'reservations_canceled', + 'total_reservations', + 'users', + 'accessibility', + 'form_url', + 'carpool_url', + 'review_url', + 'place_name', + 'has_shared_rooms', + 'options', + 'hidden', + 'accessibility_detail', + 'description', + 'food_allergen_free', + 'food_gluten_free', + 'food_vegan', + 'food_vege', + 'google_maps_url', + 'sub_title', + 'toilet_gendered', + 'room_type', + 'type', + 'videoconference_tool', + 'videoconference_link', + 'dates', + 'animator' + ] @classmethod def setUpClass(cls): @@ -34,6 +94,11 @@ def setUpClass(cls): def setUp(self): self.maxDiff = 10000 + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) self.retreat = Retreat.objects.create( name="mega_retreat", details="This is a description of the mega retreat.", @@ -43,12 +108,9 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, activity_language='FR', accessibility=True, form_url="example.com", @@ -57,7 +119,14 @@ def setUp(self): has_shared_rooms=True, toilet_gendered=False, room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, ) + self.retreat.activate() self.retreat2 = Retreat.objects.create( name="ultra_retreat", @@ -68,12 +137,9 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, activity_language='FR', accessibility=True, form_url="example.com", @@ -82,7 +148,14 @@ def setUp(self): has_shared_rooms=True, toilet_gendered=False, room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 17, 12)), + retreat=self.retreat2, ) + self.retreat2.activate() self.retreat_hidden = Retreat.objects.create( name="hidden_retreat", @@ -93,12 +166,9 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, activity_language='FR', accessibility=True, form_url="example.com", @@ -108,146 +178,14 @@ def setUp(self): hidden=True, toilet_gendered=False, room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, ) - - @override_settings( - EXTERNAL_SCHEDULER={ - 'URL': "http://example.com", - 'USER': "user", - 'PASSWORD': "password", - } - ) - @responses.activate - def test_create_physical_retreat(self): - """ - Ensure we can create a retreat if user has permission. - """ - self.client.force_authenticate(user=self.admin) - - responses.add( - responses.POST, - "http://example.com/authentication", - json={"token": "1234567890"}, - status=200 - ) - - responses.add( - responses.POST, - "http://example.com/tasks", - status=200 - ) - - data = { - 'name': "random_retreat", - 'seats': 40, - 'details': "short_description", - 'address_line1': 'random_address_1', - 'city': 'random_city', - 'country': 'Random_Country', - 'postal_code': 'RAN_DOM', - 'state_province': 'Random_State', - 'timezone': "America/Montreal", - 'price': '100.00', - 'start_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), - 'end_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 16)), - 'min_day_refund': 7, - 'min_day_exchange': 7, - 'refund_rate': 50, - 'is_active': True, - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'has_shared_rooms': True, - 'hidden': False, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': True, - 'room_type': Retreat.DOUBLE_OCCUPATION, - } - - response = self.client.post( - reverse('retreat:retreat-list'), - data, - format='json', - ) - - self.assertEqual( - response.status_code, - status.HTTP_201_CREATED, - response.content, - ) - - content = { - 'details': 'short_description', - 'email_content': None, - 'address_line1': 'random_address_1', - 'address_line2': None, - 'available_on_product_types': [], - 'available_on_products': [], - 'city': 'random_city', - 'country': 'Random_Country', - 'postal_code': 'RAN_DOM', - 'state_province': 'Random_State', - 'latitude': None, - 'longitude': None, - 'name': 'random_retreat', - 'notification_interval': '1 00:00:00', - 'pictures': [], - 'start_time': '2130-01-15T12:00:00-05:00', - 'end_time': '2130-01-17T16:00:00-05:00', - 'seats': 40, - 'reserved_seats': 0, - 'activity_language': None, - 'price': '100.00', - 'exclusive_memberships': [], - 'timezone': "America/Montreal", - 'is_active': True, - 'places_remaining': 40, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'users': [], - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'has_shared_rooms': True, - 'options': [], - 'hidden': False, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': True, - 'room_type': Retreat.DOUBLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - } - - response_data = remove_translation_fields(json.loads(response.content)) - del response_data['id'] - del response_data['url'] - - self.assertEqual( - response_data, - content + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2140, 1, 17, 12)), + retreat=self.retreat_hidden, ) + self.retreat_hidden.activate() @override_settings( EXTERNAL_SCHEDULER={ @@ -257,7 +195,7 @@ def test_create_physical_retreat(self): } ) @responses.activate - def test_create_virtual_retreat(self): + def test_create_retreat(self): """ Ensure we can create a retreat if user has permission. """ @@ -279,21 +217,21 @@ def test_create_virtual_retreat(self): data = { 'name': "random_retreat", 'seats': 40, - 'type': 'V', 'details': "short_description", 'timezone': "America/Montreal", 'price': '100.00', - 'start_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), - 'end_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 16)), 'min_day_refund': 7, 'min_day_exchange': 7, 'refund_rate': 50, - 'is_active': True, 'hidden': False, 'description': None, 'sub_title': None, 'postal_code': None, - 'place_name': None + 'place_name': None, + 'type': reverse( + 'retreat:retreattype-detail', + args=[self.retreatType.id] + ), } response = self.client.post( @@ -305,73 +243,28 @@ def test_create_virtual_retreat(self): self.assertEqual( response.status_code, status.HTTP_201_CREATED, - response.content, - ) - - content = { - 'details': 'short_description', - 'email_content': None, - 'address_line1': None, - 'address_line2': None, - 'available_on_product_types': [], - 'available_on_products': [], - 'city': None, - 'country': None, - 'postal_code': None, - 'state_province': None, - 'latitude': None, - 'longitude': None, - 'name': 'random_retreat', - 'notification_interval': '1 00:00:00', - 'pictures': [], - 'start_time': '2130-01-15T12:00:00-05:00', - 'end_time': '2130-01-17T16:00:00-05:00', - 'seats': 40, - 'reserved_seats': 0, - 'activity_language': None, - 'price': '100.00', - 'exclusive_memberships': [], - 'timezone': "America/Montreal", - 'is_active': True, - 'places_remaining': 40, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'users': [], - 'accessibility': None, - 'form_url': None, - 'carpool_url': None, - 'review_url': None, - 'place_name': None, - 'has_shared_rooms': None, - 'options': [], - 'hidden': False, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': None, - 'room_type': None, - 'type': 'V', - 'videoconference_tool': None, - 'videoconference_link': None - } - - response_data = remove_translation_fields(json.loads(response.content)) - del response_data['id'] - del response_data['url'] - - self.assertEqual( - response_data, - content - ) + response.content + ) + + attributes = self.ATTRIBUTES + [ + 'state_province_fr', + 'old_id', + 'details_en', + 'details_fr', + 'address_line2_en', + 'name_en', + 'name_fr', + 'state_province_en', + 'country_fr', + 'country_en', + 'city_en', + 'address_line2_fr', + 'address_line1_fr', + 'city_fr', + 'address_line1_en', + ] + content = json.loads(response.content) + self.check_attributes(content, attributes) @override_settings( EXTERNAL_SCHEDULER={ @@ -425,6 +318,10 @@ def test_create(self): 'hidden': True, 'toilet_gendered': True, 'room_type': Retreat.DOUBLE_OCCUPATION, + 'type': reverse( + 'retreat:retreattype-detail', + args=[self.retreatType.id] + ), } response = self.client.post( @@ -436,73 +333,28 @@ def test_create(self): self.assertEqual( response.status_code, status.HTTP_201_CREATED, - response.content, - ) - - content = { - 'details': 'short_description', - 'email_content': None, - 'address_line1': 'random_address_1', - 'address_line2': None, - 'available_on_product_types': [], - 'available_on_products': [], - 'city': 'random_city', - 'country': 'Random_Country', - 'postal_code': 'RAN_DOM', - 'state_province': 'Random_State', - 'latitude': None, - 'longitude': None, - 'name': 'random_retreat', - 'notification_interval': '1 00:00:00', - 'options': [], - 'pictures': [], - 'start_time': '2130-01-15T12:00:00-05:00', - 'end_time': '2130-01-17T16:00:00-05:00', - 'seats': 40, - 'reserved_seats': 0, - 'activity_language': None, - 'price': '100.00', - 'exclusive_memberships': [], - 'timezone': "America/Montreal", - 'is_active': True, - 'places_remaining': 40, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'users': [], - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'has_shared_rooms': True, - 'hidden': True, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': True, - 'room_type': Retreat.DOUBLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - } - - response_data = remove_translation_fields(json.loads(response.content)) - del response_data['id'] - del response_data['url'] - - self.assertEqual( - response_data, - content - ) + response.content + ) + + attributes = self.ATTRIBUTES + [ + 'state_province_fr', + 'old_id', + 'details_en', + 'details_fr', + 'address_line2_en', + 'name_en', + 'name_fr', + 'state_province_en', + 'country_fr', + 'country_en', + 'city_en', + 'address_line2_fr', + 'address_line1_fr', + 'city_fr', + 'address_line1_en', + ] + content = json.loads(response.content) + self.check_attributes(content, attributes) @override_settings( EXTERNAL_SCHEDULER={ @@ -542,18 +394,19 @@ def test_create_without_toilet_gendered_and_room_type(self): 'state_province': 'Random_State', 'timezone': "America/Montreal", 'price': '100.00', - 'start_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), - 'end_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 16)), 'min_day_refund': 7, 'min_day_exchange': 7, 'refund_rate': 50, - 'is_active': True, 'accessibility': True, 'form_url': "example.com", 'carpool_url': 'example2.com', 'review_url': 'example3.com', 'has_shared_rooms': True, 'hidden': True, + 'type': reverse( + 'retreat:retreattype-detail', + args=[self.retreatType.id] + ), } response = self.client.post( @@ -565,73 +418,28 @@ def test_create_without_toilet_gendered_and_room_type(self): self.assertEqual( response.status_code, status.HTTP_201_CREATED, - response.content, - ) - - content = { - 'details': 'short_description', - 'email_content': None, - 'address_line1': 'random_address_1', - 'address_line2': None, - 'available_on_product_types': [], - 'available_on_products': [], - 'city': 'random_city', - 'country': 'Random_Country', - 'postal_code': 'RAN_DOM', - 'state_province': 'Random_State', - 'latitude': None, - 'longitude': None, - 'name': 'random_retreat', - 'notification_interval': '1 00:00:00', - 'options': [], - 'pictures': [], - 'start_time': '2130-01-15T12:00:00-05:00', - 'end_time': '2130-01-17T16:00:00-05:00', - 'seats': 40, - 'reserved_seats': 0, - 'activity_language': None, - 'price': '100.00', - 'exclusive_memberships': [], - 'timezone': "America/Montreal", - 'is_active': True, - 'places_remaining': 40, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'users': [], - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'has_shared_rooms': True, - 'hidden': True, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': None, - 'room_type': None, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - } - - response_data = remove_translation_fields(json.loads(response.content)) - del response_data['id'] - del response_data['url'] - - self.assertEqual( - response_data, - content - ) + response.content + ) + + attributes = self.ATTRIBUTES + [ + 'state_province_fr', + 'old_id', + 'details_en', + 'details_fr', + 'address_line2_en', + 'name_en', + 'name_fr', + 'state_province_en', + 'country_fr', + 'country_en', + 'city_en', + 'address_line2_fr', + 'address_line1_fr', + 'city_fr', + 'address_line1_en', + ] + content = json.loads(response.content) + self.check_attributes(content, attributes) def test_create_invalid_refund_rate(self): """ @@ -651,18 +459,19 @@ def test_create_invalid_refund_rate(self): 'state_province': 'Random_State', 'timezone': "America/Montreal", 'price': '100.00', - 'start_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), - 'end_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 16)), 'min_day_refund': 7, 'min_day_exchange': 7, 'refund_rate': 500, - 'is_active': True, 'accessibility': True, 'form_url': "example.com", 'carpool_url': 'example2.com', 'review_url': 'example3.com', 'has_shared_rooms': True, 'hidden': False, + 'type': reverse( + 'retreat:retreattype-detail', + args=[self.retreatType.id] + ), } response = self.client.post( @@ -730,18 +539,19 @@ def test_create_duplicate_name(self): 'state_province': 'Random_State', 'timezone': "America/Montreal", 'price': '100.00', - 'start_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), - 'end_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 16)), 'min_day_refund': 7, 'min_day_exchange': 7, 'refund_rate': 50, - 'is_active': True, 'accessibility': True, 'form_url': "example.com", 'carpool_url': 'example2.com', 'review_url': 'example3.com', 'has_shared_rooms': True, 'hidden': False, + 'type': reverse( + 'retreat:retreattype-detail', + args=[self.retreatType.id] + ), } response = self.client.post( @@ -771,29 +581,23 @@ def test_create_missing_field(self): ) content = { - 'details': ['This field is required.'], - 'address_line1': ['This field is required.'], - 'city': ['This field is required.'], - 'country': ['This field is required.'], - 'name': ['This field is required.'], - 'postal_code': ['This field is required.'], - 'seats': ['This field is required.'], - 'state_province': ['This field is required.'], - 'timezone': ['This field is required.'], "price": ["This field is required."], - "start_time": ["This field is required."], - "end_time": ["This field is required."], - "min_day_refund": ["This field is required."], - "refund_rate": ["This field is required."], - "min_day_exchange": ["This field is required."], - "is_active": ["This field is required."], - "accessibility": ["This field is required."], - "has_shared_rooms": ["This field is required."], + "timezone": ["This field is required."], + "type": ["This field is required."], + "name": ["This field is required."], } - self.assertEqual(json.loads(response.content), content) + self.assertEqual( + response.status_code, + status.HTTP_400_BAD_REQUEST, + response.content + ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + json.loads(response.content), + content, + response.content + ) def test_create_invalid_field(self): """ @@ -813,9 +617,6 @@ def test_create_invalid_field(self): 'state_province': (1,), 'timezone': ("invalid",), 'price': "", - 'start_time': "", - 'end_time': "", - 'is_active': "", 'min_day_exchange': (1,), 'min_day_refund': (1,), 'refund_rate': (1,), @@ -847,16 +648,7 @@ def test_create_invalid_field(self): 'country': ['Not a valid string.'], 'seats': ['A valid integer is required.'], 'timezone': ['Unknown timezone'], - 'is_active': ['Must be a valid boolean.'], - 'end_time': [ - 'Datetime has wrong format. Use one of these formats instead: ' - 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].' - ], 'price': ['A valid number is required.'], - 'start_time': [ - 'Datetime has wrong format. Use one of these formats instead: ' - 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].' - ], 'min_day_exchange': ['A valid integer is required.'], 'min_day_refund': ['A valid integer is required.'], 'refund_rate': ['A valid integer is required.'], @@ -866,7 +658,7 @@ def test_create_invalid_field(self): 'review_url': ['Not a valid string.'], 'has_shared_rooms': ['Must be a valid boolean.'], 'place_name': ['Not a valid string.'], - 'type': ['"[1]" is not a valid choice.'], + 'type': ['Incorrect type. Expected URL string, received list.'], 'videoconference_tool': ['Not a valid string.'] } @@ -891,12 +683,9 @@ def test_update(self): 'state_province': 'Random state', 'timezone': "America/Montreal", 'price': '199.00', - 'start_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - 'end_time': LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), 'min_day_refund': 7, 'min_day_exchange': 7, 'refund_rate': 50, - 'is_active': False, 'accessibility': True, 'form_url': "example.com", 'carpool_url': 'example2.com', @@ -907,7 +696,7 @@ def test_update(self): 'room_type': Retreat.DOUBLE_OCCUPATION, } - response = self.client.put( + response = self.client.patch( reverse( 'retreat:retreat-detail', kwargs={'pk': self.retreat.id}, @@ -916,71 +705,31 @@ def test_update(self): format='json', ) - content = { - 'details': 'short_description', - 'email_content': None, - 'activity_language': 'FR', - 'id': self.retreat.id, - 'address_line1': 'random_address_1', - 'address_line2': None, - 'city': 'New city', - 'country': 'Random country', - 'postal_code': '123 456', - 'state_province': 'Random state', - 'latitude': None, - 'longitude': None, - 'name': 'New Name', - 'pictures': [], - 'start_time': '2130-01-15T08:00:00-05:00', - 'end_time': '2130-01-17T12:00:00-05:00', - 'seats': 40, - 'reserved_seats': 0, - 'notification_interval': '1 00:00:00', - 'price': '199.00', - 'exclusive_memberships': [], - 'timezone': "America/Montreal", - 'is_active': False, - 'places_remaining': 40, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'users': [], - 'url': 'http://testserver/retreat/retreats/' + - str(self.retreat.id), - 'has_shared_rooms': True, - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - 'hidden': False, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': True, - 'room_type': Retreat.DOUBLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - } - self.assertEqual( - remove_translation_fields(json.loads(response.content)), - content - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) + response.status_code, + status.HTTP_200_OK, + response.content + ) + + attributes = self.ATTRIBUTES + [ + 'state_province_fr', + 'old_id', + 'details_en', + 'details_fr', + 'address_line2_en', + 'name_en', + 'name_fr', + 'state_province_en', + 'country_fr', + 'country_en', + 'city_en', + 'address_line2_fr', + 'address_line1_fr', + 'city_fr', + 'address_line1_en', + ] + content = json.loads(response.content) + self.check_attributes(content, attributes) def test_delete(self): """ @@ -1020,76 +769,13 @@ def test_list(self): format='json', ) - content = { - 'count': 1, - 'next': None, - 'previous': None, - 'results': [ - { - 'activity_language': 'FR', - 'details': 'This is a description of the mega retreat.', - 'email_content': None, - 'id': self.retreat.id, - 'address_line1': '123 random street', - 'address_line2': None, - 'city': None, - 'country': 'Random country', - 'postal_code': '123 456', - 'state_province': 'Random state', - 'latitude': None, - 'longitude': None, - 'name': 'mega_retreat', - 'pictures': [], - 'start_time': '2130-01-15T08:00:00-05:00', - 'end_time': '2130-01-17T12:00:00-05:00', - 'seats': 400, - 'reserved_seats': 0, - 'notification_interval': '1 00:00:00', - 'price': '199.00', - 'exclusive_memberships': [], - 'timezone': None, - 'is_active': True, - 'places_remaining': 400, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'users': [], - 'url': 'http://testserver/retreat/retreats/' + - str(self.retreat.id), - 'has_shared_rooms': True, - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - 'hidden': False, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - } - ] - } - - self.assertEqual(json.loads(response.content), content) + content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) + for item in content['results']: + self.check_attributes(item) + def test_list_as_admin(self): self.client.force_authenticate(user=self.admin) @@ -1101,242 +787,29 @@ def test_list_as_admin(self): format='json', ) - content = {'count': 3, 'next': None, 'previous': None, 'results': [ - { - 'places_remaining': 400, 'total_reservations': 0, - 'reservations': [], 'reservations_canceled': [], - 'timezone': None, - 'name': 'hidden_retreat', 'name_fr': None, - 'name_en': 'hidden_retreat', - 'details': 'This is a description of the hidden retreat.', - 'country': 'Random country', 'state_province': 'Random state', - 'city': None, 'address_line1': '123 random street', - 'has_shared_rooms': True, 'is_active': True, - 'accessibility': True, - 'pictures': [], 'place_name': None, 'country_fr': None, - 'country_en': 'Random country', 'state_province_fr': None, - 'state_province_en': 'Random state', 'city_fr': None, - 'city_en': None, 'address_line1_fr': None, - 'address_line1_en': '123 random street', 'address_line2': None, - 'address_line2_fr': None, 'address_line2_en': None, - 'available_on_product_types': [], - 'available_on_products': [], - 'postal_code': '123 456', 'latitude': None, 'longitude': None, - 'details_fr': None, - 'details_en': 'This is a description of the hidden retreat.', - 'seats': 400, 'reserved_seats': 0, - 'notification_interval': '1 00:00:00', - 'old_id': None, - 'options': [], - 'activity_language': 'FR', - 'price': '199.00', 'start_time': '2140-01-15T08:00:00-05:00', - 'end_time': '2140-01-17T12:00:00-05:00', 'min_day_refund': 7, - 'refund_rate': 50, 'min_day_exchange': 7, - 'email_content': None, - 'form_url': 'example.com', 'carpool_url': 'example2.com', - 'review_url': 'example3.com', 'hidden': True, 'users': [], - 'exclusive_memberships': [], - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - }, - { - 'places_remaining': 400, 'total_reservations': 0, - 'reservations': [], 'reservations_canceled': [], - 'timezone': None, - 'name': 'mega_retreat', 'name_fr': None, - 'name_en': 'mega_retreat', - 'details': 'This is a description of the mega retreat.', - 'country': 'Random country', 'state_province': 'Random state', - 'city': None, 'address_line1': '123 random street', - 'has_shared_rooms': True, 'is_active': True, - 'accessibility': True, - 'pictures': [], 'place_name': None, 'country_fr': None, - 'country_en': 'Random country', 'state_province_fr': None, - 'state_province_en': 'Random state', 'city_fr': None, - 'city_en': None, 'address_line1_fr': None, - 'address_line1_en': '123 random street', 'address_line2': None, - 'address_line2_fr': None, 'address_line2_en': None, - 'available_on_product_types': [], - 'available_on_products': [], - 'postal_code': '123 456', 'latitude': None, 'longitude': None, - 'details_fr': None, - 'details_en': 'This is a description of the mega retreat.', - 'seats': 400, 'reserved_seats': 0, - 'notification_interval': '1 00:00:00', - 'old_id': None, - 'options': [], - 'activity_language': 'FR', - 'price': '199.00', 'start_time': '2130-01-15T08:00:00-05:00', - 'end_time': '2130-01-17T12:00:00-05:00', 'min_day_refund': 7, - 'refund_rate': 50, 'min_day_exchange': 7, - 'email_content': None, - 'form_url': 'example.com', 'carpool_url': 'example2.com', - 'review_url': 'example3.com', 'hidden': False, 'users': [], - 'exclusive_memberships': [], - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - }, - { - 'places_remaining': 400, 'total_reservations': 0, - 'reservations': [], 'reservations_canceled': [], - 'timezone': None, - 'name': 'ultra_retreat', 'name_fr': None, - 'name_en': 'ultra_retreat', - 'details': 'This is a description of the ultra retreat.', - 'country': 'Random country', 'state_province': 'Random state', - 'city': None, 'address_line1': '123 random street', - 'has_shared_rooms': True, 'is_active': False, - 'accessibility': True, 'pictures': [], 'place_name': None, - 'country_fr': None, 'country_en': 'Random country', - 'state_province_fr': None, 'state_province_en': 'Random state', - 'city_fr': None, 'city_en': None, 'address_line1_fr': None, - 'address_line1_en': '123 random street', 'address_line2': None, - 'address_line2_fr': None, 'address_line2_en': None, - 'available_on_product_types': [], - 'available_on_products': [], - 'postal_code': '123 456', 'latitude': None, 'longitude': None, - 'details_fr': None, - 'details_en': 'This is a description of the ultra retreat.', - 'seats': 400, 'reserved_seats': 0, - 'notification_interval': '1 00:00:00', - 'old_id': None, - 'options': [], - 'activity_language': 'FR', - 'price': '199.00', 'start_time': '2140-01-15T08:00:00-05:00', - 'end_time': '2140-01-17T12:00:00-05:00', 'min_day_refund': 7, - 'refund_rate': 50, 'min_day_exchange': 7, - 'email_content': None, - 'form_url': 'example.com', 'carpool_url': 'example2.com', - 'review_url': 'example3.com', 'hidden': False, 'users': [], - 'exclusive_memberships': [], - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - }]} - - response_content = json.loads(response.content) - del response_content['results'][0]['url'] - del response_content['results'][0]['id'] - del response_content['results'][1]['url'] - del response_content['results'][1]['id'] - del response_content['results'][2]['url'] - del response_content['results'][2]['id'] - - self.assertEqual(response_content, content) + content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_list_filtered_by_end_time_gte(self): - """ - Ensure we can list retreats filtered by end_time greater - than a given date. - """ - - response = self.client.get( - reverse('retreat:retreat-list') + - "?end_time__gte=2139-01-01T00:00:00", - format='json', - ) - - content = { - 'count': 1, - 'next': None, - 'previous': None, - 'results': [{ - 'activity_language': 'FR', - 'details': 'This is a description of the ultra retreat.', - 'email_content': None, - 'id': self.retreat2.id, - 'address_line1': '123 random street', - 'address_line2': None, - 'city': None, - 'country': 'Random country', - 'postal_code': '123 456', - 'state_province': 'Random state', - 'latitude': None, - 'longitude': None, - 'name': 'ultra_retreat', - 'pictures': [], - 'start_time': '2140-01-15T08:00:00-05:00', - 'end_time': '2140-01-17T12:00:00-05:00', - 'seats': 400, - 'reserved_seats': 0, - 'notification_interval': '1 00:00:00', - 'price': '199.00', - 'exclusive_memberships': [], - 'timezone': None, - 'is_active': True, - 'places_remaining': 400, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'users': [], - 'url': 'http://testserver/retreat/retreats/' + - str(self.retreat2.id), - 'has_shared_rooms': True, - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - 'hidden': False, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - }] - } - - self.assertEqual(json.loads(response.content), content) - - self.assertEqual(response.status_code, status.HTTP_200_OK) + attributes = self.ATTRIBUTES + [ + 'state_province_fr', + 'old_id', + 'details_en', + 'details_fr', + 'address_line2_en', + 'name_en', + 'name_fr', + 'state_province_en', + 'country_fr', + 'country_en', + 'city_en', + 'address_line2_fr', + 'address_line1_fr', + 'city_fr', + 'address_line1_en', + ] + for item in content['results']: + self.check_attributes(item, attributes) def test_read(self): """ @@ -1350,68 +823,14 @@ def test_read(self): ), ) - content = { - 'details': 'This is a description of the mega retreat.', - 'email_content': None, - 'activity_language': 'FR', - 'id': self.retreat.id, - 'address_line1': '123 random street', - 'address_line2': None, - 'city': None, - 'country': 'Random country', - 'postal_code': '123 456', - 'state_province': 'Random state', - 'latitude': None, - 'longitude': None, - 'name': 'mega_retreat', - 'pictures': [], - 'start_time': '2130-01-15T08:00:00-05:00', - 'end_time': '2130-01-17T12:00:00-05:00', - 'seats': 400, - 'reserved_seats': 0, - 'notification_interval': '1 00:00:00', - 'price': '199.00', - 'exclusive_memberships': [], - 'timezone': None, - 'is_active': True, - 'places_remaining': 400, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'users': [], - 'url': 'http://testserver/retreat/retreats/' + - str(self.retreat.id), - 'has_shared_rooms': True, - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - 'hidden': False, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - } - - self.assertEqual(json.loads(response.content), content) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + content = json.loads(response.content) + self.check_attributes(content) def test_read_as_admin(self): """ @@ -1426,74 +845,32 @@ def test_read_as_admin(self): ), ) - response_data = json.loads(response.content) - - self.assertTrue('name_fr' in response_data) - - response_data = remove_translation_fields(response_data) + content = json.loads(response.content) - content = { - 'details': 'This is a description of the mega retreat.', - 'activity_language': 'FR', - 'email_content': None, - 'id': self.retreat.id, - 'address_line1': '123 random street', - 'address_line2': None, - 'city': None, - 'country': 'Random country', - 'postal_code': '123 456', - 'state_province': 'Random state', - 'latitude': None, - 'longitude': None, - 'name': 'mega_retreat', - 'pictures': [], - 'start_time': '2130-01-15T08:00:00-05:00', - 'end_time': '2130-01-17T12:00:00-05:00', - 'reserved_seats': 0, - 'notification_interval': '1 00:00:00', - 'seats': 400, - 'price': '199.00', - 'exclusive_memberships': [], - 'timezone': None, - 'is_active': True, - 'places_remaining': 400, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'total_reservations': 0, - 'accessibility': True, - 'form_url': "example.com", - 'carpool_url': 'example2.com', - 'review_url': 'example3.com', - 'place_name': None, - 'users': [], - 'url': 'http://testserver/retreat/retreats/' + - str(self.retreat.id), - 'has_shared_rooms': True, - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - 'hidden': False, - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - } - - self.assertEqual(response_data, content) - - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + + attributes = self.ATTRIBUTES + [ + 'state_province_fr', + 'old_id', + 'details_en', + 'details_fr', + 'address_line2_en', + 'name_en', + 'name_fr', + 'state_province_en', + 'country_fr', + 'country_en', + 'city_en', + 'address_line2_fr', + 'address_line1_fr', + 'city_fr', + 'address_line1_en', + ] + self.check_attributes(content, attributes) def test_read_non_existent_retreat(self): """ diff --git a/retirement/tests/tests_viewset_WaitQueue.py b/retirement/tests/tests_viewset_Wait_Queue.py similarity index 97% rename from retirement/tests/tests_viewset_WaitQueue.py rename to retirement/tests/tests_viewset_Wait_Queue.py index 79674cb6..513383ea 100644 --- a/retirement/tests/tests_viewset_WaitQueue.py +++ b/retirement/tests/tests_viewset_Wait_Queue.py @@ -11,7 +11,7 @@ from blitz_api.factories import AdminFactory, UserFactory -from ..models import Retreat, WaitQueue +from ..models import Retreat, WaitQueue, RetreatType, RetreatDate User = get_user_model() @@ -29,6 +29,11 @@ def setUpClass(cls): cls.admin = AdminFactory() def setUp(self): + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) self.retreat = Retreat.objects.create( name="mega_retreat", details="This is a description of the mega retreat.", @@ -38,19 +43,23 @@ def setUp(self): state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, activity_language='FR', accessibility=True, form_url="example.com", carpool_url='example2.com', review_url='example3.com', has_shared_rooms=True, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, ) + self.retreat.activate() self.wait_queue_subscription = WaitQueue.objects.create( user=self.user2, retreat=self.retreat, diff --git a/retirement/tests/tests_Wait_Queue_Place.py b/retirement/tests/tests_viewset_Wait_Queue_Place.py similarity index 86% rename from retirement/tests/tests_Wait_Queue_Place.py rename to retirement/tests/tests_viewset_Wait_Queue_Place.py index 7203eee9..1fb9ed02 100644 --- a/retirement/tests/tests_Wait_Queue_Place.py +++ b/retirement/tests/tests_viewset_Wait_Queue_Place.py @@ -9,12 +9,13 @@ from rest_framework.test import APITestCase from blitz_api.factories import RetreatFactory, UserFactory, AdminFactory -from ..models import WaitQueuePlace, WaitQueue, WaitQueuePlaceReserved +from ..models import WaitQueuePlace, WaitQueue, WaitQueuePlaceReserved, \ + Retreat, RetreatDate, RetreatType LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) -class RetreatTests(APITestCase): +class WaitQueuePlaceTests(APITestCase): def setUp(self) -> None: self.admin = AdminFactory() @@ -27,9 +28,39 @@ def setUp(self) -> None: self.user6 = UserFactory(email='user6@test.com') self.user_cancel = UserFactory() - self.retreat = RetreatFactory() - self.retreat.min_day_refund = 7 - self.retreat.save() + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) + + self.retreat = Retreat.objects.create( + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, + address_line1="123 random street", + postal_code="123 456", + state_province="Random state", + country="Random country", + price=199, + min_day_refund=7, + min_day_exchange=7, + refund_rate=50, + accessibility=True, + form_url="example.com", + carpool_url='example2.com', + review_url='example3.com', + has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, + ) + self.retreat.activate() self.wait_queue_place = WaitQueuePlace.objects.create( retreat=self.retreat, diff --git a/retirement/translation.py b/retirement/translation.py index b1ee1a34..1d312ac9 100644 --- a/retirement/translation.py +++ b/retirement/translation.py @@ -4,6 +4,13 @@ from . import models +@register(models.RetreatType) +class RetreatTypeTranslationOptions(TranslationOptions): + fields = ( + 'name', + ) + + @register(models.Retreat) class RetreatTranslationOptions(TranslationOptions): fields = ( @@ -20,5 +27,6 @@ class PictureTranslationOptions(TranslationOptions): fields = ('name', ) +simple_history.register(models.RetreatType, inherit=True) simple_history.register(models.Retreat, inherit=True) simple_history.register(models.Picture, inherit=True) diff --git a/retirement/urls.py b/retirement/urls.py index 5073cad7..565d7ab6 100644 --- a/retirement/urls.py +++ b/retirement/urls.py @@ -40,6 +40,9 @@ def __init__(self, *args, **kwargs): router.register('wait_queue_places', views.WaitQueuePlaceViewSet) router.register('wait_queue_places_reserved', views.WaitQueuePlaceReservedViewSet) +router.register('retreat_types', views.RetreatTypeViewSet) +router.register('retreat_dates', views.RetreatDateViewSet) +router.register('automatic_emails', views.AutomaticEmailViewSet) urlpatterns = [ path('', include(router.urls)), # includes router generated URL diff --git a/retirement/views.py b/retirement/views.py index c353b574..edefc06d 100644 --- a/retirement/views.py +++ b/retirement/views.py @@ -1,9 +1,10 @@ import json -from decimal import Decimal from datetime import datetime, timedelta import pytz from django.core.files.base import ContentFile +from django.db.models import F, When, Case +from django_filters import DateTimeFilter, FilterSet from blitz_api.mixins import ExportMixin from django.conf import settings @@ -11,35 +12,66 @@ from django.core.mail import send_mail as django_send_mail from django.db import transaction from django.template.loader import render_to_string -from django.urls import reverse from django.utils import timezone from django.utils.translation import ugettext_lazy as _ -import rest_framework -from rest_framework import mixins, status, viewsets +from rest_framework import ( + mixins, + status, + viewsets, +) from rest_framework import serializers as rest_framework_serializers from rest_framework.decorators import action -from rest_framework.permissions import IsAdminUser, IsAuthenticated +from rest_framework.permissions import ( + IsAdminUser, + IsAuthenticated, +) from rest_framework.response import Response from blitz_api.models import ExportMedia from blitz_api.serializers import ExportMediaSerializer -from log_management.models import Log, EmailLog +from log_management.models import ( + Log, + EmailLog, +) from store.exceptions import PaymentAPIError from store.models import OrderLineBaseProduct from store.services import PAYSAFE_EXCEPTION -from . import permissions, serializers +from . import ( + permissions, + serializers, +) from .models import ( - Picture, Reservation, Retreat, WaitQueue, - RetreatInvitation, WaitQueuePlace, - WaitQueuePlaceReserved + Picture, + Reservation, + Retreat, + WaitQueue, + RetreatInvitation, + WaitQueuePlace, + WaitQueuePlaceReserved, + RetreatType, + AutomaticEmail, + AutomaticEmailLog, + RetreatDate, +) +from .resources import ( + ReservationResource, + RetreatResource, + WaitQueueResource, + RetreatReservationResource, + OptionProductResource, +) +from .serializers import ( + RetreatTypeSerializer, + AutomaticEmailSerializer, + RetreatDateSerializer, +) +from .services import ( + send_retreat_reminder_email, + send_post_retreat_email, + send_automatic_email, ) -from .resources import (ReservationResource, RetreatResource, - WaitQueueResource, - RetreatReservationResource, OptionProductResource) -from .services import (send_retreat_reminder_email, - send_post_retreat_email, ) User = get_user_model() @@ -63,13 +95,14 @@ class RetreatViewSet(ExportMixin, viewsets.ModelViewSet): queryset = Retreat.objects.all() permission_classes = (permissions.IsAdminOrReadOnly,) filterset_fields = { - 'start_time': ['exact', 'gte', 'lte'], - 'end_time': ['exact', 'gte', 'lte'], 'is_active': ['exact'], 'hidden': ['exact'], - 'type': ['exact'] + 'type__id': ['exact'], + 'retreat_dates__end_time': ['exact', 'gte', 'lte'], } - ordering = ('name', 'start_time', 'end_time') + ordering = [ + 'name', + ] export_resource = RetreatResource() @@ -90,6 +123,64 @@ def destroy(self, request, *args, **kwargs): instance.save() return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, permission_classes=[IsAdminUser], methods=['post']) + def activate(self, request, pk=None): + """ + That custom action allows an admin to activate + a retreat and to run all the automations related. + """ + retreat = self.get_object() + + try: + retreat.activate() + except ValueError as error: + return Response( + { + 'non_field_errors': [str(error)], + }, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.get_serializer(retreat) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=True, permission_classes=[]) + def execute_automatic_email(self, request, pk=None): + """ + That custom action allows an admin (or an automated task) to + notify a users who will attend the retreat with an existing + automated email pre-configured (AutomaticEmail). + """ + retreat = self.get_object() + try: + email = AutomaticEmail.objects.get( + id=int(request.GET.get('email')) + ) + except Exception: + response_data = { + 'detail': "AutomaticEmail not found" + } + return Response(response_data, status=status.HTTP_400_BAD_REQUEST) + + # Notify a user for every reserved seat + emails = [] + for reservation in retreat.reservations.filter(is_active=True): + if reservation.automatic_email_logs.filter(email=email): + pass + else: + send_automatic_email(reservation.user, retreat, email) + AutomaticEmailLog.objects.create( + reservation=reservation, + email=email + ) + emails.append(reservation.user.email) + + response_data = { + 'stop': True, + 'emails': emails + } + return Response(response_data, status=status.HTTP_200_OK) + @action(detail=True, permission_classes=[]) def remind_users(self, request, pk=None): """ @@ -97,6 +188,12 @@ def remind_users(self, request, pk=None): users who will attend the retreat. """ retreat = self.get_object() + if not retreat.is_active: + response_data = { + 'detail': "Retreat need to be activate to send emails." + } + return Response(response_data, status=status.HTTP_200_OK) + # This is a hard-coded limitation to allow anonymous users to call # the function. time_limit = retreat.start_time - timedelta(days=8) @@ -256,15 +353,18 @@ class ReservationViewSet(ExportMixin, viewsets.ModelViewSet): """ serializer_class = serializers.ReservationSerializer queryset = Reservation.objects.all() - filterset_fields = '__all__' + filterset_fields = [ + 'user', + 'retreat', + 'is_active', + 'retreat__type__is_virtual' + ] ordering_fields = ( 'is_active', 'is_present', 'cancelation_date', 'cancelation_reason', 'cancelation_action', - 'retreat__start_time', - 'retreat__end_time', ) export_resource = ReservationResource() @@ -581,3 +681,24 @@ def get_queryset(self): if self.request.user.is_staff: return WaitQueuePlaceReserved.objects.all() return WaitQueuePlaceReserved.objects.filter(user=self.request.user) + + +class RetreatDateViewSet(viewsets.ModelViewSet): + serializer_class = RetreatDateSerializer + queryset = RetreatDate.objects.all() + permission_classes = [permissions.IsAdminOrReadOnly] + filter_fields = '__all__' + + +class RetreatTypeViewSet(viewsets.ModelViewSet): + serializer_class = RetreatTypeSerializer + queryset = RetreatType.objects.all() + permission_classes = [permissions.IsAdminOrReadOnly] + filter_fields = ['is_virtual'] + + +class AutomaticEmailViewSet(viewsets.ModelViewSet): + serializer_class = AutomaticEmailSerializer + queryset = AutomaticEmail.objects.all() + permission_classes = [permissions.IsAdminOrReadOnly] + filter_fields = '__all__' diff --git a/store/models.py b/store/models.py index 2f7bd0ad..9ec12bd0 100644 --- a/store/models.py +++ b/store/models.py @@ -1,29 +1,23 @@ -import decimal import json import random import string from datetime import datetime from decimal import Decimal - from django.db import models from django.utils.translation import ugettext_lazy as _ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import ( - GenericForeignKey, GenericRelation + GenericForeignKey, + GenericRelation, ) from django.core.mail import send_mail from django.contrib.contenttypes.models import ContentType from django.template.loader import render_to_string - from safedelete.models import SafeDeleteModel - from simple_history.models import HistoricalRecords - from blitz_api.models import AcademicLevel - from model_utils.managers import InheritanceManager - from log_management.models import Log, EmailLog User = get_user_model() @@ -180,40 +174,6 @@ def add_line_from_data(self, orderlines_data): order_line.cost += option.price order_line.save() - # A free virtual retreat is offered for all membership bought - # We check number of virtual retreat we have in stock - # We add the number of virtual retreat offered by others item in cart - number_of_memberships = 0 - LIMIT_DATE_FOR_FREE_VIRTUAL_RETREAT_ON_MEMBERSHIP = datetime.strptime( - settings.LIMIT_DATE_FOR_FREE_VIRTUAL_RETREAT_ON_MEMBERSHIP, - "%Y-%m-%d" - ) - if LIMIT_DATE_FOR_FREE_VIRTUAL_RETREAT_ON_MEMBERSHIP > datetime.now(): - number_of_memberships = self.order_lines.filter( - models.Q(content_type__model='membership') - ).count() - - number_of_free_virtual_retreat_applied = 0 - number_of_free_virtual_retreat_available = \ - self.user.number_of_free_virtual_retreat + number_of_memberships - - retreats = self.order_lines.filter( - models.Q(content_type__model='retreat') - ) - - for retreat in retreats: - if retreat.content_object.type == Retreat.TYPE_VIRTUAL: - if number_of_free_virtual_retreat_available > \ - number_of_free_virtual_retreat_applied: - retreat.cost = 0 - retreat.save() - number_of_free_virtual_retreat_applied += 1 - - self.user.number_of_free_virtual_retreat += number_of_memberships - self.user.number_of_free_virtual_retreat -= \ - number_of_free_virtual_retreat_applied - self.user.save() - class OrderLine(models.Model): """ diff --git a/store/tests/test_model_OptionProduct.py b/store/tests/tests_model_OptionProduct.py similarity index 62% rename from store/tests/test_model_OptionProduct.py rename to store/tests/tests_model_OptionProduct.py index 93fd6427..18b6cf59 100644 --- a/store/tests/test_model_OptionProduct.py +++ b/store/tests/tests_model_OptionProduct.py @@ -1,22 +1,52 @@ -from datetime import timedelta - +import pytz from django.utils import timezone +from django.conf import settings +from datetime import datetime from django.contrib.contenttypes.models import ContentType - from rest_framework.test import APITestCase - from blitz_api.factories import UserFactory, RetreatFactory -from blitz_api.models import AcademicLevel -from retirement.models import Retreat +from retirement.models import Retreat, RetreatDate, RetreatType +from store.models import Order, OptionProduct, Package -from ..models import Order, OptionProduct, Package +LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) class OptionProductTests(APITestCase): def setUp(self): self.user = UserFactory() - self.retreat = RetreatFactory() + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) + self.retreat = Retreat.objects.create( + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, + address_line1="123 random street", + postal_code="123 456", + state_province="Random state", + country="Random country", + price=199, + min_day_refund=7, + min_day_exchange=7, + refund_rate=50, + accessibility=True, + form_url="example.com", + carpool_url='example2.com', + review_url='example3.com', + has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, + ) + self.retreat.activate() self.retreat_content_types = ContentType.objects.get_for_model(Retreat) self.order = Order.objects.create( user=self.user, diff --git a/store/tests/test_stats_OrderLine.py b/store/tests/tests_stats_OrderLine.py similarity index 100% rename from store/tests/test_stats_OrderLine.py rename to store/tests/tests_stats_OrderLine.py diff --git a/store/tests/tests_viewset_Coupon.py b/store/tests/tests_viewset_Coupon.py index c1ed2133..39adc0b3 100644 --- a/store/tests/tests_viewset_Coupon.py +++ b/store/tests/tests_viewset_Coupon.py @@ -1,34 +1,70 @@ import json import pytz - -from datetime import datetime, timedelta - +from datetime import ( + datetime, + timedelta, +) from rest_framework import status -from rest_framework.test import APIClient, APITestCase - +from rest_framework.test import ( + APIClient, + APITestCase, +) from unittest import mock - from django.conf import settings from django.core import mail from django.utils import timezone from django.urls import reverse from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType - -from blitz_api.factories import UserFactory, AdminFactory -from blitz_api.models import AcademicLevel -from blitz_api.services import remove_translation_fields -from workplace.models import TimeSlot, Period, Workplace -from retirement.models import Retreat - -from ..models import Package, Order, OrderLine, Membership, Coupon +from blitz_api.factories import ( + UserFactory, + AdminFactory, +) +from blitz_api.testing_tools import CustomAPITestCase +from workplace.models import ( + TimeSlot, + Period, + Workplace, +) +from retirement.models import ( + Retreat, + RetreatType, + RetreatDate, +) +from store.models import ( + Package, + Membership, + Coupon, +) User = get_user_model() LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) -class CouponTests(APITestCase): +class CouponTests(CustomAPITestCase): + + ATTRIBUTES = [ + 'url', + 'id', + 'value', + 'percent_off', + 'code', + 'start_time', + 'end_time', + 'max_use', + 'max_use_per_user', + 'details', + 'owner', + 'applicable_product_types', + 'applicable_memberships', + 'applicable_packages', + 'applicable_retreats', + 'applicable_timeslots', + 'users', + 'is_applicable_to_physical_retreat', + 'is_applicable_to_virtual_retreat', + ] @classmethod def setUpClass(cls): @@ -75,27 +111,38 @@ def setUpClass(cls): start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), ) + cls.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) cls.retreat = Retreat.objects.create( name="mega_retreat", - seats=400, details="This is a description of the mega retreat.", + seats=400, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, - activity_language='FR', accessibility=True, + form_url="example.com", + carpool_url='example2.com', + review_url='example3.com', has_shared_rooms=True, toilet_gendered=False, room_type=Retreat.SINGLE_OCCUPATION, + type=cls.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=cls.retreat, ) + cls.retreat.activate() cls.coupon = Coupon.objects.create( value=13, code="ABCDEFGH", @@ -1055,160 +1102,12 @@ def test_list(self): format='json', ) - data = json.loads(response.content) - - content = { - 'count': 1, - 'next': None, - 'previous': None, - 'results': [{ - "url": "http://testserver/coupons/" + str(self.coupon.id), - "id": self.coupon.id, - "applicable_product_types": [ - "package" - ], - "value": "13.00", - "percent_off": None, - "code": data['results'][0]['code'], - "start_time": "2019-01-06T15:11:05-05:00", - "end_time": "2020-01-06T15:11:06-05:00", - "max_use": 100, - "max_use_per_user": 2, - "details": "Any package for clients", - "owner": "http://testserver/users/" + str(self.user.id), - "applicable_memberships": [{ - 'academic_levels': [], - 'available': True, - 'details': '1-Year student membership', - 'duration': '365 00:00:00', - 'id': self.membership.id, - 'name': 'basic_membership', - 'price': '50.00', - 'url': 'http://testserver/memberships/' + - str(self.membership.id), - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - }], - "applicable_packages": [{ - 'available': True, - 'details': '100 reservations package', - 'exclusive_memberships': [], - 'id': self.package.id, - 'name': 'extreme_package', - 'price': '400.00', - 'reservations': 100, - 'url': - 'http://testserver/packages/' + str(self.package.id), - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - }], - "applicable_retreats": [{ - 'accessibility': True, - 'activity_language': 'FR', - 'address_line1': '123 random street', - 'address_line2': None, - 'carpool_url': None, - 'city': None, - 'country': 'Random country', - 'details': 'This is a description of the mega retreat.', - 'email_content': None, - 'end_time': '2130-01-17T12:00:00-05:00', - 'exclusive_memberships': [], - 'form_url': None, - 'id': self.retreat.id, - 'is_active': True, - 'latitude': None, - 'longitude': None, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'name': 'mega_retreat', - 'notification_interval': '1 00:00:00', - 'pictures': [], - 'place_name': None, - 'places_remaining': 400, - 'postal_code': '123 456', - 'price': '199.00', - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'reserved_seats': 0, - 'review_url': None, - 'seats': 400, - 'type': 'P', - 'start_time': '2130-01-15T08:00:00-05:00', - 'state_province': 'Random state', - 'timezone': None, - 'total_reservations': 0, - 'url': - f'http://testserver/retreat/retreats/' - f'{self.retreat.id}', - 'users': [], - 'has_shared_rooms': True, - 'hidden': False, - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'videoconference_tool': None, - 'videoconference_link': None - }], - "applicable_timeslots": [{ - 'billing_price': 1.0, - 'end_time': '2130-01-15T12:00:00-05:00', - 'id': self.period.id, - 'period': 'http://testserver/periods/' + - str(self.period.id), - 'places_remaining': 40, - 'price': '1.00', - 'reservations': [], - 'reservations_canceled': [], - 'start_time': '2130-01-15T08:00:00-05:00', - 'url': 'http://testserver/time_slots/' + - str(self.time_slot.id), - 'users': [], - 'workplace': { - 'address_line1': '123 random street', - 'address_line2': None, - 'city': '', - 'country': 'Random country', - 'details': 'This is a description of the workplace.', - 'id': self.workplace.id, - 'latitude': None, - 'longitude': None, - 'name': 'random_workplace', - 'pictures': [], - 'place_name': '', - 'postal_code': '123 456', - 'seats': 40, - 'state_province': 'Random state', - 'timezone': None, - 'url': - f'http://testserver/workplaces/' - f'{self.workplace.id}', - 'volunteers': [] - } - }], - "users": [], - "is_applicable_to_physical_retreat": False, - "is_applicable_to_virtual_retreat": False - }] - } - - self.assertEqual(data, content) - self.assertEqual(response.status_code, status.HTTP_200_OK) + content = json.loads(response.content) + for item in content['results']: + self.check_attributes(item) + self.coupon.applicable_retreats.set([]) self.coupon.applicable_timeslots.set([]) self.coupon.applicable_packages.set([]) @@ -1307,149 +1206,12 @@ def test_read(self): ), ) - data = json.loads(response.content) - - content = { - "url": "http://testserver/coupons/" + str(self.coupon.id), - "id": self.coupon.id, - "value": "13.00", - "percent_off": None, - "code": data['code'], - "start_time": "2019-01-06T15:11:05-05:00", - "end_time": "2020-01-06T15:11:06-05:00", - "max_use": 100, - "max_use_per_user": 2, - "details": "Any package for clients", - "owner": "http://testserver/users/" + str(self.user.id), - "applicable_product_types": ['package'], - "applicable_memberships": [{ - 'academic_levels': [], - 'available': True, - 'details': '1-Year student membership', - 'duration': '365 00:00:00', - 'id': self.membership.id, - 'name': 'basic_membership', - 'price': '50.00', - 'url': 'http://testserver/memberships/' + - str(self.membership.id), - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - }], - "applicable_packages": [{ - 'available': True, - 'details': '100 reservations package', - 'exclusive_memberships': [], - 'id': self.package.id, - 'name': 'extreme_package', - 'price': '400.00', - 'reservations': 100, - 'url': 'http://testserver/packages/' + str(self.package.id), - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - }], - "applicable_retreats": [{ - 'accessibility': True, - 'activity_language': 'FR', - 'address_line1': '123 random street', - 'address_line2': None, - 'carpool_url': None, - 'city': None, - 'country': 'Random country', - 'details': 'This is a description of the mega retreat.', - 'email_content': None, - 'end_time': '2130-01-17T12:00:00-05:00', - 'exclusive_memberships': [], - 'form_url': None, - 'id': self.retreat.id, - 'is_active': True, - 'latitude': None, - 'longitude': None, - 'min_day_exchange': 7, - 'min_day_refund': 7, - 'name': 'mega_retreat', - 'notification_interval': '1 00:00:00', - 'pictures': [], - 'place_name': None, - 'places_remaining': 400, - 'postal_code': '123 456', - 'price': '199.00', - 'refund_rate': 50, - 'reservations': [], - 'reservations_canceled': [], - 'reserved_seats': 0, - 'review_url': None, - 'seats': 400, - 'start_time': '2130-01-15T08:00:00-05:00', - 'state_province': 'Random state', - 'timezone': None, - 'total_reservations': 0, - 'url': - f'http://testserver/retreat/retreats/' - f'{self.retreat.id}', - 'users': [], - 'has_shared_rooms': True, - 'hidden': False, - 'available_on_product_types': [], - 'available_on_products': [], - 'options': [], - 'accessibility_detail': None, - 'description': None, - 'food_allergen_free': False, - 'food_gluten_free': False, - 'food_vegan': False, - 'food_vege': False, - 'google_maps_url': None, - 'sub_title': None, - 'toilet_gendered': False, - 'room_type': Retreat.SINGLE_OCCUPATION, - 'type': 'P', - 'videoconference_tool': None, - 'videoconference_link': None - }], - "applicable_timeslots": [{ - 'billing_price': 1.0, - 'end_time': '2130-01-15T12:00:00-05:00', - 'id': self.period.id, - 'period': 'http://testserver/periods/' + str(self.period.id), - 'places_remaining': 40, - 'price': '1.00', - 'reservations': [], - 'reservations_canceled': [], - 'start_time': '2130-01-15T08:00:00-05:00', - 'url': 'http://testserver/time_slots/' + - str(self.time_slot.id), - 'users': [], - 'workplace': { - 'address_line1': '123 random street', - 'address_line2': None, - 'city': '', - 'country': 'Random country', - 'details': 'This is a description of the workplace.', - 'id': self.workplace.id, - 'latitude': None, - 'longitude': None, - 'name': 'random_workplace', - 'pictures': [], - 'place_name': '', - 'postal_code': '123 456', - 'seats': 40, - 'state_province': 'Random state', - 'timezone': None, - 'volunteers': [], - 'url': f'http://testserver/workplaces/{self.workplace.id}' - } - }], - "users": [], - "is_applicable_to_physical_retreat": False, - "is_applicable_to_virtual_retreat": False - } - - self.assertEqual(json.loads(response.content), content) + content = json.loads(response.content) self.assertEqual(response.status_code, status.HTTP_200_OK) + self.check_attributes(content) + self.coupon.applicable_retreats.set([]) self.coupon.applicable_timeslots.set([]) self.coupon.applicable_packages.set([]) diff --git a/store/tests/test_viewset_OptionProduct.py b/store/tests/tests_viewset_OptionProduct.py similarity index 78% rename from store/tests/test_viewset_OptionProduct.py rename to store/tests/tests_viewset_OptionProduct.py index 5ea4a417..26f357b8 100644 --- a/store/tests/test_viewset_OptionProduct.py +++ b/store/tests/tests_viewset_OptionProduct.py @@ -1,16 +1,33 @@ import json +import pytz +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.utils import timezone +from datetime import datetime from rest_framework import status from rest_framework.reverse import reverse -from rest_framework.test import APITestCase, APIRequestFactory +from rest_framework.test import ( + APITestCase, + APIRequestFactory, +) + +from blitz_api.factories import ( + UserFactory, + AdminFactory, +) +from retirement.models import ( + Retreat, + RetreatType, + RetreatDate, +) +from store.models import ( + Order, + OptionProduct, +) -from blitz_api.factories import UserFactory, RetreatFactory, AdminFactory -from blitz_api.services import remove_translation_fields -from retirement.models import Retreat -from store.models import Order, OptionProduct +LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) @override_settings( @@ -27,9 +44,39 @@ class OrderTests(APITestCase): def setUp(self): self.user = UserFactory() self.admin = AdminFactory() - self.retreat = RetreatFactory() - self.retreat.is_active = True - self.retreat.save() + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + ) + self.retreat = Retreat.objects.create( + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=400, + address_line1="123 random street", + postal_code="123 456", + state_province="Random state", + country="Random country", + price=199, + min_day_refund=7, + min_day_exchange=7, + refund_rate=50, + accessibility=True, + form_url="example.com", + carpool_url='example2.com', + review_url='example3.com', + has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, + ) + self.retreat.activate() + self.retreat_content_types = ContentType.objects.get_for_model(Retreat) self.order = Order.objects.create( user=self.user, diff --git a/store/tests/tests_viewset_Order.py b/store/tests/tests_viewset_Order.py index 58b94ddf..1dfac94d 100644 --- a/store/tests/tests_viewset_Order.py +++ b/store/tests/tests_viewset_Order.py @@ -1,15 +1,22 @@ import json -from datetime import datetime, timedelta, date +from datetime import ( + datetime, + timedelta, + date, +) from rest_framework import status -from rest_framework.test import APIClient, APITestCase +from rest_framework.test import ( + APIClient, + APITestCase, +) from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core import mail -from django.test import modify_settings, override_settings +from django.test import override_settings from django.utils import timezone from django.urls import reverse @@ -17,12 +24,24 @@ import responses from unittest import mock -from blitz_api.factories import UserFactory, AdminFactory +from blitz_api.factories import ( + UserFactory, + AdminFactory, +) -from workplace.models import TimeSlot, Period, Workplace -from retirement.models import Retreat, RetreatInvitation +from workplace.models import ( + TimeSlot, + Period, + Workplace, +) +from retirement.models import ( + Retreat, + RetreatInvitation, + RetreatType, + RetreatDate, Reservation, +) -from .paysafe_sample_responses import ( +from store.tests.paysafe_sample_responses import ( SAMPLE_PROFILE_RESPONSE, SAMPLE_PAYMENT_RESPONSE, SAMPLE_CARD_RESPONSE, @@ -32,9 +51,16 @@ SAMPLE_CARD_REFUSED, ) -from ..models import ( - Package, Order, OrderLine, Membership, PaymentProfile, - Coupon, CouponUser, MembershipCoupon, OptionProduct +from store.models import ( + Package, + Order, + OrderLine, + Membership, + PaymentProfile, + Coupon, + CouponUser, + MembershipCoupon, + OptionProduct, ) User = get_user_model() @@ -63,6 +89,7 @@ class OrderTests(APITestCase): def setUp(self): + self.retreat_content_type = ContentType.objects.get_for_model(Retreat) self.client = APIClient() self.user: User = UserFactory() self.user.city = "Current city" @@ -75,6 +102,7 @@ def setUp(self): self.admin.student_number = "Random code" self.admin.academic_program_code = "Random code" self.admin.save() + self.user_for_no_place_retreat: User = UserFactory() self.membership = Membership.objects.create( name="basic_membership", details="1-Year student membership", @@ -172,75 +200,72 @@ def setUp(self): start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), ) + self.retreatType = RetreatType.objects.create( + name="Type 1", + minutes_before_display_link=10, + number_of_tomatoes=4, + template_id_for_welcome_message=1, + ) self.retreat = Retreat.objects.create( name="mega_retreat", - seats=400, details="This is a description of the mega retreat.", + seats=400, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, - activity_language='FR', accessibility=True, + form_url="example.com", + carpool_url='example2.com', + review_url='example3.com', has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, + ) + RetreatDate.objects.create( + start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat, ) + self.retreat.activate() self.retreat.add_wait_queue_place(self.user, generate_cron=False) self.retreat_no_seats = Retreat.objects.create( - name="no_place_left_retreat", - seats=0, - details="This is a description of the full retreat.", + name="mega_retreat", + details="This is a description of the mega retreat.", + seats=1, address_line1="123 random street", postal_code="123 456", state_province="Random state", country="Random country", price=199, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), min_day_refund=7, min_day_exchange=7, refund_rate=50, - is_active=True, - activity_language='FR', accessibility=True, + form_url="example.com", + carpool_url='example2.com', + review_url='example3.com', has_shared_rooms=True, + toilet_gendered=False, + room_type=Retreat.SINGLE_OCCUPATION, + type=self.retreatType, ) - self.virtualRetreat = Retreat.objects.create( - name="virtual retreat", - seats=400, - details="This is a description of the virtual retreat.", - price=200, + RetreatDate.objects.create( start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), - min_day_refund=7, - min_day_exchange=7, - refund_rate=50, - is_active=True, - activity_language='FR', - videoconference_tool='Jitsi', - type=Retreat.TYPE_VIRTUAL, + end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 17, 12)), + retreat=self.retreat_no_seats, ) - self.virtualRetreat2 = Retreat.objects.create( - name="virtual retreat 2", - seats=400, - details="This is a description of the virtual retreat 2.", - price=100, - start_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 8)), - end_time=LOCAL_TIMEZONE.localize(datetime(2130, 1, 15, 12)), - min_day_refund=7, - min_day_exchange=7, - refund_rate=50, + Reservation.objects.create( + user=self.user_for_no_place_retreat, + retreat=self.retreat_no_seats, is_active=True, - activity_language='FR', - videoconference_tool='Jitsi', - type=Retreat.TYPE_VIRTUAL, ) + self.retreat_no_seats.activate() self.coupon = Coupon.objects.create( code="ABCD1234", start_time=LOCAL_TIMEZONE.localize(datetime(2000, 1, 15, 8)), @@ -1912,459 +1937,6 @@ def test_create_retreat(self): # 1 email for the retreat informations self.assertEqual(len(mail.outbox), 2) - @responses.activate - @override_settings( - LIMIT_DATE_FOR_FREE_VIRTUAL_RETREAT_ON_MEMBERSHIP='5000-01-01' - ) - def test_create_virtual_retreat_with_membership(self): - """ - Ensure we can create an order with a virtual retreat and a - membership and that the virtual retreat is free - """ - self.client.force_authenticate(user=self.user) - - self.user.city = "Current city" - self.user.phone = "123-456-7890" - self.user.save() - - number_of_free_virtual_retreat_in_bank = \ - self.user.number_of_free_virtual_retreat - - responses.add( - responses.POST, - "http://example.com/cardpayments/v1/accounts/0123456789/auths/", - json=SAMPLE_PAYMENT_RESPONSE, - status=200 - ) - - data = { - 'payment_token': "CZgD1NlBzPuSefg", - 'order_lines': [ - { - 'content_type': 'retreat', - 'object_id': self.virtualRetreat.id, - 'quantity': 1, - }, - { - 'content_type': 'membership', - 'object_id': self.membership.id, - 'quantity': 1, - } - ], - } - - response = self.client.post( - reverse('order-list'), - data, - format='json', - ) - - self.assertEqual( - response.status_code, - status.HTTP_201_CREATED, - response.content - ) - - response_data = json.loads(response.content) - del response_data['url'] - del response_data['id'] - del response_data['order_lines'][0]['order'] - del response_data['order_lines'][0]['object_id'] - del response_data['order_lines'][0]['url'] - del response_data['order_lines'][0]['id'] - del response_data['order_lines'][1]['order'] - del response_data['order_lines'][1]['object_id'] - del response_data['order_lines'][1]['url'] - del response_data['order_lines'][1]['id'] - content = { - 'order_lines': [ - { - 'content_type': 'retreat', - 'quantity': 1, - 'coupon': None, - 'coupon_real_value': 0.0, - 'cost': 0.0, - 'metadata': None, - 'options': [] - }, - { - 'content_type': 'membership', - 'quantity': 1, - 'coupon': None, - 'coupon_real_value': 0.0, - 'cost': 50.0, - 'metadata': None, - 'options': [] - } - ], - 'user': 'http://testserver/users/' + str(self.user.id), - 'transaction_date': response_data['transaction_date'], - 'authorization_id': '1', - 'settlement_id': '1', - 'reference_number': '751', - } - - refreshed_user = User.objects.get(id=self.user.id) - - self.assertEqual( - number_of_free_virtual_retreat_in_bank, - refreshed_user.number_of_free_virtual_retreat - ) - - self.assertEqual(response_data, content) - - # 1 email for the order details - # 1 email for the retreat informations - self.assertEqual(len(mail.outbox), 2) - - @responses.activate - @override_settings( - LIMIT_DATE_FOR_FREE_VIRTUAL_RETREAT_ON_MEMBERSHIP='5000-01-01' - ) - def test_create_membership_before_end_of_limit_free_virtual_retreat(self): - """ - Ensure we can create an order with a membership and get - a free virtual retreat in bank - """ - self.client.force_authenticate(user=self.user) - - self.user.city = "Current city" - self.user.phone = "123-456-7890" - self.user.save() - - number_of_free_virtual_retreat_in_bank =\ - self.user.number_of_free_virtual_retreat - - responses.add( - responses.POST, - "http://example.com/cardpayments/v1/accounts/0123456789/auths/", - json=SAMPLE_PAYMENT_RESPONSE, - status=200 - ) - - data = { - 'payment_token': "CZgD1NlBzPuSefg", - 'order_lines': [ - { - 'content_type': 'membership', - 'object_id': self.membership.id, - 'quantity': 1, - } - ], - } - - response = self.client.post( - reverse('order-list'), - data, - format='json', - ) - - self.assertEqual( - response.status_code, - status.HTTP_201_CREATED, - response.content - ) - - response_data = json.loads(response.content) - del response_data['url'] - del response_data['id'] - del response_data['order_lines'][0]['order'] - del response_data['order_lines'][0]['object_id'] - del response_data['order_lines'][0]['url'] - del response_data['order_lines'][0]['id'] - content = { - 'order_lines': [ - { - 'content_type': 'membership', - 'quantity': 1, - 'coupon': None, - 'coupon_real_value': 0.0, - 'cost': 50.0, - 'metadata': None, - 'options': [] - } - ], - 'user': 'http://testserver/users/' + str(self.user.id), - 'transaction_date': response_data['transaction_date'], - 'authorization_id': '1', - 'settlement_id': '1', - 'reference_number': '751', - } - - refreshed_user = User.objects.get(id=self.user.id) - - self.assertEqual( - number_of_free_virtual_retreat_in_bank + 1, - refreshed_user.number_of_free_virtual_retreat - ) - - self.assertEqual(response_data, content) - - # 1 email for the order details - self.assertEqual(len(mail.outbox), 1) - - @responses.activate - def test_create_virtual_retreat_with_membership_after_limit(self): - """ - Ensure we can create an order with a virtual retreat and a - membership and that the virtual retreat is not free since the limit - is passed - """ - self.client.force_authenticate(user=self.user) - - self.user.city = "Current city" - self.user.phone = "123-456-7890" - self.user.save() - - responses.add( - responses.POST, - "http://example.com/cardpayments/v1/accounts/0123456789/auths/", - json=SAMPLE_PAYMENT_RESPONSE, - status=200 - ) - - data = { - 'payment_token': "CZgD1NlBzPuSefg", - 'order_lines': [ - { - 'content_type': 'retreat', - 'object_id': self.virtualRetreat.id, - 'quantity': 1, - }, - { - 'content_type': 'membership', - 'object_id': self.membership.id, - 'quantity': 1, - } - ], - } - - response = self.client.post( - reverse('order-list'), - data, - format='json', - ) - - self.assertEqual( - response.status_code, - status.HTTP_201_CREATED, - response.content - ) - - response_data = json.loads(response.content) - del response_data['url'] - del response_data['id'] - del response_data['order_lines'][0]['order'] - del response_data['order_lines'][0]['object_id'] - del response_data['order_lines'][0]['url'] - del response_data['order_lines'][0]['id'] - del response_data['order_lines'][1]['order'] - del response_data['order_lines'][1]['object_id'] - del response_data['order_lines'][1]['url'] - del response_data['order_lines'][1]['id'] - content = { - 'order_lines': [ - { - 'content_type': 'retreat', - 'quantity': 1, - 'coupon': None, - 'coupon_real_value': 0.0, - 'cost': 200.0, - 'metadata': None, - 'options': [] - }, - { - 'content_type': 'membership', - 'quantity': 1, - 'coupon': None, - 'coupon_real_value': 0.0, - 'cost': 50.0, - 'metadata': None, - 'options': [] - } - ], - 'user': 'http://testserver/users/' + str(self.user.id), - 'transaction_date': response_data['transaction_date'], - 'authorization_id': '1', - 'settlement_id': '1', - 'reference_number': '751', - } - - self.assertEqual(response_data, content) - - # 1 email for the order details - # 1 email for the retreat informations - self.assertEqual(len(mail.outbox), 2) - - @responses.activate - def test_create_virtual_retreat_with_free_retreat_in_profile(self): - """ - Ensure we can create an order with a virtual retreat and a - free virtual retreat in banks and that the virtual retreat is free - """ - self.client.force_authenticate(user=self.user) - - self.user.city = "Current city" - self.user.phone = "123-456-7890" - self.user.number_of_free_virtual_retreat = 1 - self.user.save() - - responses.add( - responses.POST, - "http://example.com/cardpayments/v1/accounts/0123456789/auths/", - json=SAMPLE_PAYMENT_RESPONSE, - status=200 - ) - - data = { - 'payment_token': "CZgD1NlBzPuSefg", - 'order_lines': [ - { - 'content_type': 'retreat', - 'object_id': self.virtualRetreat.id, - 'quantity': 1, - } - ], - } - - response = self.client.post( - reverse('order-list'), - data, - format='json', - ) - - self.assertEqual( - response.status_code, - status.HTTP_201_CREATED, - response.content - ) - - response_data = json.loads(response.content) - del response_data['url'] - del response_data['id'] - del response_data['reference_number'] - del response_data['order_lines'][0]['order'] - del response_data['order_lines'][0]['url'] - del response_data['order_lines'][0]['id'] - content = { - 'order_lines': [ - { - 'object_id': self.virtualRetreat.id, - 'content_type': 'retreat', - 'quantity': 1, - 'coupon': None, - 'coupon_real_value': 0.0, - 'cost': 0.0, - 'metadata': None, - 'options': [] - } - ], - 'user': 'http://testserver/users/' + str(self.user.id), - 'transaction_date': response_data['transaction_date'], - 'authorization_id': 0, - 'settlement_id': 0, - } - - self.assertEqual(response_data, content) - - # 1 email for the order details - # 1 email for the retreat informations - self.assertEqual(len(mail.outbox), 2) - - @responses.activate - def test_create_two_virtual_retreat_with_free_retreat_in_profile(self): - """ - Ensure we can create an order with two virtual retreat and that the - first virtual retreat is free - """ - self.client.force_authenticate(user=self.user) - - self.user.city = "Current city" - self.user.phone = "123-456-7890" - self.user.number_of_free_virtual_retreat = 1 - self.user.save() - - responses.add( - responses.POST, - "http://example.com/cardpayments/v1/accounts/0123456789/auths/", - json=SAMPLE_PAYMENT_RESPONSE, - status=200 - ) - - data = { - 'payment_token': "CZgD1NlBzPuSefg", - 'order_lines': [ - { - 'content_type': 'retreat', - 'object_id': self.virtualRetreat.id, - 'quantity': 1, - }, - { - 'content_type': 'retreat', - 'object_id': self.virtualRetreat2.id, - 'quantity': 1, - } - ], - } - - response = self.client.post( - reverse('order-list'), - data, - format='json', - ) - - self.assertEqual( - response.status_code, - status.HTTP_201_CREATED, - response.content - ) - - response_data = json.loads(response.content) - del response_data['url'] - del response_data['id'] - del response_data['reference_number'] - del response_data['order_lines'][0]['order'] - del response_data['order_lines'][0]['url'] - del response_data['order_lines'][0]['id'] - del response_data['order_lines'][1]['order'] - del response_data['order_lines'][1]['url'] - del response_data['order_lines'][1]['id'] - content = { - 'order_lines': [ - { - 'object_id': self.virtualRetreat.id, - 'content_type': 'retreat', - 'quantity': 1, - 'coupon': None, - 'coupon_real_value': 0.0, - 'cost': 0.0, - 'metadata': None, - 'options': [] - }, - { - 'object_id': self.virtualRetreat2.id, - 'content_type': 'retreat', - 'quantity': 1, - 'coupon': None, - 'coupon_real_value': 0.0, - 'cost': 100.0, - 'metadata': None, - 'options': [] - } - ], - 'user': 'http://testserver/users/' + str(self.user.id), - 'transaction_date': response_data['transaction_date'], - 'authorization_id': '1', - 'settlement_id': '1', - } - - self.assertEqual(response_data, content) - - # 1 email for the order details - # 1 email for the free retreat informations - # 1 email for the payed retreat informations - self.assertEqual(len(mail.outbox), 3) - @responses.activate def test_create_retreat_twice(self): """