From 3048742be4067cd652152d8506088fd1b9d59856 Mon Sep 17 00:00:00 2001 From: RomainFayolle Date: Tue, 27 Jun 2023 10:17:50 -0400 Subject: [PATCH 1/5] create magiclink logic and use it in export media --- blitz_api/admin.py | 12 +- blitz_api/factories.py | 15 +- .../migrations/0030_auto_20230627_0926.py | 31 ++ blitz_api/models.py | 67 +++- blitz_api/serializers.py | 21 +- blitz_api/settings.py | 5 + blitz_api/tests/test_views_MagicLink.py | 351 ++++++++++++++++++ blitz_api/urls.py | 1 + blitz_api/views.py | 31 +- 9 files changed, 527 insertions(+), 7 deletions(-) create mode 100644 blitz_api/migrations/0030_auto_20230627_0926.py create mode 100644 blitz_api/tests/test_views_MagicLink.py diff --git a/blitz_api/admin.py b/blitz_api/admin.py index b7b866d3..79c6b58c 100644 --- a/blitz_api/admin.py +++ b/blitz_api/admin.py @@ -10,7 +10,7 @@ from simple_history.admin import SimpleHistoryAdmin from .models import (AcademicField, AcademicLevel, ActionToken, Domain, - Organization, TemporaryToken, User) + Organization, TemporaryToken, User, ExportMedia) from .resources import (AcademicFieldResource, AcademicLevelResource, OrganizationResource, UserResource) @@ -132,6 +132,15 @@ class AcademicLevelAdmin(SimpleHistoryAdmin, TranslationAdmin, resource_class = AcademicLevelResource +class ExportMediaAdmin(admin.ModelAdmin): + list_display = ('name', 'author', 'type',) + search_fields = ('type', 'author__email',) + list_filter = ( + 'type', + 'author', + ) + + admin.site.register(User, CustomUserAdmin) admin.site.register(Organization, CustomOrganizationAdmin) admin.site.register(Domain, SimpleHistoryAdmin) @@ -139,3 +148,4 @@ class AcademicLevelAdmin(SimpleHistoryAdmin, TranslationAdmin, admin.site.register(TemporaryToken, TemporaryTokenAdmin) admin.site.register(AcademicField, AcademicFieldAdmin) admin.site.register(AcademicLevel, AcademicLevelAdmin) +admin.site.register(ExportMedia, ExportMediaAdmin) diff --git a/blitz_api/factories.py b/blitz_api/factories.py index 1addad5f..5cdaa63e 100644 --- a/blitz_api/factories.py +++ b/blitz_api/factories.py @@ -7,7 +7,12 @@ import factory.fuzzy from django.contrib.auth import get_user_model -from blitz_api.models import Organization, AcademicLevel, AcademicField +from blitz_api.models import ( + Organization, + AcademicLevel, + AcademicField, + MagicLink, +) from retirement.models import ( Retreat, RetreatDate, @@ -83,6 +88,14 @@ class Meta: name = factory.Sequence(lambda n: f'AcademicField {n}') +class MagicLinkFactory(DjangoModelFactory): + class Meta: + model = MagicLink + + full_link = 'http://myverylonglink.thatiwantshorten.toamagiclink' + description = 'This is a factory magic link' + + class RetreatTypeFactory(DjangoModelFactory): class Meta: model = RetreatType diff --git a/blitz_api/migrations/0030_auto_20230627_0926.py b/blitz_api/migrations/0030_auto_20230627_0926.py new file mode 100644 index 00000000..d4d63638 --- /dev/null +++ b/blitz_api/migrations/0030_auto_20230627_0926.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.8 on 2023-06-27 13:26 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('blitz_api', '0029_auto_20230203_1624'), + ] + + operations = [ + migrations.CreateModel( + name='MagicLink', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('full_link', models.TextField(verbose_name='Full link')), + ('type', models.CharField(choices=[('DOWNLOAD', 'Download')], default='DOWNLOAD', max_length=255)), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('nb_uses', models.PositiveIntegerField(default=0, verbose_name='Number of uses')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ], + ), + migrations.AlterField( + model_name='exportmedia', + name='type', + field=models.CharField(choices=[('ANONYMOUS CHRONO DATA', 'Anonymous Chrono data'), ('OTHER', 'Other'), ('RETREAT SALES', 'Retreat sales'), ('RETREAT PARTICIPATION', 'Retreat participation'), ('RETREAT OPTIONS', 'Retreat options'), ('COUPON USAGE', 'Coupon usage')], default='OTHER', max_length=255), + ), + ] diff --git a/blitz_api/models.py b/blitz_api/models.py index fccba280..2038fee5 100644 --- a/blitz_api/models.py +++ b/blitz_api/models.py @@ -3,6 +3,7 @@ import os import logging import calendar +import uuid from django.conf import settings from django.db import models @@ -689,6 +690,63 @@ class Meta: abstract = True +class MagicLink(models.Model): + """ + This model stores a full link (for download etc.) and uuid. + The uuid is used publicly (in email etc.) and the model does the + bridge between uuid and the full link + """ + MAGIC_LINK_TYPE_DOWNLOAD = 'DOWNLOAD' + + MAGIC_LINK_TYPE_CHOICES = ( + (MAGIC_LINK_TYPE_DOWNLOAD, _('Download')), + ) + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + full_link = models.TextField( + verbose_name=_("Full link") + ) + type = models.CharField( + max_length=255, + choices=MAGIC_LINK_TYPE_CHOICES, + default=MAGIC_LINK_TYPE_DOWNLOAD, + ) + description = models.TextField( + blank=True, + null=True, + verbose_name=_("Description"), + ) + nb_uses = models.PositiveIntegerField( + verbose_name=_('Number of uses'), + default=0, + ) + created_at = models.DateTimeField( + verbose_name=_('Created at'), + auto_now_add=True, + ) + updated_at = models.DateTimeField( + verbose_name=_('Updated at'), + auto_now=True + ) + + @property + def is_used(self): + return self.nb_uses > 0 + + def use(self): + self.nb_uses += 1 + self.save() + + def get_url(self): + FRONTEND_SETTINGS = settings.LOCAL_SETTINGS['FRONTEND_INTEGRATION'] + BASE_URL = FRONTEND_SETTINGS['SSO_URL'] + + return BASE_URL + FRONTEND_SETTINGS['MAGIC_LINK_URL'].replace( + "{{token}}", + str(self.id) + ) + + class ExportMedia(models.Model): EXPORT_ANONYMOUS_CHRONO_DATA = 'ANONYMOUS CHRONO DATA' EXPORT_OTHER = 'OTHER' @@ -740,12 +798,19 @@ def size(self): def send_confirmation_email(self): if self.author: + magic_link = MagicLink.objects.create( + full_link=self.file.url, + type=MagicLink.MAGIC_LINK_TYPE_DOWNLOAD, + description=f'Export {self.type} ' + f'for {self.author}' + ) + services.send_mail( [self.author], { "USER_FIRST_NAME": self.author.first_name, "USER_LAST_NAME": self.author.last_name, - "export_link": self.file.url, + "export_link": magic_link.get_url(), }, "EXPORT_DONE", ) diff --git a/blitz_api/serializers.py b/blitz_api/serializers.py index 60716533..4a017602 100644 --- a/blitz_api/serializers.py +++ b/blitz_api/serializers.py @@ -12,8 +12,13 @@ from django.core.exceptions import ValidationError from .models import ( - Domain, Organization, ActionToken, AcademicField, AcademicLevel, - ExportMedia + Domain, + Organization, + ActionToken, + AcademicField, + AcademicLevel, + ExportMedia, + MagicLink, ) from .services import remove_translation_fields, check_if_translated_field from . import services, mailchimp @@ -726,3 +731,15 @@ def create(self, validated_data): 'non_field_errors': [e.args[0]['detail']] }) return validated_data + + +class MagicLinkSerializer(serializers.ModelSerializer): + id = serializers.ReadOnlyField() + + class Meta: + model = MagicLink + fields = [ + 'id', + 'full_link', + 'description', + ] diff --git a/blitz_api/settings.py b/blitz_api/settings.py index 8341004a..a2cce32a 100644 --- a/blitz_api/settings.py +++ b/blitz_api/settings.py @@ -421,6 +421,10 @@ cast=bool, ), 'FRONTEND_INTEGRATION': { + 'SSO_URL': config( + 'SSO_URL', + default='https://login-dev-gcp.thesez-vous.org/', + ), 'LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT': config( 'LINK_TO_BE_PREPARED_FOR_VIRTUAL_RETREAT', default='https://www.thesez-vous.com/sypreparer_retraitevirtuelle.html', @@ -453,6 +457,7 @@ 'RETREAT_UNSUBSCRIBE_URL', default='https://example.com/wait_queue/{{wait_queue_id}}/unsubscribe', ), + "MAGIC_LINK_URL": "magic-link/{{token}}", }, 'SELLING_TAX': 0.14975, 'RETREAT_NOTIFICATION_LIFETIME_DAYS': config( diff --git a/blitz_api/tests/test_views_MagicLink.py b/blitz_api/tests/test_views_MagicLink.py new file mode 100644 index 00000000..568a77a6 --- /dev/null +++ b/blitz_api/tests/test_views_MagicLink.py @@ -0,0 +1,351 @@ +import json + +from rest_framework import status +from rest_framework.reverse import reverse +from rest_framework.test import ( + APIRequestFactory, + APITestCase +) +from blitz_api.factories import ( + UserFactory, + AdminFactory, + MagicLinkFactory +) +from blitz_api.models import MagicLink + + +class TestMagicLink(APITestCase): + def setUp(self): + self.user = UserFactory() + self.user.set_password('Test1234!') + self.user.save() + + self.admin = AdminFactory() + self.admin.set_password('Test123!') + self.admin.save() + + self.magiclink = MagicLinkFactory() + + factory = APIRequestFactory() + self.request = factory.get('/') + + def test_create_magiclink_as_user(self): + """ + Test that a user can't create a MagicLink + """ + self.client.force_authenticate(user=self.user) + + data = { + 'full_link': 'My user full link', + 'description': 'My magiclink description', + } + + response = self.client.post( + reverse('magiclink-list'), + data, + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + response.content + ) + content = json.loads(response.content) + self.assertEqual( + content, + { + "detail": + "You do not have permission to perform this action." + } + ) + + +def test_retrieve_magiclink_as_unauthenticated(self): + """ + Test that an unauthenticated user can retrieve a MagicLink + """ + response = self.client.get( + reverse( + 'magiclink-detail', + kwargs={'pk': self.magiclink.id}, + ), + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + content = json.loads(response.content) + self.assertEqual( + content['full_link'], + self.magiclink.full_link + ) + magic_link = MagicLink.objects.get(id=self.magiclink.id) + self.assertEqual(magic_link.nb_uses, 1) + + +def test_retrieve_magiclink_as_user(self): + """ + Test that a user can retrieve a MagicLink + """ + self.client.force_authenticate(user=self.user) + + response = self.client.get( + reverse( + 'magiclink-detail', + kwargs={'pk': self.magiclink.id}, + ), + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + content = json.loads(response.content) + self.assertEqual( + content['full_link'], + self.magiclink.full_link + ) + magic_link = MagicLink.objects.get(id=self.magiclink.id) + self.assertEqual(magic_link.nb_uses, 1) + + +def test_retrieve_magiclink_as_user_uses(self): + """ + Test that retrieving a magic link increments its uses + """ + self.client.force_authenticate(user=self.user) + + response = self.client.get( + reverse( + 'magiclink-detail', + kwargs={'pk': self.magiclink.id}, + ), + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + response = self.client.get( + reverse( + 'magiclink-detail', + kwargs={'pk': self.magiclink.id}, + ), + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + ) + magic_link = MagicLink.objects.get(id=self.magiclink.id) + self.assertEqual(magic_link.nb_uses, 2) + + +def test_update_magiclink_as_user(self): + """ + Test that a user can't update the magiclink + """ + data = { + 'full_link': 'My user full link', + 'description': 'Long Text Updated', + } + + self.client.force_authenticate(user=self.user) + + response = self.client.patch( + reverse( + 'magiclink-detail', + kwargs={'pk': self.magiclink.id}, + request=self.request + ), + data, + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + response.content + ) + + content = json.loads(response.content) + self.assertEqual( + content, + { + "detail": + "You do not have permission to perform this action." + } + ) + + +def test_destroy_magiclink_as_user(self): + """ + Test that a user can't destroy the magiclink + """ + + self.client.force_authenticate(user=self.user) + + response = self.client.delete( + reverse( + 'magiclink-detail', + kwargs={'pk': self.magiclink.id}, + request=self.request + ), + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_403_FORBIDDEN, + response.content + ) + + content = json.loads(response.content) + self.assertEqual( + content, + { + "detail": + "You do not have permission to perform this action." + } + ) + + +def test_create_magiclink_as_admin(self): + """ + Test that a staff member can create a MagicLink + """ + self.client.force_authenticate(user=self.admin) + + data = { + 'full_link': 'My user full link', + 'description': 'Long Text', + } + + response = self.client.post( + reverse('magiclink-list'), + data, + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_201_CREATED, + response.content + ) + + +def test_list_all_magiclinks_as_admin(self): + """ + Test that a staff member can list the magiclinks + """ + self.magiclink_2 = MagicLinkFactory() + self.magiclink_3 = MagicLinkFactory() + + self.client.force_authenticate(user=self.admin) + response = self.client.get( + reverse('magiclink-list'), + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + content = json.loads(response.content) + self.assertEqual( + (content['count']), + 3 + ) + + +def test_search_magiclink_type(self): + """ + Ensure an admin can filter magiclinks by type + """ + self.magiclink_2 = MagicLinkFactory() + + self.client.force_authenticate(user=self.admin) + response = self.client.get( + reverse('magiclink-list') + '?search=DOWNLOAD', + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + content = json.loads(response.content) + self.assertEqual( + (content['count']), + 2 + ) + + +def test_update_magiclink_as_admin(self): + """ + Test that a staff member can update a magiclink + """ + self.client.force_authenticate(user=self.admin) + + data = { + 'description': 'New Long Text updated', + } + + response = self.client.patch( + reverse( + 'magiclink-detail', + kwargs={'pk': self.magiclink.id}, + request=self.request + ), + data, + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_200_OK, + response.content + ) + content = json.loads(response.content) + + self.assertEqual( + content['description'], + 'New Long Text updated' + ) + + self.assertEqual( + MagicLink.objects.get(id=self.magiclink.id).description, + 'New Long Text updated' + ) + + +def test_destroy_magiclink_as_admin(self): + """ + Test that a staff member can destroy a magiclink + """ + self.client.force_authenticate(user=self.admin) + + response = self.client.delete( + reverse( + 'magiclink-detail', + kwargs={'pk': self.magiclink.id}, + request=self.request + ), + format='json', + ) + + self.assertEqual( + response.status_code, + status.HTTP_204_NO_CONTENT, + response.content + ) diff --git a/blitz_api/urls.py b/blitz_api/urls.py index eaa747ab..0556a1d2 100644 --- a/blitz_api/urls.py +++ b/blitz_api/urls.py @@ -68,6 +68,7 @@ def __init__(self, *args, **kwargs): ) router.register('export_media', views.ExportMediaViewSet) +router.register('magic_links', views.MagicLinkViewSet, basename='magiclink') urlpatterns = [ websocket("ws/last_messages", tomato_views.last_messages), diff --git a/blitz_api/views.py b/blitz_api/views.py index b4acb0fb..d194948e 100644 --- a/blitz_api/views.py +++ b/blitz_api/views.py @@ -18,9 +18,15 @@ from blitz_api.mixins import ExportMixin from .models import ( - TemporaryToken, ActionToken, Domain, Organization, AcademicLevel, + TemporaryToken, + ActionToken, + Domain, + Organization, + AcademicLevel, AcademicField, - ExportMedia) + ExportMedia, + MagicLink, +) from .resources import (AcademicFieldResource, AcademicLevelResource, OrganizationResource, UserResource) from . import serializers, permissions, services @@ -635,3 +641,24 @@ class ExportMediaViewSet(viewsets.ModelViewSet): class MailChimpView(generics.CreateAPIView): serializer_class = serializers.MailChimpSerializer permission_classes = [] + + +class MagicLinkViewSet(viewsets.ModelViewSet): + serializer_class = serializers.MagicLinkSerializer + queryset = MagicLink.objects.all() + filter_fields = ('type',) + permission_classes = [] + + def get_permissions(self): + if self.action in ['retrieve']: + permission_classes = [] + else: + permission_classes = [IsAdminUser] + + return [permission() for permission in permission_classes] + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + instance.use() + serializer = self.get_serializer(instance) + return Response(serializer.data) From b8401f42bed93186124d761be47d1defe93186fd Mon Sep 17 00:00:00 2001 From: RignonNoel Date: Thu, 29 Jun 2023 14:38:20 -0400 Subject: [PATCH 2/5] cleanup Dockerfile and forbid pip cache inside it --- Dockerfile | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index dbe5ff7e..025ae767 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,20 +2,11 @@ FROM lambci/lambda:build-python3.8 LABEL maintainer="support@fjnr.ca" -# Fancy prompt to remind you are in zappashell -RUN echo 'export PS1="\[\e[36m\]blitz_shell>\[\e[m\] "' >> /root/.bashrc - COPY requirements.txt /requirements.txt COPY requirements-dev.txt /requirements-dev.txt -# Virtualenv created for zappa -RUN virtualenv ~/ve -RUN source ~/ve/bin/activate \ - && pip install -r /requirements.txt \ - && pip install -r /requirements-dev.txt - -RUN pip --timeout=1000 install -r /requirements.txt \ - && pip --timeout=1000 install -r /requirements-dev.txt +RUN pip --timeout=1000 --no-cache-dir install -r /requirements.txt +RUN pip --timeout=1000 --no-cache-dir install -r /requirements-dev.txt RUN mkdir -p /opt/project From cd2fd2f69cf0b29f56c68ca9e22b24089208b074 Mon Sep 17 00:00:00 2001 From: RignonNoel Date: Thu, 29 Jun 2023 15:27:22 -0400 Subject: [PATCH 3/5] remove entrypoint and use healthcheck compose --- Dockerfile | 6 ------ Jenkinsfile | 10 +++++----- docker-compose.yml | 11 +++++++++-- docker/entrypoint | 36 ------------------------------------ 4 files changed, 14 insertions(+), 49 deletions(-) delete mode 100644 docker/entrypoint diff --git a/Dockerfile b/Dockerfile index 025ae767..25468701 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,14 +10,8 @@ RUN pip --timeout=1000 --no-cache-dir install -r /requirements-dev.txt RUN mkdir -p /opt/project -COPY ./docker/entrypoint /entrypoint -RUN sed -i 's/\r$//g' /entrypoint -RUN chmod +x /entrypoint - COPY ./docker/start /start RUN sed -i 's/\r$//g' /start RUN chmod +x /start WORKDIR /opt/project - -ENTRYPOINT ["/entrypoint"] \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index 1b8af53b..c6c4bd94 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,7 +1,7 @@ pipeline { agent { docker { - image 'tmaier/docker-compose' + image 'docker:20.10.24-cli-alpine3.18' args '-v /var/run/docker.sock:/var/run/docker.sock' } } @@ -11,22 +11,22 @@ pipeline { stages { stage('Debug info') { steps { - sh 'docker-compose --version' + sh 'docker compose --version' } } stage('Build images') { steps { - sh 'docker-compose build' + sh 'docker compose build' } } stage('Static code analysis') { steps { - sh 'docker-compose run --rm api pycodestyle --config=.pycodestylerc .' + sh 'docker compose run --rm api pycodestyle --config=.pycodestylerc .' } } stage('Unit tests') { steps { - sh 'docker-compose run --rm api python manage.py test' + sh 'docker compose run --rm api python manage.py test' } } stage('deploy QA') { diff --git a/docker-compose.yml b/docker-compose.yml index 8bdb9e3e..78a923fc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3' +version: '3.9' services: db: @@ -12,6 +12,12 @@ services: - db:/var/lib/postgresql/data ports: - 5432:5432 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U root -d blitz"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 30s api: restart: always @@ -23,7 +29,8 @@ services: - /opt/project/src command: /start depends_on: - - db + db: + condition: service_healthy ports: - 8000:8000 diff --git a/docker/entrypoint b/docker/entrypoint deleted file mode 100644 index f8865e23..00000000 --- a/docker/entrypoint +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -# if any of the commands in your code fails for any reason, the entire script fails -set -o errexit -# fail exit if one of your pipe command fails -set -o pipefail -# exits if any of your variables is not set -set -o nounset - -postgres_ready() { -python << END -import sys - -import psycopg2 - -try: - psycopg2.connect( - dbname="${DB_NAME}", - user="${DB_USER}", - password="${DB_PASSWORD}", - host="${DB_HOST}", - port="${DB_PORT}", - ) -except psycopg2.OperationalError: - sys.exit(-1) -sys.exit(0) - -END -} -until postgres_ready; do - >&2 echo 'Waiting for PostgreSQL to become available...' - sleep 1 -done ->&2 echo 'PostgreSQL is available' - -exec "$@" From 590b4a107fac50cfb92e01842ebb38d9b51fc14d Mon Sep 17 00:00:00 2001 From: RignonNoel Date: Thu, 29 Jun 2023 16:20:54 -0400 Subject: [PATCH 4/5] remove entrypoint and use healthcheck compose --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 78a923fc..9a91f3c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: interval: 5s timeout: 5s retries: 5 - start_period: 30s + start_period: 60s api: restart: always From cd6800123defa116f9f10d03ec6d398e7282b80a Mon Sep 17 00:00:00 2001 From: RomainFayolle Date: Tue, 4 Jul 2023 11:57:58 -0400 Subject: [PATCH 5/5] add options quantity in retreat export --- retirement/exports.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/retirement/exports.py b/retirement/exports.py index 5c58e49f..fc091078 100644 --- a/retirement/exports.py +++ b/retirement/exports.py @@ -12,6 +12,7 @@ from blitz_api.models import ExportMedia from retirement.models import Retreat +from store.models import OrderLineBaseProduct LOCAL_TIMEZONE = pytz.timezone(settings.TIME_ZONE) @@ -72,6 +73,7 @@ def generate_retreat_participation( output_stream = io.StringIO() writer = csv.writer(output_stream) retreat = Retreat.objects.get(pk=retreat_id) + option_mapping = {} room_export = retreat.has_room_option rooms_data = {} to_reorder_lines = [] @@ -80,6 +82,12 @@ def generate_retreat_participation( 'Nom', 'Prénom', 'Email', "Date d'inscription", 'Restrictions personnelles', 'Ville', 'Téléphone', 'Genre', ] + options = retreat.options + for opt in options: + option_mapping[opt.id] = len(header) + room_header = [opt.name] + header += room_header + room_index = len(header) if room_export: room_header = [ 'Option de chambre', 'Préférence de genre', @@ -106,13 +114,24 @@ def generate_retreat_participation( line_array[6] = reservation.user.phone line_array[7] = reservation.user.gender + for opt in options: + try: + quantity = OrderLineBaseProduct.objects.get( + order_line=reservation.order_line, + option=opt + ).quantity + except OrderLineBaseProduct.DoesNotExist: + quantity = 0 + line_array[option_mapping[opt.id]] = quantity + if room_export: user_id = reservation.user.id - line_array[8] = rooms_data[user_id]['room_option'] - line_array[9] = rooms_data[user_id]['gender_preference'] - line_array[10] = rooms_data[user_id]['share_with'] - line_array[11] = rooms_data[user_id]['room_number'] - if line_array[11] == 'NA': + line_array[room_index] = rooms_data[user_id]['room_option'] + line_array[room_index + 1] = rooms_data[ + user_id]['gender_preference'] + line_array[room_index + 2] = rooms_data[user_id]['share_with'] + line_array[room_index + 3] = rooms_data[user_id]['room_number'] + if line_array[room_index + 3] == 'NA': no_room_lines.append(line_array) else: to_reorder_lines.append(line_array)