From fff075d220bffa87f8a9ad5ad0f87de6dcc5b376 Mon Sep 17 00:00:00 2001 From: Twin Karmakharm Date: Thu, 28 Nov 2024 11:09:33 +0700 Subject: [PATCH] feat: add dynamic form generation --- SORT/settings.py | 1 - home/urls.py | 1 - home/views.py | 4 +- invites/__init__.py | 0 invites/admin.py | 3 - invites/apps.py | 6 - invites/forms.py | 9 - invites/migrations/0001_initial.py | 39 --- .../migrations/0002_alter_invitation_token.py | 19 -- invites/migrations/__init__.py | 0 invites/models.py | 22 -- invites/tests.py | 3 - invites/urls.py | 7 - invites/views.py | 49 ---- requirements.txt | 1 + survey/admin.py | 7 +- survey/forms.py | 100 ++++--- survey/migrations/0001_initial.py | 81 ++---- ...2_answer_submitted_at_alter_answer_user.py | 27 -- .../0003_alter_question_question_type.py | 26 -- .../0004_alter_question_questionnaire.py | 22 -- .../migrations/0005_question_answer_type.py | 26 -- .../0006_remove_question_answer_type.py | 16 - survey/migrations/0007_comment.py | 45 --- ...move_comment_user_answer_token_and_more.py | 34 --- ..._alter_answer_token_alter_comment_token.py | 22 -- survey/misc.py | 73 +++++ survey/mixins.py | 2 +- survey/models.py | 61 ++-- .../invitations/complete_invitation.html | 0 .../invitations/send_invitation.html | 0 survey/templates/survey/survey.html | 17 ++ .../survey/survey_link_invalid_view.html | 8 + survey/templates/survey/survey_response.html | 13 + survey/urls.py | 11 +- survey/views.py | 274 ++++++++++++------ 36 files changed, 417 insertions(+), 612 deletions(-) delete mode 100644 invites/__init__.py delete mode 100644 invites/admin.py delete mode 100644 invites/apps.py delete mode 100644 invites/forms.py delete mode 100644 invites/migrations/0001_initial.py delete mode 100644 invites/migrations/0002_alter_invitation_token.py delete mode 100644 invites/migrations/__init__.py delete mode 100644 invites/models.py delete mode 100644 invites/tests.py delete mode 100644 invites/urls.py delete mode 100644 invites/views.py delete mode 100644 survey/migrations/0002_answer_submitted_at_alter_answer_user.py delete mode 100644 survey/migrations/0003_alter_question_question_type.py delete mode 100644 survey/migrations/0004_alter_question_questionnaire.py delete mode 100644 survey/migrations/0005_question_answer_type.py delete mode 100644 survey/migrations/0006_remove_question_answer_type.py delete mode 100644 survey/migrations/0007_comment.py delete mode 100644 survey/migrations/0008_remove_answer_user_remove_comment_user_answer_token_and_more.py delete mode 100644 survey/migrations/0009_alter_answer_token_alter_comment_token.py create mode 100644 survey/misc.py rename {invites => survey}/templates/invitations/complete_invitation.html (100%) rename {invites => survey}/templates/invitations/send_invitation.html (100%) create mode 100644 survey/templates/survey/survey.html create mode 100644 survey/templates/survey/survey_link_invalid_view.html create mode 100644 survey/templates/survey/survey_response.html diff --git a/SORT/settings.py b/SORT/settings.py index fd7cf4c..3904b92 100644 --- a/SORT/settings.py +++ b/SORT/settings.py @@ -48,7 +48,6 @@ "home", "survey", - "invites", ] MIDDLEWARE = [ diff --git a/home/urls.py b/home/urls.py index 1d1ad0c..c6c6076 100644 --- a/home/urls.py +++ b/home/urls.py @@ -9,7 +9,6 @@ path('logout/', views.LogoutInterfaceView.as_view(), name='logout'), path('signup/', views.SignupView.as_view(), name='signup'), path('', include('survey.urls'), name='survey'), - path('invite/', include('invites.urls'), name='invites'), path('profile/', views.ProfileView.as_view(), name='profile'), path('password_reset/', views.CustomPasswordResetView.as_view(), name='password_reset'), path('password_reset/done/', views.CustomPasswordResetDoneView.as_view(), name='password_reset_done'), diff --git a/home/views.py b/home/views.py index cb58a55..555a2d4 100644 --- a/home/views.py +++ b/home/views.py @@ -3,7 +3,7 @@ from django.contrib.auth.views import LoginView, LogoutView from django.views.generic.edit import CreateView, UpdateView from django.shortcuts import redirect -from survey.models import Questionnaire +from survey.models import Survey from django.shortcuts import render from django.views import View from .forms import ManagerSignupForm, ManagerLoginForm, UserProfileForm @@ -44,7 +44,7 @@ class HomeView(LoginRequiredMixin, View): template_name = 'home/welcome.html' login_url = 'login' def get(self, request): - consent_questionnaire = Questionnaire.objects.get( + consent_questionnaire = Survey.objects.get( title="Consent") return render(request, 'home/welcome.html', {'questionnaire': consent_questionnaire}) diff --git a/invites/__init__.py b/invites/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/invites/admin.py b/invites/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/invites/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/invites/apps.py b/invites/apps.py deleted file mode 100644 index c7429fb..0000000 --- a/invites/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class InvitationsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "invites" diff --git a/invites/forms.py b/invites/forms.py deleted file mode 100644 index 058f076..0000000 --- a/invites/forms.py +++ /dev/null @@ -1,9 +0,0 @@ -from django import forms -from django.core.validators import EmailValidator - - -class InvitationForm(forms.Form): - email = forms.EmailField(label='Participant Email', - max_length=100, - required=True, - validators=[EmailValidator()]) \ No newline at end of file diff --git a/invites/migrations/0001_initial.py b/invites/migrations/0001_initial.py deleted file mode 100644 index 48b030c..0000000 --- a/invites/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-22 14:33 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - ("survey", "0007_comment"), - ] - - operations = [ - migrations.CreateModel( - name="Invitation", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("token", models.CharField(blank=True, max_length=64, unique=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("used", models.BooleanField(default=False)), - ( - "questionnaire", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="survey.questionnaire", - ), - ), - ], - ), - ] diff --git a/invites/migrations/0002_alter_invitation_token.py b/invites/migrations/0002_alter_invitation_token.py deleted file mode 100644 index 874b843..0000000 --- a/invites/migrations/0002_alter_invitation_token.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-23 09:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("invites", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="invitation", - name="token", - field=models.CharField( - blank=True, editable=False, max_length=64, unique=True - ), - ), - ] diff --git a/invites/migrations/__init__.py b/invites/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/invites/models.py b/invites/models.py deleted file mode 100644 index 2a09e8d..0000000 --- a/invites/models.py +++ /dev/null @@ -1,22 +0,0 @@ -import secrets -from django.db import models -from django.utils import timezone -from survey.models import Questionnaire - -class Invitation(models.Model): - - questionnaire = models.ForeignKey(Questionnaire, on_delete=models.CASCADE) - token = models.CharField(max_length=64, unique=True, blank=True, editable=False) - created_at = models.DateTimeField(auto_now_add=True) - used = models.BooleanField(default=False) - - def __str__(self): - return f"Invitation for {self.questionnaire.title}" - - def save(self, *args, **kwargs): - if not self.token: - self.token = secrets.token_urlsafe(32) - super().save(*args, **kwargs) - - def is_expired(self): - return timezone.now() > self.created_at + timezone.timedelta(days=7) diff --git a/invites/tests.py b/invites/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/invites/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/invites/urls.py b/invites/urls.py deleted file mode 100644 index fc16e83..0000000 --- a/invites/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path -from .views import InvitationView, SuccessInvitationView - -urlpatterns = [ - path('', InvitationView.as_view(), name='invite'), - path('success/', SuccessInvitationView.as_view(), name='success_invitation') -] \ No newline at end of file diff --git a/invites/views.py b/invites/views.py deleted file mode 100644 index 7beb395..0000000 --- a/invites/views.py +++ /dev/null @@ -1,49 +0,0 @@ -from django.views.generic.edit import FormView -from django.core.mail import send_mail -from django.contrib import messages -from .forms import InvitationForm -from django.urls import reverse_lazy -from django.views.generic import TemplateView -from survey.models import Questionnaire -from invites.models import Invitation -from django.contrib.auth.mixins import LoginRequiredMixin -from django.shortcuts import get_object_or_404, redirect -from django.http import HttpResponseForbidden - - -class InvitationView(FormView): - - template_name = 'invitations/send_invitation.html' - form_class = InvitationForm - success_url = reverse_lazy('success_invitation') - - def form_valid(self, form): - email = form.cleaned_data['email'] - - questionnaire = Questionnaire.objects.first() - - invitation = Invitation.objects.create(questionnaire=questionnaire) - - token = invitation.token - - # Generate the survey link with the token - survey_link = f"http://localhost:8000/survey/{questionnaire.pk}/{token}/" - - # Send the email - send_mail( - 'Your Survey Invitation', - f'Click here to start the survey: {survey_link}', - 'from@example.com', - [email], - fail_silently=False, - ) - - # Show success message - messages.success(self.request, f'Invitation sent to {email}.') - return super().form_valid(form) - - - -class SuccessInvitationView(LoginRequiredMixin, TemplateView): - - template_name = 'invitations/complete_invitation.html' diff --git a/requirements.txt b/requirements.txt index 053c4ad..da5b1df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,3 +24,4 @@ traitlets==5.14.3 typing_extensions==4.12.2 tzdata==2024.1 wcwidth==0.2.13 +strenum==0.4.15 diff --git a/survey/admin.py b/survey/admin.py index 402c11c..d85e9b7 100644 --- a/survey/admin.py +++ b/survey/admin.py @@ -1,6 +1,5 @@ from django.contrib import admin -from .models import Questionnaire, Question, Answer +from .models import Survey, SurveyResponse -admin.site.register(Questionnaire) -admin.site.register(Question) -admin.site.register(Answer) +admin.site.register(Survey) +admin.site.register(SurveyResponse) diff --git a/survey/forms.py b/survey/forms.py index ef9f0c8..e345f8a 100644 --- a/survey/forms.py +++ b/survey/forms.py @@ -1,40 +1,62 @@ from django import forms -from .models import Answer, Question -class AnswerForm(forms.ModelForm): - class Meta: - model = Answer - fields = [] # no defaults needed - - def __init__(self, *args, **kwargs): - questionnaire = kwargs.pop('questionnaire', None) # get the questionnaire passed in - super().__init__(*args, **kwargs) - - if questionnaire: - - for index, question in enumerate(questionnaire.questions.all(), start=1): - if question.question_type == 'boolean': - self.fields[f'question_{question.id}'] = forms.ChoiceField( - label=f"{index}. {question.question_text}", - choices=[('agree', 'I agree'), ('disagree', 'I disagree')], - widget=forms.RadioSelect, - required=True - ) - - elif question.question_type == 'rating': - - self.fields[f'question_{question.id}'] = forms.ChoiceField( - label=question.question_text, - choices=[(i, str(i)) for i in range(1, 6)], # assuming a 1-5 rating - required=True - ) - - - else: - - pass - - # self.fields[f'question_{question.id}'] = forms.CharField( - # label=question.question_text, - # required=True, - # widget=forms.Textarea(attrs={'rows': 4, 'style': 'width: 80%;'}), - # ) +from django.forms import BaseFormSet, formset_factory +from strenum import StrEnum +from django.core.validators import EmailValidator + +class InvitationForm(forms.Form): + email = forms.EmailField(label='Participant Email', + max_length=100, + required=True, + validators=[EmailValidator()]) + +class FormFieldType(StrEnum): + CHAR = "char" + TEXT = "text" + RADIO = "radio" + CHECKBOX = "checkbox" + LIKERT = "likert" + + + +def create_field_from_config(field_config: dict): + """ + Convert a field configuration into the correct django field + """ + if field_config['type'] == FormFieldType.CHAR: + field = forms.CharField(label=field_config["label"]) + elif field_config['type'] == FormFieldType.TEXT: + field = forms.CharField(label=field_config["label"], + widget=forms.Textarea) + elif field_config['type'] == FormFieldType.RADIO: + field = forms.ChoiceField(label=field_config["label"], + choices=field_config["options"], + widget=forms.RadioSelect) + elif field_config['type'] == FormFieldType.CHECKBOX: + field = forms.MultipleChoiceField(label=field_config["label"], + widget=forms.CheckboxSelectMultiple, + choices=field_config["options"]) + else: + field = forms.CharField(label=field_config["label"], + widget=forms.Textarea) + + if "required" in field_config: + field.required = field_config["required"] + + return field + + + +def create_dynamic_formset(field_configs: list): + """ + Create a dynamic form set from a list of field configurations. + """ + class BlankDynamicForm(forms.Form): + pass + + class BaseTestFormSet(BaseFormSet): + def add_fields(self, form, index): + super().add_fields(form, index) + for field_config in field_configs: + form.fields[field_config["name"]] = create_field_from_config(field_config) + + return formset_factory(BlankDynamicForm, BaseTestFormSet, min_num=1, max_num=1) diff --git a/survey/migrations/0001_initial.py b/survey/migrations/0001_initial.py index 9849a7b..796716e 100644 --- a/survey/migrations/0001_initial.py +++ b/survey/migrations/0001_initial.py @@ -1,85 +1,42 @@ -# Generated by Django 5.1.1 on 2024-09-12 18:18 +# Generated by Django 5.1.2 on 2024-12-03 06:54 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): + initial = True - dependencies = [] + dependencies = [ + ] operations = [ migrations.CreateModel( - name="Question", + name='Survey', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("question_text", models.CharField(max_length=500)), - ( - "question_type", - models.CharField( - choices=[ - ("text", "Text"), - ("multiple_choice", "Multiple Choice"), - ("rating", "Rating"), - ], - max_length=50, - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('description', models.TextField()), + ('survey_config', models.JSONField()), ], ), migrations.CreateModel( - name="Questionnaire", + name='Invitation', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("title", models.CharField(max_length=200)), - ("description", models.TextField()), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.CharField(blank=True, editable=False, max_length=64, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('used', models.BooleanField(default=False)), + ('survey', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='survey.survey')), ], ), migrations.CreateModel( - name="Answer", + name='SurveyResponse', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("user", models.CharField(max_length=100)), - ("answer_text", models.TextField(blank=True, null=True)), - ( - "question", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="survey.question", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answers', models.JSONField()), + ('survey', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='survey', to='survey.survey')), ], ), - migrations.AddField( - model_name="question", - name="questionnaire", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to="survey.questionnaire" - ), - ), ] diff --git a/survey/migrations/0002_answer_submitted_at_alter_answer_user.py b/survey/migrations/0002_answer_submitted_at_alter_answer_user.py deleted file mode 100644 index b61a588..0000000 --- a/survey/migrations/0002_answer_submitted_at_alter_answer_user.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-01 13:59 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("survey", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name="answer", - name="submitted_at", - field=models.DateTimeField(null=True), - ), - migrations.AlterField( - model_name="answer", - name="user", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL - ), - ), - ] diff --git a/survey/migrations/0003_alter_question_question_type.py b/survey/migrations/0003_alter_question_question_type.py deleted file mode 100644 index 1bd04d0..0000000 --- a/survey/migrations/0003_alter_question_question_type.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-01 15:43 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("survey", "0002_answer_submitted_at_alter_answer_user"), - ] - - operations = [ - migrations.AlterField( - model_name="question", - name="question_type", - field=models.CharField( - choices=[ - ("text", "Text"), - ("multiple_choice", "Multiple Choice"), - ("rating", "Rating"), - ("boolean", "Agree/Disagree"), - ], - default="multiple_choice", - max_length=50, - ), - ), - ] diff --git a/survey/migrations/0004_alter_question_questionnaire.py b/survey/migrations/0004_alter_question_questionnaire.py deleted file mode 100644 index 1c35847..0000000 --- a/survey/migrations/0004_alter_question_questionnaire.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-01 16:17 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("survey", "0003_alter_question_question_type"), - ] - - operations = [ - migrations.AlterField( - model_name="question", - name="questionnaire", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="questions", - to="survey.questionnaire", - ), - ), - ] diff --git a/survey/migrations/0005_question_answer_type.py b/survey/migrations/0005_question_answer_type.py deleted file mode 100644 index 8ddf9eb..0000000 --- a/survey/migrations/0005_question_answer_type.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-01 16:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("survey", "0004_alter_question_questionnaire"), - ] - - operations = [ - migrations.AddField( - model_name="question", - name="answer_type", - field=models.CharField( - blank=True, - choices=[ - ("text", "Text"), - ("multiple_choice", "Multiple Choice"), - ("rating", "Rating"), - ("boolean", "Agree/Disagree"), - ], - max_length=20, - ), - ), - ] diff --git a/survey/migrations/0006_remove_question_answer_type.py b/survey/migrations/0006_remove_question_answer_type.py deleted file mode 100644 index d714e1a..0000000 --- a/survey/migrations/0006_remove_question_answer_type.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-01 16:40 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("survey", "0005_question_answer_type"), - ] - - operations = [ - migrations.RemoveField( - model_name="question", - name="answer_type", - ), - ] diff --git a/survey/migrations/0007_comment.py b/survey/migrations/0007_comment.py deleted file mode 100644 index 08f5729..0000000 --- a/survey/migrations/0007_comment.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 5.1.1 on 2024-10-03 12:52 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("survey", "0006_remove_question_answer_type"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="Comment", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("text", models.TextField()), - ("submitted_at", models.DateTimeField(null=True)), - ( - "questionnaire", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="survey.questionnaire", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - ] diff --git a/survey/migrations/0008_remove_answer_user_remove_comment_user_answer_token_and_more.py b/survey/migrations/0008_remove_answer_user_remove_comment_user_answer_token_and_more.py deleted file mode 100644 index bcbab21..0000000 --- a/survey/migrations/0008_remove_answer_user_remove_comment_user_answer_token_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-23 09:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("survey", "0007_comment"), - ] - - operations = [ - migrations.RemoveField( - model_name="answer", - name="user", - ), - migrations.RemoveField( - model_name="comment", - name="user", - ), - migrations.AddField( - model_name="answer", - name="token", - field=models.CharField( - blank=True, editable=False, max_length=64, unique=True - ), - ), - migrations.AddField( - model_name="comment", - name="token", - field=models.CharField( - blank=True, editable=False, max_length=64, unique=True - ), - ), - ] diff --git a/survey/migrations/0009_alter_answer_token_alter_comment_token.py b/survey/migrations/0009_alter_answer_token_alter_comment_token.py deleted file mode 100644 index 30857a2..0000000 --- a/survey/migrations/0009_alter_answer_token_alter_comment_token.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1.2 on 2024-10-23 09:15 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("survey", "0008_remove_answer_user_remove_comment_user_answer_token_and_more"), - ] - - operations = [ - migrations.AlterField( - model_name="answer", - name="token", - field=models.CharField(blank=True, editable=False, max_length=64), - ), - migrations.AlterField( - model_name="comment", - name="token", - field=models.CharField(blank=True, editable=False, max_length=64), - ), - ] diff --git a/survey/misc.py b/survey/misc.py new file mode 100644 index 0000000..9639b20 --- /dev/null +++ b/survey/misc.py @@ -0,0 +1,73 @@ +test_survey_config = { + "sections": [ + { + "title": "Welcome", + "type": "consent", + "fields": [ + { + "type": "radio", + "name": "consent", + "label": "Your agreement to complete the survey", + "required": True, + "options": [["Yes", "I agree to complete the survey"], ["No", "I do not agree"]] + } + ] + }, + { + "title": "Section 1", + "description": "", + "fields": [ + { + "type": "char", + "name": "char_field", + "label": "Char field", + }, + { + "type": "text", + "name": "text_field", + "label": "Text field", + }, + { + "type": "checkbox", + "name": "checkbox_field", + "label": "Checkbox field", + "options": [["Value 1", "Label 1"], ["Value 2", "Label 2"]] + }, + { + "type": "radio", + "name": "radio_field", + "label": "Radio field", + "options": [["Value 1", "Label 1"], ["Value 2", "Label 2"]] + } + ], + }, + { + "title": "Section 2", + "description": "", + "fields": [ + { + "type": "char", + "name": "char_field", + "label": "Char field", + }, + { + "type": "text", + "name": "text_field", + "label": "Text field", + }, + { + "type": "checkbox", + "name": "checkbox_field", + "label": "Checkbox field", + "options": [["Value 1", "Label 1"], ["Value 2", "Label 2"]] + }, + { + "type": "radio", + "name": "radio_field", + "label": "Radio field", + "options": [["Value 1", "Label 1"], ["Value 2", "Label 2"]] + } + ], + } + ] + } \ No newline at end of file diff --git a/survey/mixins.py b/survey/mixins.py index ab2320e..cfc6e51 100644 --- a/survey/mixins.py +++ b/survey/mixins.py @@ -1,7 +1,7 @@ __author__ = "Farhad Allian" from django.shortcuts import redirect -from invites.models import Invitation +from survey.models import Invitation class TokenAuthenticationMixin: diff --git a/survey/models.py b/survey/models.py index d6656b4..74a76f5 100644 --- a/survey/models.py +++ b/survey/models.py @@ -1,11 +1,17 @@ +import secrets from django.db import models from django.contrib.auth.models import User from django.urls import reverse +from django.utils import timezone -class Questionnaire(models.Model): - # Questionnaire data model +class Survey(models.Model): + """ + Represents a survey that will be sent out to a participant + """ title = models.CharField(max_length=200) description = models.TextField() + survey_config = models.JSONField() + # TODO: Add the project it belongs to as foreign key def __str__(self): return self.title @@ -14,40 +20,33 @@ def get_absolute_url(self, token): return reverse('survey', kwargs={'pk': self.pk, 'token': token}) -class Question(models.Model): - # Question data model - QUESTION_TYPE_CHOICES = [ - ('text', 'Text'), - ('multiple_choice', 'Multiple Choice'), - ('rating', 'Rating'), - ('boolean', 'Agree/Disagree') - ] +class SurveyResponse(models.Model): + """ + Represents a single response to the survey from a participant + """ - questionnaire = models.ForeignKey(Questionnaire, related_name='questions', on_delete=models.CASCADE) # Many questions belong to one questionnaire - question_text = models.CharField(max_length=500) - question_type = models.CharField(max_length=50, choices=QUESTION_TYPE_CHOICES, default='multiple_choice') + survey = models.ForeignKey(Survey, related_name='survey', on_delete=models.CASCADE) # Many questions belong to one survey + answers = models.JSONField() - def __str__(self): - return self.question_text + def get_absolute_url(self, token): + return reverse('survey', kwargs={'pk': self.pk, 'token': token}) -class Answer(models.Model): - # User answer data model - question = models.ForeignKey(Question, on_delete=models.CASCADE) - answer_text = models.TextField(blank=True, null=True) - token = models.CharField(max_length=64, blank=True, editable=False) - submitted_at = models.DateTimeField(null=True) # - def __str__(self): - return f"Answer for {self.question.question_text}" +class Invitation(models.Model): - -class Comment(models.Model): - # Comments data model - questionnaire = models.ForeignKey(Questionnaire, on_delete=models.CASCADE) - token = models.CharField(max_length=64, blank=True, editable=False) - text = models.TextField() - submitted_at = models.DateTimeField(null=True) + survey = models.ForeignKey(Survey, on_delete=models.CASCADE) + token = models.CharField(max_length=64, unique=True, blank=True, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + used = models.BooleanField(default=False) def __str__(self): - return f"Comment on {self.questionnaire.title}" \ No newline at end of file + return f"Invitation for {self.survey.title}" + + def save(self, *args, **kwargs): + if not self.token: + self.token = secrets.token_urlsafe(32) + super().save(*args, **kwargs) + + def is_expired(self): + return timezone.now() > self.created_at + timezone.timedelta(days=7) \ No newline at end of file diff --git a/invites/templates/invitations/complete_invitation.html b/survey/templates/invitations/complete_invitation.html similarity index 100% rename from invites/templates/invitations/complete_invitation.html rename to survey/templates/invitations/complete_invitation.html diff --git a/invites/templates/invitations/send_invitation.html b/survey/templates/invitations/send_invitation.html similarity index 100% rename from invites/templates/invitations/send_invitation.html rename to survey/templates/invitations/send_invitation.html diff --git a/survey/templates/survey/survey.html b/survey/templates/survey/survey.html new file mode 100644 index 0000000..0c9ee4f --- /dev/null +++ b/survey/templates/survey/survey.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} + +{% block content %} +

{{ survey.title }}

+

{{ survey.description }}

+ + + + + + + +{% endblock %} \ No newline at end of file diff --git a/survey/templates/survey/survey_link_invalid_view.html b/survey/templates/survey/survey_link_invalid_view.html new file mode 100644 index 0000000..87d4fa2 --- /dev/null +++ b/survey/templates/survey/survey_link_invalid_view.html @@ -0,0 +1,8 @@ +{% extends 'base.html' %} + +{% block content %} + +

Your survey link is invalid

+

Reason ... e.g. token expired, no longer accepting responses,etc.

+ +{% endblock %} \ No newline at end of file diff --git a/survey/templates/survey/survey_response.html b/survey/templates/survey/survey_response.html new file mode 100644 index 0000000..7395716 --- /dev/null +++ b/survey/templates/survey/survey_response.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block content %} + +

{{ title }}

+
+ {% csrf_token %} + {{ form }} + + +
+ +{% endblock %} \ No newline at end of file diff --git a/survey/urls.py b/survey/urls.py index a25b1d4..d570a64 100644 --- a/survey/urls.py +++ b/survey/urls.py @@ -2,6 +2,11 @@ from . import views urlpatterns = [ - path('survey///', views.QuestionnaireView.as_view(), name='questionnaire'), - path('completion/', views.CompletionView.as_view(), name='completion_page') -] \ No newline at end of file + path('survey/', views.SurveyView.as_view(), name='survey'), + path('completion/', views.CompletionView.as_view(), name='completion_page'), + path('survey_response//', views.SurveyResponseView.as_view(), name='survey_response'), + path('survey_link_invalid/', views.SurveyLinkInvalidView.as_view(), name='survey_link_invalid'), + path('invite/', views.InvitationView.as_view(), name='invite'), + path('invite/success/', views.SuccessInvitationView.as_view(), name='success_invitation'), + +] diff --git a/survey/views.py b/survey/views.py index 380fee1..0cdf23e 100644 --- a/survey/views.py +++ b/survey/views.py @@ -1,109 +1,148 @@ +from django.core.mail import send_mail +from django.http import HttpRequest from django.shortcuts import render, get_object_or_404, redirect +from django.urls import reverse_lazy from django.views import View -from django.contrib.auth.mixins import LoginRequiredMixin -from .models import Questionnaire, Answer, Comment -from .forms import AnswerForm +from django.views.generic import FormView, TemplateView +from django.views.generic.edit import CreateView from django.contrib import messages -from invites.models import Invitation +from django.contrib.auth.mixins import LoginRequiredMixin from .mixins import TokenAuthenticationMixin -import logging +from .forms import create_dynamic_formset, InvitationForm +from .models import Survey, SurveyResponse +from .models import Invitation +from .misc import test_survey_config + +import logging logger = logging.getLogger(__name__) -class QuestionnaireView(TokenAuthenticationMixin, View): +class SurveyView(LoginRequiredMixin, View): + """ + Manager's view of a survey to be sent out. The manager is able to + configure what fields are included in the survey on this page. + """ login_url = '/login/' # redirect to login if not authenticated - def get(self, request, pk, token): - # Retrieve the questionnaire - questionnaire = get_object_or_404(Questionnaire, pk=pk) - - if not self.validate_token(token): - messages.error(request, "Invalid or expired invitation token.") - logger.error(f"Token validation failed.") - return redirect('completion_page') - - form = AnswerForm(questionnaire=questionnaire) - question_numbers = list(enumerate(questionnaire.questions.all(), start=1)) - - rating_questions = questionnaire.questions.filter(question_type='rating') - legend_text = self.get_legend_text(rating_questions) - - return render(request, 'survey/questionnaire.html', { - 'form': form, - 'questionnaire': questionnaire, - 'question_numbers': question_numbers, - 'legend_text': legend_text, - 'token': token, - }) - - def post(self, request, pk, token): - logger.info("Received POST request for questionnaire and token") - questionnaire = get_object_or_404(Questionnaire, pk=pk) - form = AnswerForm(request.POST, questionnaire=questionnaire) - - if form.is_valid(): - if self.all_agree(form, questionnaire): - self.save_answers(form, questionnaire, token) - next_questionnaire = self.get_next_questionnaire(questionnaire) - - if next_questionnaire: - logger.info(f"Redirecting to next questionnaire: {next_questionnaire.pk}") - return redirect('questionnaire', pk=next_questionnaire.pk, token=token) + def get(self, request, pk): + return self.render_survey_page(request, pk) + + def post(self, request, pk): + return self.render_survey_page(request, pk) + + + def render_survey_page(self, request, pk): + context = {} + survey = get_object_or_404(Survey, pk=pk) + + context["survey"] = survey + + return render(request, 'survey/survey.html', context) + +# TODO: Add TokenAuthenticationMixin after re-enabling the token +class SurveyResponseView(View): + """ + Participant's view of the survey. This view renders the survey configuration + allowing participant to fill in the survey form and send it for processing. + """ + + + def get(self, request: HttpRequest, pk: int, token: str): + return self.render_survey_response_page(request, pk, token, is_post=False) + + def post(self, request: HttpRequest, pk: int, token: str): + return self.render_survey_response_page(request, pk, token, is_post=True) + + + def render_survey_response_page(self, + request: HttpRequest, + pk: int, + token: str, + is_post: bool): + + survey_form_session_key = "survey_form_session" + + # Check token + + # TODO: Re-enable token once the invitation UI is in place + # if not self.validate_token(token): + # messages.error(request, "Invalid or expired invitation token.") + # logger.error(f"Token validation failed.") + # return redirect('survey_link_invalid') + + # Get the survey object and config + survey = get_object_or_404(Survey, pk=pk) + survey_config = survey.survey_config + + # TODO: Check that config is valid + + # Context for rendering + context = {} + + context["pk"] = pk + context["token"] = token + + # Gets the session data or sets it anew with section starting from 0 + session_data = {"section": 0} + if is_post: + if survey_form_session_key in request.session: + session_data = request.session[survey_form_session_key] + + current_section = session_data["section"] + survey_form_set = create_dynamic_formset(survey_config["sections"][current_section]["fields"]) + + if is_post: + # Only process if it's a post request + + # Validate current form + survey_form = survey_form_set(request.POST) + if survey_form.is_valid(): + logger.info("Form validated") + # Store form data + if "data" not in session_data: + session_data["data"] = [] + session_data["data"].append(survey_form.cleaned_data) + + current_section += 1 + if current_section < len(survey_config["sections"]): + # Go to next section + logger.info(f"Redirecting to next section") + # Store section's data + session_data["section"] = current_section + # Display the next section + survey_form = create_dynamic_formset(survey_config["sections"][current_section]["fields"]) else: - logger.info("No more questionnaires. Redirecting to completion page.") - return redirect('completion_page') + # No more sections so it's finished + logger.info("No more questions. Redirecting to completion page.") + + # Save data + SurveyResponse.objects.create(survey=survey, answers=session_data["data"]) + + # Delete session key + del request.session[survey_form_session_key] + request.session.modified = True + # TODO: Re-enable this once token has been enabled + # Invalidate token + # token = Invitation.objects.get(token=token) + # token.used = True + # token.save() + + # Go to the completion page + return redirect('completion_page') else: - messages.error(request, "You must agree to all statements to proceed.") - - context = { - 'form': form, - 'questionnaire': questionnaire, - 'question_numbers': enumerate(questionnaire.questions.all(), start=1), - 'token': token, - } - return render(request, 'survey/questionnaire.html', context=context) - - def all_agree(self, form, questionnaire): - if questionnaire.title == "Consent": - for question in questionnaire.questions.all(): - answer_text = form.cleaned_data.get(f'question_{question.id}') - if answer_text != "agree": - return False - return True - - def save_answers(self, form, questionnaire, token): - for question in questionnaire.questions.all(): - answer_text = form.cleaned_data.get(f'question_{question.id}') - Answer.objects.create( - question=question, - answer_text=answer_text, - token=token, - ) - - comment_text = form.cleaned_data.get('comments') - if comment_text: - Comment.objects.create( - text=comment_text, - questionnaire=questionnaire, - token=token, - ) - - def get_legend_text(self, rating_questions): - if rating_questions.exists(): - return "Our Organisation: (0=Not yet planned; 1=Planned; 2=Early progress; 3=Substantial Progress; 4=Established)" - return "" - - def get_next_questionnaire(self, current_questionnaire): - next_questionnaire = ( - Questionnaire.objects - .exclude(title="Consent") - .filter(pk__gt=current_questionnaire.pk) - .order_by('pk') - .first() - ) - return next_questionnaire + logger.info("Form invalid") + else: + # Return empty form if it's a get request + survey_form = survey_form_set() + + request.session[survey_form_session_key] = session_data + context["title"] = survey_config["sections"][session_data["section"]]["title"] + context["form"] = survey_form + return render(request=request, + template_name='survey/survey_response.html', + context=context) def validate_token(self, token): @@ -115,7 +154,56 @@ def validate_token(self, token): logger.warning(f"Token is invalid or expired.") return is_valid +class SurveyLinkInvalidView(View): + """ + Shown when a participant is trying to access the SurveyResponseView using an + invalid pk or token. + """ + + def get(self, request): + return render(request, "survey/survey_link_invalid_view.html" ) + class CompletionView(View): + """ + Shown when a survey is completed by a participant. + """ def get(self, request): messages.info(request, "You have completed the survey.") return render(request, 'survey/completion.html') + +class InvitationView(FormView): + + template_name = 'invitations/send_invitation.html' + form_class = InvitationForm + success_url = reverse_lazy('success_invitation') + + def form_valid(self, form): + email = form.cleaned_data['email'] + + questionnaire = Survey.objects.first() + + invitation = Invitation.objects.create(questionnaire=questionnaire) + + token = invitation.token + + # Generate the survey link with the token + survey_link = f"http://localhost:8000/survey/{questionnaire.pk}/{token}/" + + # Send the email + send_mail( + 'Your Survey Invitation', + f'Click here to start the survey: {survey_link}', + 'from@example.com', + [email], + fail_silently=False, + ) + + # Show success message + messages.success(self.request, f'Invitation sent to {email}.') + return super().form_valid(form) + + + +class SuccessInvitationView(LoginRequiredMixin, TemplateView): + + template_name = 'invitations/complete_invitation.html' \ No newline at end of file