• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ephios-dev / ephios / 18652193089

20 Oct 2025 12:36PM UTC coverage: 86.048%. Remained the same
18652193089

push

github

felixrindt
pin cryptography for vapid to work

3656 of 4164 branches covered (87.8%)

Branch coverage included in aggregate %.

13625 of 15919 relevant lines covered (85.59%)

0.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.73
/ephios/plugins/questionnaires/models.py
1
from django import forms
1✔
2
from django.db import models
1✔
3
from django.utils.html import escape, format_html
1✔
4
from django.utils.text import slugify
1✔
5
from django.utils.translation import gettext_lazy as _
1✔
6
from django_select2.forms import Select2MultipleWidget, Select2Widget
1✔
7
from rest_framework import serializers
1✔
8

9
from ephios.core.models.events import AbstractParticipation, Shift
1✔
10
from ephios.core.models.users import UserProfile
1✔
11
from ephios.core.signup.participants import AbstractParticipant, LocalUserParticipant
1✔
12
from ephios.modellogging.log import ModelFieldsLogConfig, dont_log, log
1✔
13

14

15
@log()
1✔
16
class Question(models.Model):
1✔
17
    class Type(models.TextChoices):
1✔
18
        TEXT = "text", _("Text input")
1✔
19
        SINGLE = "single", _("Single choice")
1✔
20
        MULTIPLE = "multiple", _("Multiple choice")
1✔
21

22
    name = models.CharField(
1✔
23
        max_length=50,
24
        verbose_name=_("Name"),
25
        help_text=_(
26
            "The name of the question is used to identify the question, for example when picking questions for a shift"
27
        ),
28
    )
29
    question_text = models.CharField(max_length=250, verbose_name=_("Question"))
1✔
30
    description = models.TextField(verbose_name=_("Description"), blank=True, null=True)
1✔
31
    required = models.BooleanField(
1✔
32
        verbose_name=_("Required"),
33
        help_text=_("Whether users must submit an answer to this question"),
34
    )
35
    type = models.CharField(
1✔
36
        max_length=20, verbose_name=_("Type"), choices=Type.choices, default=Type.TEXT
37
    )
38
    choices = models.JSONField(default=list, verbose_name=_("Choices"))
1✔
39
    archived = models.BooleanField(
1✔
40
        default=False,
41
        verbose_name=_("Archived"),
42
        help_text=_(
43
            "Archive a question to hide it in the question selection for new shifts without affecting shifts where this question is already in use."
44
        ),
45
    )
46
    use_saved_answers = models.BooleanField(
1✔
47
        verbose_name=_("Use saved answers"),
48
        help_text=_(
49
            "If checked, forms will be prefilled with a users saved answer to this question, if available. "
50
            "Enable this if answers are independent of the event the question is used for."
51
        ),
52
        default=False,
53
    )
54

55
    class Meta:
1✔
56
        verbose_name = _("Question")
1✔
57
        verbose_name_plural = _("Questions")
1✔
58

59
    def __str__(self):
1✔
60
        return str(self.name)
1✔
61

62
    def can_delete(self):
1✔
63
        return not self.questionnaire_set.exists() and not self.answer_set.exists()
1✔
64

65
    def _get_field_classes(self):
1✔
66
        match self.type:
1✔
67
            case self.Type.TEXT:
1✔
68
                return forms.CharField, serializers.CharField
1✔
69
            case self.Type.SINGLE:
1!
70
                return forms.ChoiceField, serializers.ChoiceField
1✔
71
            case self.Type.MULTIPLE:
×
72
                return forms.MultipleChoiceField, serializers.MultipleChoiceField
×
73

74
    def _get_field_kwargs(self):
1✔
75
        form_kwargs = {}
1✔
76
        serializer_kwargs = {}
1✔
77

78
        match self.type:
1✔
79
            case self.Type.TEXT:
1✔
80
                max_length = 100
1✔
81
                form_kwargs["max_length"] = max_length
1✔
82
                serializer_kwargs["max_length"] = max_length
1✔
83
            case self.Type.SINGLE:
1!
84
                form_kwargs["widget"] = (
1✔
85
                    forms.RadioSelect if len(self.choices) <= 5 else Select2Widget
86
                )
87
            case self.Type.MULTIPLE:
×
88
                form_kwargs["widget"] = Select2MultipleWidget
×
89

90
        if self.type != self.Type.TEXT:
1✔
91
            assert isinstance(
1✔
92
                self.choices, list
93
            ), f"The choices of question {self.name} are not a list"
94
            # We're intentionally using the plain `choice` as choice key and don't convert it into a slug or similar.
95
            # This is uncommon but seems to be the best solution with choices from (potentially changing) user input
96
            # pylint: disable=not-an-iterable
97
            choices = [(choice, choice) for choice in self.choices]
1✔
98
            form_kwargs["choices"] = choices
1✔
99
            serializer_kwargs["choices"] = choices
1✔
100

101
        # show an icon in forms indicating the answer can be saved
102
        if self.use_saved_answers:
1✔
103
            form_kwargs["help_text"] = format_html(
1✔
104
                (
105
                    '{description}<br/><i class="fas fa-info-circle"></i> {infotext}'
106
                    if self.description
107
                    else '<i class="fas fa-info-circle"></i> {infotext}'
108
                ),
109
                description=self.description,
110
                infotext=_("Answer can be saved to your profile."),
111
            )
112

113
        return form_kwargs, serializer_kwargs
1✔
114

115
    def _get_initial_answer(
1✔
116
        self, participant: AbstractParticipant, participation: AbstractParticipation
117
    ):
118
        """
119
        Restores answer from participation (when editing) or the user's saved answers (if local participant)
120
        """
121
        initial = None
1✔
122
        if existing_answer := Answer.objects.filter(
1!
123
            participation_id=participation.pk, question=self
124
        ).first():
125
            initial = existing_answer.answer
×
126
        elif self.use_saved_answers and (
1✔
127
            saved_answer := (
128
                SavedAnswer.objects.filter(user=participant.user, question=self).first()
129
                if isinstance(participant, LocalUserParticipant)
130
                else None
131
            )
132
        ):
133
            initial = saved_answer.answer
1✔
134

135
        # Reset answer it does not match the question type
136
        match self.type:
1✔
137
            case self.Type.TEXT:
1✔
138
                if isinstance(initial, list):
1!
139
                    # Convert multiple answers if the question was a multiple choice question before
140
                    initial = ", ".join(initial)
×
141
            case self.Type.SINGLE:
1!
142
                if isinstance(initial, list):
1!
143
                    # Reset answer if the question was a multiple choice question before
144
                    if len(initial) == 1:
×
145
                        initial = initial[0]
×
146
                    else:
147
                        initial = None
×
148

149
                if initial not in self.choices:
1!
150
                    # Reset answer if the option does not exist any more
151
                    initial = None
1✔
152
            case self.Type.MULTIPLE:
×
153
                if isinstance(initial, str):
×
154
                    # Convert single answer to multi-answer
155
                    initial = [initial]
×
156

157
                if isinstance(initial, list):
×
158
                    # Remove invalid answers if answers are set
159
                    initial = [answer for answer in initial if answer in self.choices]
×
160

161
        return initial
1✔
162

163
    def get_signup_form_field(
1✔
164
        self, participant: AbstractParticipant, participation: AbstractParticipation
165
    ):
166
        initial = self._get_initial_answer(participant, participation)
1✔
167

168
        field_name = self.get_form_slug()
1✔
169

170
        field_classes = self._get_field_classes()
1✔
171
        field_kwargs = self._get_field_kwargs()
1✔
172

173
        field = {
1✔
174
            "label": escape(self.question_text),
175
            "help_text": escape(self.description),
176
            "default": initial,
177
            "required": self.required,
178
            "form_class": field_classes[0],
179
            "form_kwargs": {
180
                **field_kwargs[0],
181
            },
182
            "serializer_class": field_classes[1],
183
            "serializer_kwargs": {
184
                **field_kwargs[1],
185
            },
186
        }
187

188
        return field_name, field
1✔
189

190
    def get_saved_answer_form_field(self):
1✔
191
        field_class = self._get_field_classes()[0]
1✔
192
        field_kwargs = self._get_field_kwargs()[0]
1✔
193

194
        field_kwargs["label"] = _("Answer")
1✔
195

196
        return field_class(**field_kwargs)
1✔
197

198
    def get_form_slug(self):
1✔
199
        return "questionnaires_" + slugify(f"{self.pk} {self.name}")
1✔
200

201
    @staticmethod
1✔
202
    def get_pk_from_slug(slug: str):
1✔
203
        """
204
        Returns the questions' pk from a form field slug  generated by `Question.get_form_slug`.
205
        These slugs have the format `questionnaires_123-demo-question`.
206

207
        For this slug, this method would return `123`.
208
        """
209
        return int(slug.split("_", maxsplit=1)[1].split("-")[0])
1✔
210

211

212
@log(
1✔
213
    ModelFieldsLogConfig(attach_to_func=lambda questionnaire: (Shift, questionnaire.shift_id)),
214
)
215
class Questionnaire(models.Model):
1✔
216
    shift = models.OneToOneField(Shift, on_delete=models.CASCADE)
1✔
217
    questions = models.ManyToManyField(Question, blank=True)
1✔
218

219
    class Meta:
1✔
220
        verbose_name = _("Questionnaire")
1✔
221
        verbose_name_plural = _("Questionnaires")
1✔
222

223
    def __str__(self):
1✔
224
        return f'{", ".join(self.questions.values_list("name", flat=True))} @ {self.shift}'
1✔
225

226

227
def answer_text(answer):
1✔
228
    return ", ".join(answer) if isinstance(answer, list) else answer
1✔
229

230

231
@log(
1✔
232
    ModelFieldsLogConfig(
233
        attach_to_func=lambda answer: (AbstractParticipation, answer.participation_id)
234
    ),
235
)
236
class Answer(models.Model):
1✔
237
    participation = models.ForeignKey(AbstractParticipation, on_delete=models.CASCADE)
1✔
238
    question = models.ForeignKey(Question, on_delete=models.PROTECT)
1✔
239
    answer = models.JSONField(verbose_name=_("Answer"))
1✔
240

241
    class Meta:
1✔
242
        verbose_name = _("Answer")
1✔
243
        verbose_name_plural = _("Answers")
1✔
244

245
    def __str__(self):
1✔
246
        return f'{self.question}: "{self.answer}" ({self.participation})'
1✔
247

248
    @property
1✔
249
    def answer_text(self):
1✔
250
        return answer_text(self.answer)
1✔
251

252

253
@dont_log
1✔
254
class SavedAnswer(models.Model):
1✔
255
    user = models.ForeignKey(UserProfile, on_delete=models.CASCADE)
1✔
256
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
1✔
257
    answer = models.JSONField(verbose_name=_("Answer"))
1✔
258

259
    class Meta:
1✔
260
        verbose_name = _("Saved answer")
1✔
261
        verbose_name_plural = _("Saved answers")
1✔
262

263
    def __str__(self):
1✔
264
        return f'{self.question}: "{self.answer}" ({self.user})'
×
265

266
    @property
1✔
267
    def answer_text(self):
1✔
268
        return answer_text(self.answer)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc