Skip to content

Commit 63cb3a6

Browse files
Merge pull request #305 from RSE-Sheffield/feat/survey-pause
Add survey pause/unpause feature
2 parents b0be5f1 + 4b8d15f commit 63cb3a6

File tree

9 files changed

+237
-16
lines changed

9 files changed

+237
-16
lines changed

survey/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from django.core.exceptions import ValidationError
2+
3+
4+
class SurveyInactiveError(ValidationError):
5+
"""
6+
A survey is paused so no data is being gathered.
7+
"""
8+
9+
pass
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.1.4 on 2025-07-01 09:19
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("survey", "0018_alter_surveyevidencesection_text"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="survey",
15+
name="is_active",
16+
field=models.BooleanField(
17+
default=True, help_text="Are responses being collected?"
18+
),
19+
),
20+
]

survey/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ class Survey(models.Model):
2424
project = models.ForeignKey(Project, on_delete=models.CASCADE, null=True)
2525
created_at = models.DateTimeField(auto_now_add=True)
2626
survey_body_path = models.TextField(blank=True, null=True)
27+
is_active = models.BooleanField(
28+
default=True,
29+
help_text="Are responses being collected?",
30+
null=False,
31+
)
2732

2833
def __str__(self):
2934
return self.name
@@ -121,6 +126,13 @@ class SurveyResponse(models.Model):
121126
def get_absolute_url(self, token):
122127
return reverse("survey", kwargs={"pk": self.survey.pk})
123128

129+
def clean(self):
130+
super().clean()
131+
132+
# Paused survey
133+
if not self.survey.is_active:
134+
raise ValueError("Cannot submit response to an inactive survey")
135+
124136

125137
class Invitation(models.Model):
126138
"""

survey/services/survey.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,17 +122,22 @@ def update_consent_demography_config(
122122

123123
@requires_permission("edit", obj_param="survey")
124124
def duplicate_survey(self, user: User, survey: Survey):
125-
new_survey = Survey.objects.create(project=survey.project,
126-
name=f"Copy of {survey.name}",
127-
description=survey.description)
128-
129-
self.update_consent_demography_config(user, new_survey,
130-
consent_config=survey.consent_config,
131-
demography_config=survey.demography_config, survey_body_path=survey.survey_body_path)
125+
new_survey = Survey.objects.create(
126+
project=survey.project,
127+
name=f"Copy of {survey.name}",
128+
description=survey.description,
129+
)
130+
131+
self.update_consent_demography_config(
132+
user,
133+
new_survey,
134+
consent_config=survey.consent_config,
135+
demography_config=survey.demography_config,
136+
survey_body_path=survey.survey_body_path,
137+
)
132138

133139
return new_survey
134140

135-
136141
def _create_survey_evidence_sections(
137142
self, survey: Survey, clear_existing_sections: bool = True
138143
):
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{% extends 'base_manager.html' %}
2+
{% block content %}
3+
{% load project_filters %}
4+
<div class="container mx-auto px-4 py-8 mt-4">
5+
<nav aria-label="breadcrumb">
6+
<ol class="breadcrumb">
7+
<li class="breadcrumb-item" aria-current="page">
8+
<i class="bx bxs-buildings"></i> <a href="{% url 'myorganisation' %}">My Organisation</a>
9+
</li>
10+
<li class="breadcrumb-item" aria-current="page">
11+
<i class="bx bxs-folder-open"></i> <a
12+
href="{% url 'project' survey.project.id %}">Project: {{ survey.project.name }}</a>
13+
</li>
14+
<li class="breadcrumb-item" aria-current="page">
15+
<i class="bx bxs-chart"></i> Survey: {{ survey.name }}
16+
</li>
17+
<li class="breadcrumb-item active" aria-current="page">
18+
<i class="bx bx-pause-circle"></i>&nbsp;Deactivate survey
19+
</li>
20+
</ol>
21+
</nav>
22+
</div>
23+
<div class="container px-4">
24+
<h1>Deactivate survey</h1>
25+
<p>
26+
Please confirm that you would like to <strong>pause response gathering</strong> for survey "{{ survey }}".
27+
</p>
28+
29+
<p>
30+
After you have paused the survey, to resume response gathering please click the "Activate survey" button on
31+
the survey management page.
32+
</p>
33+
<h2>Confirm deactivation</h2>
34+
<p>
35+
If the survey is deactivated then participants will not be able to submit survey data until it is activated
36+
again.
37+
</p>
38+
<form action="{% url 'survey_deactivate' survey.id %}" method="post">
39+
{% csrf_token %}
40+
<button type="submit" name="deactivate" value="1" class="btn btn-warning">
41+
<i class="bx bx-pause-circle"></i>&nbsp;Deactivate survey
42+
</button>
43+
<label for="confirm" class="m-3">
44+
<input type="checkbox" required id="confirm" name="confirm" value="1"/>
45+
I <strong>confirm</strong> that I want to pause response gathering
46+
</label>
47+
</form>
48+
</div>
49+
{% endblock %}

survey/templates/survey/survey.html

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ <h2>2. Invite Your Participants</h2>
132132
<p>
133133
Geneate an invitation link for your participants to fill in the survey:
134134
</p>
135-
<form method="post" action="{% url 'suvey_create_invite' survey.id %}">
135+
<form method="post" action="{% url 'survey_create_invite' survey.id %}">
136136
{% csrf_token %}
137137
<button type="submit" class="btn btn-primary" name="generate_token"><i
138138
class='bx bx-mail-send'></i> Generate invitation
@@ -176,17 +176,37 @@ <h2>Generate mock responses</h2>
176176
<div class="card-header">
177177
<h2>3. Survey responses</h2>
178178
</div>
179+
{% if not survey.is_active %}
180+
<p class="alert alert-warning">
181+
<i class="bx bx-pause-circle"></i>
182+
Data collection is <strong>paused</strong>.
183+
Participants are unable to submit survey responses.
184+
</p>
185+
{% endif %}
179186
<div class="card-body">
187+
<p>In this section one may view the collected data that has been submitted by participants, either in summary or individual responses.</p>
188+
<p>You may also pause and resume data collection to prevent new data being gathered when the survey is complete by clicking the "Deactivate survey" or "Activate survey" button below.</p>
180189
{% if survey.survey_config.sections %}
181190
<div class="mb-3">
182-
<strong>Responses collected: {{ responses_count }}</strong>
191+
<strong>Responses collected:</strong>&nbsp;{{ responses_count }}
183192
</div>
184-
185193
<div>
186194
<a href="{% url 'survey_response_data' survey.id %}" class="btn btn-primary"><i
187195
class='bx bxs-data'></i> View collected responses</a>
188196
<a href="{% url 'survey_export' survey.id %}" class="btn btn-primary" download><i
189197
class="bx bx-export"></i> Export survey data as CSV</a>
198+
{% if survey.is_active %}
199+
<a href="{% url 'survey_deactivate' survey.id %}" class="btn btn-warning"
200+
title="Stop collecting responses">
201+
<i class="bx bx-pause-circle"></i> Deactivate survey</a>
202+
{% else %}
203+
<form action="{% url 'survey_activate' survey.id %}" method="post" style="display: inline;">
204+
{% csrf_token %}
205+
<button type="submit" name="activate" value="1" class="btn btn-success">
206+
<i class="bx bx-play-circle"></i>&nbsp;Activate survey
207+
</button>
208+
</form>
209+
{% endif %}
190210
</div>
191211
{% else %}
192212
<p>Survey must be configured before this section can be used.</p>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% extends 'base_manager.html' %}
2+
{% block content %}
3+
<div class="container">
4+
<h3>Survey inactive</h3>
5+
<p>
6+
Response gathering has been paused or stopped for this survey.
7+
</p>
8+
</div>
9+
{% endblock %}

survey/urls.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
path(
2323
"survey/<int:pk>/create_invite",
2424
views.SurveyCreateInviteView.as_view(),
25-
name="suvey_create_invite",
25+
name="survey_create_invite",
2626
),
2727
path(
2828
"survey/<int:pk>/duplicate_config",
@@ -92,6 +92,21 @@
9292
views.SurveyCreateView.as_view(),
9393
name="survey_create",
9494
),
95+
path(
96+
"survey/<int:pk>/activate",
97+
views.SurveyActivateView.as_view(),
98+
name="survey_activate",
99+
),
100+
path(
101+
"survey/<int:pk>/deactivate",
102+
views.SurveyDeactivateView.as_view(),
103+
name="survey_deactivate",
104+
),
105+
path(
106+
"survey_response/inactive",
107+
views.SurveyResponseInactiveView.as_view(),
108+
name="survey_response_inactive",
109+
),
95110
path("completion/", views.CompletionView.as_view(), name="completion_page"),
96111
path(
97112
"survey_response/<str:token>",

survey/views.py

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@
99
from django.contrib.auth.mixins import LoginRequiredMixin
1010
from django.core.exceptions import PermissionDenied
1111
from django.core.files.uploadhandler import UploadFileException
12-
from django.http import HttpRequest, HttpResponse
12+
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
1313
from django.shortcuts import get_object_or_404, redirect, render
1414
from django.template.context_processors import csrf
1515
from django.urls import reverse, reverse_lazy
1616
from django.views import View
17-
from django.views.generic import DeleteView, FormView, UpdateView
17+
from django.views.generic import (
18+
DeleteView,
19+
FormView,
20+
UpdateView,
21+
DetailView,
22+
TemplateView,
23+
)
1824
from django.views.generic.edit import CreateView
1925

2026
from home.models import Project
@@ -29,6 +35,7 @@
2935
SurveyResponse,
3036
)
3137
from .services.survey import InvalidInviteTokenException
38+
from .exceptions import SurveyInactiveError
3239

3340
logger = logging.getLogger(__name__)
3441

@@ -169,6 +176,7 @@ def render_survey_config_view(self, request: HttpRequest, pk: int, is_post: bool
169176
context=context,
170177
)
171178

179+
172180
class SurveyDuplicateConfigView(LoginRequiredMixin, View):
173181
def get(self, request: HttpRequest, pk: int):
174182
survey = survey_service.get_survey(request.user, pk)
@@ -474,10 +482,12 @@ def render_survey_response_page(
474482
):
475483

476484
try:
477-
478485
survey = survey_service.get_survey_from_token(token)
486+
if not survey.is_active:
487+
raise SurveyInactiveError("Survey is not active.")
488+
479489
# Context for rendering
480-
context = {}
490+
context = dict()
481491

482492
if is_post:
483493
# Only process if it's a post request
@@ -501,6 +511,9 @@ def render_survey_response_page(
501511
except InvalidInviteTokenException:
502512
return redirect("survey_link_invalid")
503513

514+
except SurveyInactiveError:
515+
return redirect("survey_response_inactive")
516+
504517

505518
class SurveyLinkInvalidView(View):
506519
"""
@@ -559,3 +572,72 @@ def form_valid(self, form):
559572

560573
def get_success_url(self):
561574
return reverse_lazy("invite", kwargs=dict(pk=self.kwargs["pk"]))
575+
576+
577+
class SurveyActivateView(LoginRequiredMixin, DetailView):
578+
"""
579+
Activate response collection for this survey.
580+
"""
581+
582+
model = Survey
583+
584+
def get(self, request, *args, **kwargs):
585+
"""
586+
We don't need to confirm activation, so no detail view template is used.
587+
"""
588+
return HttpResponseNotAllowed(["POST"])
589+
590+
def activate(self):
591+
"""
592+
Activate the survey.
593+
"""
594+
self.object.is_active = True
595+
self.object.save()
596+
597+
def post(self, request, *args, **kwargs):
598+
"""
599+
Update the survey.
600+
"""
601+
self.object = self.get_object()
602+
self.activate()
603+
messages.success(request, "Survey activated")
604+
return redirect(self.object.get_absolute_url())
605+
606+
607+
class SurveyDeactivateView(LoginRequiredMixin, DetailView):
608+
"""
609+
Deactivate response collection for this survey.
610+
611+
The user must confirm that they want to pause data collection.
612+
"""
613+
614+
model = Survey
615+
context_object_name = "survey"
616+
template_name = "survey/deactivate_confirm.html"
617+
618+
def deactivate(self):
619+
self.object.is_active = False
620+
self.object.save()
621+
622+
def post(self, request, *args, **kwargs):
623+
self.object = self.get_object()
624+
is_confirmed: bool = bool(int(request.POST.get("confirm", "0")))
625+
626+
# Confirmed
627+
if is_confirmed:
628+
self.deactivate()
629+
messages.warning(request, "Survey deactivated")
630+
# Unconfirmed
631+
else:
632+
messages.error(
633+
request, "Please confirm that you want to pause this survey."
634+
)
635+
return redirect(self.object.get_absolute_url())
636+
637+
638+
class SurveyResponseInactiveView(TemplateView):
639+
"""
640+
Show when a survey is inactive.
641+
"""
642+
643+
template_name = "survey/survey_response_inactive.html"

0 commit comments

Comments
 (0)