diff --git a/dynamicforms/admin.py b/dynamicforms/admin.py index 87838d2a1516910e93423261c545b0b7a13f542b..2cbd214e5aa11a99ada2249a031a69020b0c4dee 100644 --- a/dynamicforms/admin.py +++ b/dynamicforms/admin.py @@ -1,7 +1,9 @@ """Dynamic forms admin panels.""" from django.contrib import admin -from .models import Form, Section, Question, FormEntry, Answer, File + +from .models import Answer, File, Form, FormEntry, Question, Section +from .views import download_multiple_forms_entries class SectionInline(admin.StackedInline): @@ -31,10 +33,19 @@ class FormAdmin(admin.ModelAdmin): list_display = ('title', 'created',) list_filter = ('created',) - readonly_fields = ('slug',) + readonly_fields = ('slug', 'created',) search_fields = ('title',) inlines = (SectionInline, FileInline,) + actions = ['download_csv'] + + def download_csv(self, request, queryset): + """Download entries of selected forms under a ZIP file.""" + return download_multiple_forms_entries(request, queryset) + + download_csv.short_description = ( + 'Télécharger les résponses des formulaires sélectionnés') + @admin.register(Section) class SectionAdmin(admin.ModelAdmin): @@ -61,6 +72,7 @@ class FormEntryAdmin(admin.ModelAdmin): list_display = ('form', 'submitted',) list_filter = ('form', 'submitted',) + readonly_fields = ('submitted',) search_fields = ('form__title',) diff --git a/dynamicforms/exports.py b/dynamicforms/exports.py new file mode 100644 index 0000000000000000000000000000000000000000..2169840db43b4cb05cc67dac44948fee5df9f722 --- /dev/null +++ b/dynamicforms/exports.py @@ -0,0 +1,72 @@ +"""Data export utilities.""" + + +from typing import List +import os +import zipfile +import csv +from io import BytesIO, StringIO +from .models import Form + + +def _get_rows(form: Form) -> List[List[str]]: + """Return CSV rows of form entries, including the header row.""" + rows = [] + + sections = form.sections.prefetch_related('questions').all() + + def sections_questions(): + for section in sections: + for question in section.questions.all(): + yield section, question + + def build_row(entry) -> List[str]: + row = [entry.submitted.strftime("%Y-%m-%d %H:%M")] + for _, question in sections_questions(): + answer = entry.answers.filter(question=question).first() + value: str = answer and (answer.answer or '') or '' + row.append(value) + return row + + # Build the header row + headers = ['Soumis le'] + for section, question in sections_questions(): + headers.append(f'{section.title}: {question.text}') + + rows.append(headers) + + # Add a row for each form entry + for entry in form.entries.prefetch_related('answers').all(): + row = build_row(entry) + rows.append(row) + + return rows + + +def _write_csv(form: Form, stream): + """Write a form's entries into a stream in CSV format.""" + writer = csv.writer(stream) + + for row in _get_rows(form): + writer.writerow(row) + + return stream + + +def write_zip(forms_queryset, stream=None, folder='forms'): + """Write form entries into a zip of CSV files.""" + if stream is None: + stream = BytesIO() + # See https://stackoverflow.com/a/32075279 + assert not isinstance(stream, StringIO), ( + 'cannot write binary zip file contents to a StringIO object, ' + 'consider using BytesIO instead' + ) + with zipfile.ZipFile(stream, 'w') as zip: + for form in forms_queryset: + csv_file = StringIO() + _write_csv(form, csv_file) + csv_filename = os.path.join(folder, f'{form.slug}.csv') + value = csv_file.getvalue() + zip.writestr(csv_filename, value.encode()) + return stream diff --git a/dynamicforms/models.py b/dynamicforms/models.py index fff267b995eec0900f193553d4095865ad3a9ace..9ca95806f8aeb2f58bc59330c6eaec4f7b47d394 100644 --- a/dynamicforms/models.py +++ b/dynamicforms/models.py @@ -25,6 +25,10 @@ class Form(models.Model): created = models.DateTimeField('créé le', auto_now_add=True) + sections: models.Manager + entries: models.Manager + files: models.Manager + class Meta: # noqa ordering = ('-created',) verbose_name = 'formulaire' @@ -55,6 +59,8 @@ class Section(models.Model): verbose_name='formulaire', help_text="Formulaire associé à la section.") + questions: models.Manager + def __str__(self): return str(self.title) @@ -102,6 +108,8 @@ class Question(models.Model): verbose_name='section', help_text="Section de formulaire associée à la question.") + answers: models.Manager + def __str__(self) -> str: return f'{self.text}{self.required and "*" or ""}' @@ -121,6 +129,8 @@ class FormEntry(models.Model): auto_now_add=True, help_text="Date et heure de soumission de l'entrée.") + answers: models.Manager + class Meta: # noqa ordering = ('-submitted',) verbose_name = 'entrée de formulaire' diff --git a/dynamicforms/tests.py b/dynamicforms/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..608ff939b8b68a23c9f79e53b3b66b3dd7faab63 --- /dev/null +++ b/dynamicforms/tests.py @@ -0,0 +1,66 @@ +"""Dynamic forms tests.""" + +from io import StringIO, BytesIO +from django.test import TestCase +from .models import Form, Section, Question, FormEntry, Answer +from .exports import _write_csv, _get_rows, write_zip + + +class WriteCsvTest(TestCase): + """Test the CSV export utility.""" + + def setUp(self): + # Create a form + form = Form.objects.create(title='Animals') + + # Add a section + section = Section.objects.create( + form=form, + title='Dogs') + + # Add two questions in the section + have_dog = Question.objects.create( + text='Do you have a dog?', + type=Question.TYPE_YES_NO, + section=section) + Question.objects.create( + text='If yes, what is its name?', + type=Question.TYPE_TEXT_SMALL, + required=False, + section=section) + + # Create an entry + entry = FormEntry.objects.create(form=form) + # Add 1 answer to the entry, leave the other answer blank + Answer.objects.create(question=have_dog, entry=entry, answer='No') + + self.form = Form.objects.get(pk=form.pk) + self.entry = FormEntry.objects.get(pk=entry.pk) + + def test_get_rows(self): + rows = _get_rows(self.form) + self.assertEqual(len(rows), 2) + expected_headers = [ + 'Soumis le', + 'Dogs: If yes, what is its name?', + 'Dogs: Do you have a dog?', + ] + self.assertListEqual(rows[0], expected_headers) + expected_entry_row = [ + self.entry.submitted.strftime("%Y-%m-%d %H:%M"), + '', + 'No', + ] + self.assertListEqual(rows[1], expected_entry_row) + + def test_write_csv_runs_without_failing(self): + stream = StringIO() + _write_csv(self.form, stream) + stream.seek(0) + stream.read() + + def test_write_zip_runs_without_failing(self): + stream = BytesIO() + write_zip([self.form], stream=stream) + stream.seek(0) + stream.read() diff --git a/dynamicforms/views.py b/dynamicforms/views.py index e60e65c609fa08bcad61460e4217dd0db4d68aee..498963b0f532c4cc0244136ff8529c248593bee0 100644 --- a/dynamicforms/views.py +++ b/dynamicforms/views.py @@ -1,7 +1,9 @@ """Dynamic forms views and API endpoints.""" +from django.http import HttpResponse from rest_framework import mixins, viewsets +from .exports import write_zip from .models import Form, FormEntry from .serializers import (FormDetailSerializer, FormEntrySerializer, FormSerializer) @@ -24,3 +26,20 @@ class FormEntryViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): serializer_class = FormEntrySerializer queryset = FormEntry.objects.all() + + +def download_multiple_forms_entries(request, queryset): + """Download form entries in a ZIP file containing CSV files. + + Note: this is not a proper Django view as it expects a queryset. + """ + stream = write_zip(queryset, folder='reponses') + stream.seek(0) + contents = stream.read() + filename = 'responses.zip' + + response = HttpResponse(contents, + content_type='application/x-zip-compressed') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + + return response