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

d120 / pyfeedback / 25237229969

01 May 2026 11:14PM UTC coverage: 88.833% (-0.8%) from 89.664%
25237229969

push

github

4-dash
minor fiexs

2991 of 3367 relevant lines covered (88.83%)

0.89 hits per line

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

88.54
/src/feedback/models/base.py
1
# coding=utf-8
2

3

4
import random
1✔
5

6
from django.db import models
1✔
7
from django.utils.translation import gettext_lazy as _
1✔
8
from django.db.utils import OperationalError
1✔
9
from django.urls import reverse
1✔
10
from django.core.exceptions import ValidationError
1✔
11
from django.core.validators import validate_email
1✔
12
from django.db.models import Q
1✔
13

14
from feedback.tools import ean_checksum_calc, ean_checksum_valid
1✔
15
from django.contrib.auth.models import User
1✔
16
from django.contrib.auth.hashers import make_password, check_password
1✔
17

18
from django.utils import formats, timezone
1✔
19

20
import datetime
1✔
21
import uuid
1✔
22
import string
1✔
23
import secrets
1✔
24

25

26
class Semester(models.Model):
1✔
27
    """Repräsentiert ein Semester der TUD."""
28
    FRAGEBOGEN_CHOICES = (
1✔
29
        ('2008', 'Fragebogen 2008'),
30
        ('2009', 'Fragebogen 2009'),
31
        ('2012', 'Fragebogen 2012'),
32
        ('2016', 'Fragebogen 2016'),
33
        ('2020', 'Fragebogen 2020'),
34
        ('2025', 'Fragebogen 2025'),
35
    )
36
    SICHTBARKEIT_CHOICES = (
1✔
37
        ('ADM', _('Administratoren')),
38
        ('VER', _('Veranstalter')),
39
        ('ALL', _('alle (öffentlich)')),
40
    )
41

42
    semester = models.IntegerField(help_text='Aufbau: YYYYS, wobei YYYY = Jahreszahl und S = Semester (0=SS, 5=WS).',
1✔
43
                                   unique=True)
44
    fragebogen = models.CharField(max_length=5, choices=FRAGEBOGEN_CHOICES, verbose_name=_("Fragebogen"),
1✔
45
                                  help_text=_('Verwendete Version des Fragebogens.'))
46
    sichtbarkeit = models.CharField(max_length=3, choices=SICHTBARKEIT_CHOICES, verbose_name=_("Sichtbarkeit"),
1✔
47
                                    help_text=_('Sichtbarkeit der Evaluationsergebnisse.<br /><em>{s1}:</em> nur für Mitglieder des Feedback-Teams<br /><em>{s2}:</em> Veranstalter und Mitglieder des Feedback-Teams<br /><em>{s3}:</em> alle (beschränkt auf das Uninetz)<br />').format(s1=SICHTBARKEIT_CHOICES[0][1],s2=SICHTBARKEIT_CHOICES[1][1],s3=SICHTBARKEIT_CHOICES[2][1])
48
                                    )
49
    vollerhebung = models.BooleanField(default=False, verbose_name=_("Vollerhebung"))
1✔
50
    standard_ergebnisversand = models.DateField(null=True, blank=True, verbose_name=_('Ergebnisversand'), help_text=_('Standarddatum für den Ergebnisversand'))
1✔
51

52
    def _format_generic(self, ss, ws, space, modulus):
1✔
53
        sem = self.semester // 10
1✔
54
        if (modulus > 0):
1✔
55
            sem = sem % modulus
1✔
56

57
        if self.semester % 10 == 0:
1✔
58
            return '%s%s%d' % (ss, space, sem)
1✔
59
        else:
60
            return '%s%s%d/%d' % (ws, space, sem, sem + 1)
1✔
61

62
    def _format(self, ss, ws):
1✔
63
        return self._format_generic(ss, ws, ' ', 0)
1✔
64

65
    def short(self):
1✔
66
        return self._format('SS', 'WS')
1✔
67

68
    def long(self):
1✔
69
        return self._format('Sommersemester', 'Wintersemester')
1✔
70

71
    def evasys(self):
1✔
72
        return self._format_generic('SS', 'WS', '', 100)
1✔
73

74
    def last_Auswertungstermin(self):
1✔
75
        """Letzter Tag der als Auswertungstermin angegeben werden kann"""
76
        year = 0
1✔
77
        month = 0
1✔
78
        day = 15
1✔
79
        # Sommersemester
80
        if self.semester % 10 == 0:
1✔
81
            year = self.semester / 10
1✔
82
            month = 10
1✔
83
        # Wintersemester
84
        else:
85
            year = (self.semester / 10) + 1
×
86
            month = 4
×
87

88
        return datetime.datetime(int(year), int(month), int(day))
1✔
89

90
    def last_Auswertungstermin_to_late_human(self):
1✔
91
        """Der erste Tag der nach dem letzten Auswertungstermin liegt formatiert"""
92
        toLateDate = self.last_Auswertungstermin() + datetime.timedelta(days=1)
×
93
        return formats.date_format(toLateDate, 'DATE_FORMAT')
×
94

95
    def auswertungstermin_years(self):
1✔
96
        """Die Jahre in denen der Auswertungstermin liegen kann"""
97
        return self.last_Auswertungstermin().year,
×
98

99
    def __str__(self):
1✔
100
        return self.long()
1✔
101

102
    class Meta:
1✔
103
        verbose_name = 'Semester'
1✔
104
        verbose_name_plural = 'Semester'
1✔
105
        ordering = ['-semester']
1✔
106
        app_label = 'feedback'
1✔
107

108
    @staticmethod
1✔
109
    def current():
1✔
110
        try:
1✔
111
            return Semester.objects.order_by('-semester')[0]
1✔
112
        except IndexError:
1✔
113
            return None
1✔
114
        except OperationalError:
1✔
115
            return None
1✔
116

117

118
class Fachgebiet(models.Model):
1✔
119
    """Repräsentiert ein Fachgebiet für das FB20"""
120
    name = models.CharField(max_length=80)
1✔
121
    kuerzel = models.CharField(max_length=10)
1✔
122

123
    @staticmethod
1✔
124
    def get_fachgebiet_from_email(email):
1✔
125
        """
126
        Gibt ein Fachgebiet anhand einer E-Mail Adresse zurück
127
        :param email: E-Mail String
128
        :return: Fachgebiet
129
        """
130
        try:
1✔
131
            suffix = email.split('@')[-1]
1✔
132
            return EmailEndung.objects.get(domain=suffix).fachgebiet
1✔
133
        except Exception:
1✔
134
            return None
1✔
135

136
    def __str__(self):
1✔
137
        return self.name
1✔
138

139
    class Meta:
1✔
140
        verbose_name = _('Fachgebiet')
1✔
141
        verbose_name_plural = _('Fachgebiete')
1✔
142
        app_label = 'feedback'
1✔
143

144

145
class EmailEndung(models.Model):
1✔
146
    """Repräsentiert alle Domains die für E-Mails von Veranstaltern verwendet werden"""
147
    fachgebiet = models.ForeignKey(Fachgebiet,
1✔
148
                                   blank=True,
149
                                   verbose_name=_("Fachgebiet"),
150
                                   help_text=_("Hier soll der Domainname einer Email-Adresse eines Fachgebiets stehen."),
151
                                   on_delete=models.CASCADE)
152
    domain = models.CharField(max_length=150,
1✔
153
                              null=True)
154

155
    def __str__(self):
1✔
156
        return self.domain
×
157

158
    class Meta:
1✔
159
        verbose_name = _('Fachgebiet Emailendung')
1✔
160
        verbose_name_plural = _('Fachgebiet Emailendungen')
1✔
161
        app_label = 'feedback'
1✔
162

163

164
class FachgebietEmail(models.Model):
1✔
165
    """Repräsentiert die E-Mail Domänen für die jeweiligen Fachgebiete des FBs 20."""
166
    fachgebiet = models.ForeignKey(Fachgebiet, related_name='fachgebiet', verbose_name=_("Fachgebiet"), on_delete=models.CASCADE)
1✔
167
    email_sekretaerin = models.EmailField(blank=True)
1✔
168

169
    class Meta:
1✔
170
        verbose_name = _('Fachgebiet Email')
1✔
171
        verbose_name_plural = _('Fachgebiet Emails')
1✔
172
        app_label = 'feedback'
1✔
173

174

175
class Person(models.Model):
1✔
176
    """Repräsentiert eine Person der TUD aus dem FB20."""
177
    GESCHLECHT_CHOICES = (
1✔
178
        ('', ''),
179
        ('m', _('Herr')),
180
        ('w', _('Frau')),
181
    )
182

183
    GESCHLECHT_EVASYS_XML = {
1✔
184
        '': '',
185
        'm': 'm',
186
        'w': 'f',
187
    }
188

189
    geschlecht = models.CharField(max_length=1, choices=GESCHLECHT_CHOICES, blank=True, verbose_name=_('Anrede'))
1✔
190
    vorname = models.CharField(_('first name'), max_length=30, blank=True)
1✔
191
    nachname = models.CharField(_('last name'), max_length=30, blank=True)
1✔
192
    email = models.EmailField(_('E-Mail'), blank=True)
1✔
193
    anschrift = models.CharField(_('anschrift'), max_length=80, blank=True,
1✔
194
                                 help_text=_('Tragen Sie bitte nur die Anschrift ohne Namen ein, '
195
                                           'da der Name automatisch hinzugefügt wird.'))
196
    fachgebiet = models.ForeignKey(Fachgebiet, verbose_name=_("Fachgebiet"), null=True, blank=True, on_delete=models.CASCADE)
1✔
197

198
    def full_name(self):
1✔
199
        return '%s %s' % (self.vorname, self.nachname)
1✔
200

201
    def get_evasys_key(self):
1✔
202
        return "pe-%s" % self.id
1✔
203

204
    def get_evasys_geschlecht(self):
1✔
205
        return Person.GESCHLECHT_EVASYS_XML[self.geschlecht]
1✔
206

207
    def __str__(self):
1✔
208
        return '%s, %s' % (self.nachname, self.vorname)
1✔
209

210
    def printable(self) -> bool:
1✔
211
        """ Check whether the user has all required fields for printing (e.g. for generating the stickers)
212
        """
213
        return self.vorname != "" and self.nachname != "" and self.email != "" and self.anschrift != ""
1✔
214

215
    class Meta:
1✔
216
        verbose_name = _('Person')
1✔
217
        verbose_name_plural = _('Personen')
1✔
218
        ordering = 'nachname', 'vorname'
1✔
219
        app_label = 'feedback'
1✔
220

221
    @staticmethod
1✔
222
    def create_from_import_person(ip):
1✔
223
        """
224
        Erstellt Personen aus dem Import
225
        :param ip: die importierte Person
226
        :return: Person
227
        """
228
        # Prüfen, ob Benutzer existiert
229
        try:
1✔
230
            return Person.objects.filter(vorname=ip.vorname, nachname=ip.nachname)[0]
1✔
231
        except (Person.DoesNotExist, IndexError):
1✔
232
            try:
1✔
233
                return AlternativVorname.objects.filter(vorname=ip.vorname).get().person
1✔
234
            except (AlternativVorname.DoesNotExist, IndexError):
1✔
235
                return Person.objects.create(vorname=ip.vorname, nachname=ip.nachname)
1✔
236

237
    @staticmethod
1✔
238
    def persons_to_edit(semester=None):
1✔
239
        """
240
        Gibt die Personen zurück, die noch bearbeitet werden müssen.
241
        :param semester: bei None, das aktuelle Semester, ansonsten das Angegebene.
242
        :return: Person
243
        """
244
        if semester is None:
1✔
245
            semester = Semester.current()
1✔
246
        return Person.objects.filter(Q(geschlecht='') | Q(email=''), veranstaltung__semester=semester)\
1✔
247
            .order_by('id').distinct()
248

249
    @staticmethod
1✔
250
    def all_edited_persons():
1✔
251
        """
252
        Gibt alle Personen zurück, die schon bearbeitet wurden.
253
        :return: Person
254
        """
255
        return Person.objects.filter(~Q(geschlecht='') & ~Q(email='')).order_by('id').distinct()
1✔
256

257
    @staticmethod
1✔
258
    def persons_with_similar_names(vorname, nachname):
1✔
259
        """
260
        Gibt alle Personen zurück, die sich im Namen ähneln.
261
        :param vorname: String
262
        :param nachname: String
263
        :return: Person
264
        """
265
        return Person.all_edited_persons().filter(vorname__startswith=vorname, nachname=nachname)
1✔
266

267
    @staticmethod
1✔
268
    def veranstaltungen(person):
1✔
269
        """
270
        Gibt die Veranstaltungen einer Person zurück.
271
        :param person: Person
272
        :return: Veranstaltung
273
        """
274
        return Veranstaltung.objects.filter(veranstalter=person)
1✔
275

276
    @staticmethod
1✔
277
    def replace_veranstalter(new, old):
1✔
278
        """
279
        Ersetzt einen "alten" Veranstalter durch einen "neuen".
280
        Zum Beispiel wenn Dozenten sich beim Namen ähneln und dementsprechend identisch sind.
281
        Wenn dies der Fall ist, wird ersetzt und ein AlternativVorname erzeugt, der sich die ähnlichen Namen merkt.
282
        :param new: die neue Person
283
        :param old: die alte Person
284
        """
285
        veranstaltungen = Person.veranstaltungen(new)
1✔
286

287
        # replace every lecture held by 'new' with 'old'
288
        for v in veranstaltungen:
1✔
289
            v.veranstalter.set([old])
1✔
290
            v.save()
1✔
291

292
        # save the second name from 'new' as the alternative first name of 'old'
293
        av = AlternativVorname.objects.create(vorname=new.vorname, person=old)
1✔
294
        av.save()
1✔
295

296
    @staticmethod
1✔
297
    def is_veranstalter(person):
1✔
298
        """
299
        prüft, ob eine Person ein Veranstalter ist.
300
        :param person: Person
301
        :return: True, wenn Veranstalter, ansonsten False
302
        """
303
        return Person.veranstaltungen(person).count() > 0
1✔
304

305

306
class AlternativVorname(models.Model):
1✔
307
    """Repräsentiert einen alternativen Vornamen für eine Person."""
308
    vorname = models.CharField(_('first name'), max_length=30, blank=True)
1✔
309
    person = models.ForeignKey(Person, on_delete=models.CASCADE)
1✔
310

311

312
class Veranstaltung(models.Model):
1✔
313
    """Repräsentiert eine Veranstaltung der TUD."""
314
    TYP_CHOICES = (
1✔
315
        ('v', _('Vorlesung')),
316
        ('vu', _('Vorlesung mit Übung')),
317
        ('pr', _('Praktikum')),
318
        ('se', _('Seminar')),
319
    )
320

321
    SPRACHE_CHOICES = (
1✔
322
        ('de', _('Deutsch')),
323
        ('en', _('Englisch')),
324
    )
325
    # 0    undefiniert
326
    # 1    Vorlesung
327
    # 2    Seminar
328
    # 3    Proseminar
329
    # 4    Übung
330
    # 5    Praktikum
331
    # 6    Tutorium
332
    # 7    Sonstige
333
    # 8    Projekt
334
    # 9    Vorlesung + Übung
335
    # 10    Ringvorlesung
336
    # 11    Vorlesung+Praktikum
337
    VORLESUNGSTYP = {
1✔
338
        'v': 1,
339
        'vu': 9,
340
        'pr': 5,
341
        'se': 2,
342
    }
343

344
    VORLESUNGSTYP_XML = {
1✔
345
        'v': 'Vorlesung',
346
        'vu': 'Vorlesung + Übung',
347
        'pr': 'Praktikum',
348
        'se': 'Seminar',
349
    }
350

351
    # Bögen 2015
352

353
    # Deutsch
354
    # PD FB20Pv1 2677
355
    # SD FB20Sv2 2681
356
    # ÜD FB20Üv1 2675
357
    # VD FB20Vv1 2679
358

359
    # Englisch
360
    # PE FB20Pv1e 2704
361
    # SE FB20Sv1e 2702
362
    # ÜE FB20Üv1e 2700
363
    # VE FB20Vv1e 2698
364

365
    EVASYS_BOGENKENNUNG_DE = {
1✔
366
        'pr': 'FB20Prd1',
367
        'se': 'FB20Sed1',
368
        'u': 'FB20Ud1',
369
        'v': 'FB20VLd105',
370
        'vu': 'FB20VLd105',
371
    }
372

373
    EVASYS_BOGENKENNUNG_EN = {
1✔
374
        'pr': 'FB20Prd1',
375
        'se': 'FB20Sed1',
376
        'u': 'FB20Ud1',
377
        'v': 'FB20VLd105',
378
        'vu': 'FB20VLd105',
379
    }
380

381
    BARCODE_BASE = 2 * 10 ** 11
1✔
382

383
    # Vorlesungsstatus
384
    STATUS_ANGELEGT = 100
1✔
385
    STATUS_BESTELLUNG_GEOEFFNET = 200
1✔
386
    STATUS_KEINE_EVALUATION = 300
1✔
387
    STATUS_KEINE_EVALUATION_FINAL = 310
1✔
388
    STATUS_BESTELLUNG_LIEGT_VOR = 500
1✔
389
    STATUS_BESTELLUNG_WIRD_VERARBEITET = 510
1✔
390
    STATUS_GEDRUCKT = 600
1✔
391
    STATUS_VERSANDT = 700
1✔
392
    STATUS_BOEGEN_EINGEGANGEN = 800
1✔
393
    STATUS_BOEGEN_GESCANNT = 900
1✔
394
    STATUS_ERGEBNISSE_VERSANDT = 1000
1✔
395

396
    STATUS_CHOICES = (
1✔
397
        (STATUS_ANGELEGT, _('Angelegt')),
398
        (STATUS_BESTELLUNG_GEOEFFNET, _('Bestellung geöffnet')),
399
        (STATUS_KEINE_EVALUATION, _('Keine Evaluation')),
400
        (STATUS_KEINE_EVALUATION_FINAL, _('Keine Evaluation final')),
401
        (STATUS_BESTELLUNG_LIEGT_VOR, _('Bestellung liegt vor')),
402
        (STATUS_BESTELLUNG_WIRD_VERARBEITET, _('Bestellung wird verarbeitet')),
403
        (STATUS_GEDRUCKT, _('Gedruckt')),
404
        (STATUS_VERSANDT, _('Versandt')),
405
        (STATUS_BOEGEN_EINGEGANGEN, _('Bögen eingegangen')),
406
        (STATUS_BOEGEN_GESCANNT, _('Bögen gescannt')),
407
        (STATUS_ERGEBNISSE_VERSANDT, _('Ergebnisse versandt')),
408
    )
409

410
    BOOL_CHOICES = (
1✔
411
        (True, _('Ja')),
412
        (False, _('Nein')),
413
    )
414

415
    # TODO: not the final version of status transition
416
    STATUS_UEBERGANG = {
1✔
417
        STATUS_ANGELEGT: (STATUS_GEDRUCKT, STATUS_BESTELLUNG_GEOEFFNET),
418
        STATUS_BESTELLUNG_GEOEFFNET: (STATUS_KEINE_EVALUATION, STATUS_BESTELLUNG_LIEGT_VOR),
419
        STATUS_KEINE_EVALUATION: (STATUS_BESTELLUNG_LIEGT_VOR,),
420
        STATUS_BESTELLUNG_WIRD_VERARBEITET: (STATUS_GEDRUCKT,),
421
        STATUS_BESTELLUNG_LIEGT_VOR: (STATUS_GEDRUCKT, STATUS_BESTELLUNG_LIEGT_VOR, STATUS_KEINE_EVALUATION),
422
        STATUS_GEDRUCKT: (STATUS_VERSANDT,),
423
        STATUS_VERSANDT: (STATUS_BOEGEN_EINGEGANGEN,),
424
        STATUS_BOEGEN_EINGEGANGEN: (STATUS_BOEGEN_GESCANNT,),
425
        STATUS_BOEGEN_GESCANNT: (STATUS_ERGEBNISSE_VERSANDT,)
426
    }
427

428
    DIGITALE_EVAL = [
1✔
429
        ('T', 'TANs'),
430
        ('L', 'Losung'),
431
    ]
432

433
    MIN_BESTELLUNG_ANZAHL = 6
1✔
434

435
    # Helfertext für Dozenten für den Veranstaltungstyp.
436
    vlNoEx = _('Wenn Ihre Vorlesung keine Übung hat wählen Sie bitte <i>Vorlesung</i> aus')
1✔
437

438
    typ = models.CharField(verbose_name=_("Typ"), max_length=2, choices=TYP_CHOICES, help_text=vlNoEx)
1✔
439
    name = models.CharField(verbose_name=_("Name"),max_length=150)
1✔
440
    semester = models.ForeignKey(Semester, on_delete=models.CASCADE)
1✔
441
    lv_nr = models.CharField(max_length=15, blank=True, verbose_name=_('LV-Nummer'))
1✔
442
    status = models.IntegerField(choices=STATUS_CHOICES, default=STATUS_ANGELEGT)
1✔
443
    grundstudium = models.BooleanField(verbose_name=_("Grundstudium"))
1✔
444
    evaluieren = models.BooleanField(verbose_name=_("Evaluieren"), choices=BOOL_CHOICES, default=True)
1✔
445
    veranstalter = models.ManyToManyField(Person, verbose_name=_("Veranstalter"), blank=True,
1✔
446
                                          help_text=_('Alle Personen, die mit der Veranstaltung befasst sind und z.B. Fragebögen bestellen können sollen.'))
447

448
    sprache = models.CharField(max_length=2, choices=SPRACHE_CHOICES, null=True, blank=True, verbose_name=_("Sprache"))
1✔
449
    anzahl = models.IntegerField(verbose_name=_("Anzahl"), null=True, blank=True)
1✔
450
    verantwortlich = models.ForeignKey(Person, related_name='verantwortlich', verbose_name=_("Verantwortlich"), null=True, blank=True, on_delete=models.CASCADE,
1✔
451
                                       help_text=_('Diese Person wird von uns bei Rückfragen kontaktiert und bekommt die Fragenbögen zugeschickt'))
452
    ergebnis_empfaenger = models.ManyToManyField(Person, blank=True,
1✔
453
                                                 related_name='ergebnis_empfaenger',
454
                                                 verbose_name=_('Empfänger der Ergebnisse'),
455
                                                 help_text=_('An diese Personen werden die Ergebnisse per E-Mail geschickt.'))
456
    primaerdozent = models.ForeignKey(Person, related_name='primaerdozent', verbose_name=_("Primaerdozent"), null=True, blank=True, on_delete=models.CASCADE,
1✔
457
                                      help_text=_('Die Person, die im Anschreiben erwähnt wird'))
458
    auswertungstermin = models.DateField(null=True, blank=True,
1✔
459
                                         verbose_name=_('Auswertungstermin'),
460
                                         help_text=_('An welchem Tag sollen Fragebögen für diese Veranstaltung ausgewerter werden? ') +
461
                                                   _('Fragebögen die danach eintreffen werden nicht mehr ausgewertet.'))
462
    bestelldatum = models.DateField(null=True, blank=True)
1✔
463
    access_token = models.CharField(max_length=16, blank=True)
1✔
464
    freiefrage1 = models.TextField(verbose_name=_('1. Freie Frage'), blank=True)
1✔
465
    freiefrage2 = models.TextField(verbose_name=_('2. Freie Frage'), blank=True)
1✔
466
    kleingruppen = models.TextField(verbose_name=_('Kleingruppen'), blank=True)
1✔
467
    veroeffentlichen = models.BooleanField(verbose_name=_('Veroeffentlichen'), default=True, choices=BOOL_CHOICES)
1✔
468
    digitale_eval = models.BooleanField(default=True, verbose_name=_('Digitale Evaluation'),
1✔
469
                                        help_text=_('Die Evaluation soll digital durchgeführt werden. Die Studierenden füllen die Evaluation online aus.'), blank=True)
470
    digitale_eval_type = models.CharField(
1✔
471
        default='T',
472
        choices=DIGITALE_EVAL,
473
        max_length=1,
474
        verbose_name=_('Digitaler Evaluationstyp'),
475
        help_text=_('Es werden generell zwei Typen von Verteilungsmethoden angeboten: Bei TANs erhalten Sie eine Excel Datei mit einer Liste aller TANs, welche Sie beispielsweise mithilfe von moodle verteilen können (eine Anleitung dazu wird bereitgestellt). Beim losungsbasierten Verfahren erhalten Sie einen einfachen, mehrfachbenutzbaren Link zum Onlinefragebogen.')
476
    )
477

478
    def get_next_state(self):
1✔
479
        """
480
        Gibt den nächsten Status einer Veranstaltung zurück.
481
        :return: Status einer Veranstaltung
482
        """
483
        try:
1✔
484
            return self.STATUS_UEBERGANG[self.status][0]  # TODO: Sobald es mehrere Zustande gibt
1✔
485
        except KeyError:
1✔
486
            return None
1✔
487

488
    def set_next_state(self):
1✔
489
        """Setzt den nächsten Status einer Veranstaltung."""
490
        status = self.STATUS_UEBERGANG[self.status]
1✔
491

492
        if self.status == self.STATUS_BESTELLUNG_GEOEFFNET:
1✔
493
            if self.evaluieren:
1✔
494
                self.status = status[1]
1✔
495
            else:
496
                self.status = status[0]
1✔
497

498
        elif self.status == self.STATUS_BESTELLUNG_LIEGT_VOR:
1✔
499
            if self.evaluieren:
1✔
500
                self.status = status[1]
1✔
501
            else:
502
                self.status = status[2]
1✔
503

504
        else:
505
            self.status = status[0]
1✔
506

507
    def get_evasys_typ(self):
1✔
508
        return Veranstaltung.VORLESUNGSTYP[self.typ]
1✔
509

510
    def get_evasys_typ_xml(self):
1✔
511
        return Veranstaltung.VORLESUNGSTYP_XML[self.typ]
1✔
512

513
    def get_evasys_key(self):
1✔
514
        return 'lv-%s' % self.id
1✔
515

516
    def get_evasys_kennung(self):
1✔
517
        return "%s-%s" % (self.lv_nr, self.semester.evasys())
1✔
518

519
    def get_evasys_survery_key(self):
1✔
520
        return 'su-%s' % self.id
1✔
521

522
    def get_evasys_survery_key_uebung(self):
1✔
523
        return 'su-%s-u' % self.id
1✔
524

525
    # FIXME: bogen name sollte nicht statisch sein!
526
    def get_evasys_bogen(self):
1✔
527
        if self.sprache == 'de':
1✔
528
            return Veranstaltung.EVASYS_BOGENKENNUNG_DE[self.typ]
1✔
529
        elif self.sprache == 'en':
1✔
530
            return Veranstaltung.EVASYS_BOGENKENNUNG_EN[self.typ]
1✔
531
        else:
532
            return ''
1✔
533

534
    def get_evasys_bogen_uebung(self):
1✔
535
        """Kennung für den Übungsfragebogen"""
536
        if self.typ == 'vu':
1✔
537
            if self.sprache == 'de':
1✔
538
                return Veranstaltung.EVASYS_BOGENKENNUNG_DE['u']
1✔
539
            elif self.sprache == 'en':
×
540
                return Veranstaltung.EVASYS_BOGENKENNUNG_EN['u']
×
541

542
        return ''
×
543

544
    def get_evasys_umfragetyp(self):
1✔
545
        """Deckblatt oder Selbstdruck verfahren"""
546
        result = 'coversheet'  # Deckblatt verfahren
1✔
547
        if self.typ in ('se', 'pr'):
1✔
548
            result = 'hardcopy'  # Selbstdruck verfahren
×
549
        if self.digitale_eval:
1✔
550
            if self.digitale_eval_type == 'L':
1✔
551
                result = 'codeword'
×
552
            else:
553
                result = 'online'
1✔
554
        return result
1✔
555

556
    def get_barcode_number(self, tutorgruppe=0):
1✔
557
        """Barcode Nummer für diese Veranstaltung"""
558
        if tutorgruppe > 99:
1✔
559
            raise ValueError(_("Tutorgruppe muss kleiner 100 sein"))
×
560

561
        if isinstance(tutorgruppe, int) == False:
1✔
562
            raise ValueError(_("Tutorgruppe muss eine ganze Zahl sein"))
×
563

564
        base = Veranstaltung.BARCODE_BASE
1✔
565
        veranst = self.pk
1✔
566
        code_draft = base + (veranst * 100) + tutorgruppe
1✔
567
        checksum = ean_checksum_calc(code_draft)
1✔
568

569
        code = (code_draft * 10) + checksum
1✔
570

571
        return code
1✔
572

573
    def get_evasys_list_veranstalter(self):
1✔
574
        personen = []
1✔
575
        if self.primaerdozent is not None:
1✔
576
            personen.append(self.primaerdozent)
1✔
577
        for per in self.ergebnis_empfaenger.all():
1✔
578
            if self.primaerdozent is None or per.pk != self.primaerdozent.pk:
1✔
579
                personen.append(per)
1✔
580
        return personen
1✔
581

582
    @staticmethod
1✔
583
    def decode_barcode(barcode):
1✔
584
        if (ean_checksum_valid(barcode) != True):
1✔
585
            raise ValueError(_("Der Barcode ist nicht valide"))
1✔
586

587
        # entferne das Padding am Anfang
588
        information = barcode % Veranstaltung.BARCODE_BASE
1✔
589

590
        # entferne die checksumme
591
        information = information // 10
1✔
592

593
        # die letzten zwei Stellen sind die Uebungsgruppe
594
        tutorgroup = information % 100
1✔
595

596
        # Alle Stellen vor der Uebungsgruppe sind der PK der Veranstaltung
597
        veranstaltung = information // 100
1✔
598

599
        return {'veranstaltung': veranstaltung, 'tutorgroup': tutorgroup}
1✔
600

601
    def __str__(self):
1✔
602
        return "%s [%s] (%s)" % (self.name, self.typ, self.semester.short())
1✔
603

604
    def create_log(self, user, scanner, interface):
1✔
605
        """
606
        Erstellt einen Log wenn sich bei einer Veranstaltung etwas geändert hat.
607
        :param user: Über welche Benutzer die Änderung erfolgt ist.
608
        :param scanner: Über welchen Barcodescanner die Änderung erfolgt ist.
609
        :param interface: Über welches Interface die Änderung erfolgt ist.
610
        """
611
        Log.objects.create(veranstaltung=self, user=user, scanner=scanner, status=self.status, interface=interface)
1✔
612

613
    def log(self, interface, is_frontend=False):
1✔
614
        """
615
        Die Logging-Funktion
616
        :param interface: Über welches Interface die Änderung erfolgt ist.
617
        :param is_frontend: Checkt, ob die Änderung über das Frontend erfolgt ist.
618
        """
619
        if isinstance(interface, BarcodeScanner):
1✔
620
            self.create_log(None, interface, Log.SCANNER)
1✔
621
        elif isinstance(interface, User):
1✔
622
            if is_frontend:
1✔
623
                self.create_log(interface, None, Log.FRONTEND)
1✔
624
            else:
625
                self.create_log(interface, None, Log.ADMIN)
1✔
626

627
    def auwertungstermin_to_late_msg(self):
1✔
628
        toLateDate = self.semester.last_Auswertungstermin_to_late_human()
×
629
        return _('Der Auswertungstermin muss vor dem {toLateDate} liegen.').format(toLateDate=toLateDate)
×
630

631
    def has_uebung(self):
1✔
632
        """Gibt True zurück wenn die Veranstaltung eine Übung hat sonst False"""
633
        result = False
1✔
634
        if self.typ == 'vu':
1✔
635
            result = True
1✔
636
        return result
1✔
637

638
    def veranstalter_list(self):
1✔
639
        """Eine Liste aller Veranstalter dieser Veranstaltung"""
640
        list = [x.full_name() for x in self.veranstalter.all()]
×
641
        return ', '.join(list)
×
642

643
    @staticmethod
1✔
644
    def anzahl_too_few_msg() :
1✔
645
        return _('Anzahl der Bestellungen muss mindestens {MIN_BESTELLUNG_ANZAHL} sein. Bei weniger als {MIN_BESTELLUNG_ANZAHL} Teilnehmenden ist eine Evaluation leider nicht möglich').format(MIN_BESTELLUNG_ANZAHL=Veranstaltung.MIN_BESTELLUNG_ANZAHL)
1✔
646

647
    def clean(self, *args, **kwargs):
1✔
648
        super(Veranstaltung, self).clean(*args, **kwargs)
1✔
649

650
        if self.auswertungstermin is not None and self.id is not None and self.auswertungstermin > self.semester.last_Auswertungstermin().date():
1✔
651
            raise ValidationError(self.auwertungstermin_to_late_msg())
×
652

653
    def save(self, *args, **kwargs):
1✔
654
        # beim Speichern Zugangsschlüssel erzeugen, falls noch keiner existiert
655
        if not self.access_token:
1✔
656
            self.access_token = '%016x' % random.randint(0, 16 ** 16 - 1)
1✔
657
        super(Veranstaltung, self).save(*args, **kwargs)
1✔
658

659
    def link_veranstalter(self):  # @see http://stackoverflow.com/a/17948593
1✔
660
        """Gibt die URL für die Bestellunng durch den Veranstalter zurück"""
661
        link_veranstalter = 'https://www.fachschaft.informatik.tu-darmstadt.de%s' % reverse('feedback:veranstalter-login')
1✔
662
        link_suffix_format = '?vid=%d&token=%s'
1✔
663
        if self.pk is not None and self.access_token is not None:
1✔
664
            return link_veranstalter + (link_suffix_format % (self.pk, self.access_token))
1✔
665
        else:
666
            return _("Der Veranstalter Link wird erst nach dem Anlegen angezeigt")
×
667

668
    def allow_order(self):
1✔
669
        """Überprüft anhand des Status' der Veranstaltung, ob bestellt werden darf."""
670
        return self.status == Veranstaltung.STATUS_BESTELLUNG_LIEGT_VOR or \
1✔
671
            self.status == Veranstaltung.STATUS_BESTELLUNG_GEOEFFNET or \
672
            self.status == Veranstaltung.STATUS_KEINE_EVALUATION
673

674
    def csv_to_tutor(self, csv_content):
1✔
675
        """Erzeuge Tutoren Objekte aus der CSV Eingabe der Veranstalter"""
676
        input_clean = csv_content.strip()
×
677
        input_lines = input_clean.splitlines()
×
678
        nummer = 1
×
679

680
        Tutor.objects.filter(veranstaltung=self).delete()
×
681
        for l in input_lines:
×
682
            # skip empty lines
683
            if len(l.strip()) > 1:
×
684
                row = l.split(',', 3)
×
685
                # skip lines which are not well formated
686
                if len(row) > 1:
×
687
                    row = [x.strip() for x in row]
×
688
                    anmerkung_input = ''
×
689
                    if len(row) > 3:
×
690
                        anmerkung_input = row[3]
×
691

692
                    Tutor.objects.create(
×
693
                        veranstaltung=self,
694
                        nummer=nummer,
695
                        nachname=row[0],
696
                        vorname=row[1],
697
                        email=row[2],
698
                        anmerkung=anmerkung_input
699
                    )
700

701
                    nummer += 1
×
702

703
    class Meta:
1✔
704
        verbose_name = _('Veranstaltung')
1✔
705
        verbose_name_plural = _('Veranstaltungen')
1✔
706
        ordering = ['semester', 'typ', 'name']
1✔
707
        unique_together = ('name', 'lv_nr', 'semester')
1✔
708
        app_label = 'feedback'
1✔
709

710

711
class Tutor(models.Model):
1✔
712
    """Repräsentiert Tutoren für eine Veranstaltung."""
713
    nummer = models.PositiveSmallIntegerField()
1✔
714
    vorname = models.CharField(_('first name'), max_length=30)
1✔
715
    nachname = models.CharField(_('last name'), max_length=30)
1✔
716
    email = models.EmailField(_('e-mail address'))
1✔
717
    anmerkung = models.CharField(max_length=100, verbose_name=_("Anmerkung"))
1✔
718
    veranstaltung = models.ForeignKey(Veranstaltung, verbose_name=_("Veranstaltung"), on_delete=models.CASCADE)
1✔
719

720
    def get_barcode_number(self):
1✔
721
        """Gibt die Barcodenummer anhand der Tutorennummer zurück."""
722
        return self.veranstaltung.get_barcode_number(tutorgruppe=self.nummer)
×
723

724
    def __str__(self):
1✔
725
        return '%s %s %d' % (self.vorname, self.nachname, self.nummer)
×
726

727
    class Meta:
1✔
728
        verbose_name = _('Tutor')
1✔
729
        verbose_name_plural = _('Tutoren')
1✔
730
        unique_together = (('nummer', 'veranstaltung'),)
1✔
731
        app_label = 'feedback'
1✔
732

733

734
class Mailvorlage(models.Model):
1✔
735
    """Repräsentiert eine Mailvorlage"""
736
    subject = models.CharField(max_length=100, unique=True)
1✔
737
    body = models.TextField()
1✔
738

739
    def __str__(self):
1✔
740
        return self.subject
1✔
741

742
    class Meta:
1✔
743
        verbose_name = _('Mailvorlage')
1✔
744
        verbose_name_plural = verbose_name + 'n'
1✔
745
        ordering = ['subject']
1✔
746
        app_label = 'feedback'
1✔
747

748

749
class EmailChange(models.Model) :
1✔
750
    """
751
    Used to keep track of email change requests
752
    """
753
    
754
    class Status(models.IntegerChoices):
1✔
755
        EXPIRED = 0, _("EXPIRED")
1✔
756
        MAGIC_LINK_SENT = 1, _("LINK SENT")
1✔
757
        OTP_SENT = 2, _("OTP SENT")
1✔
758
        COMPLETED = 3, _("COMPLETED SUCCESSFULLY")
1✔
759

760
    class Meta:
1✔
761
        indexes = [
1✔
762
            models.Index(fields=["token"]),
763
        ]
764

765
    token = models.UUIDField(editable=False, default=uuid.uuid4, unique=True)
1✔
766

767
    old_email = models.EmailField(blank=False, validators=[validate_email], verbose_name=_("Alte E-Mail"))
1✔
768
    new_email = models.EmailField(blank=False, validators=[validate_email], verbose_name=_("Neue E-Mail"))
1✔
769

770
    person_list_to_change = models.ManyToManyField(
1✔
771
        Person,
772
        related_name="email_change_list",
773
        verbose_name=_("Personen"),
774
        help_text=_("Bitte wählen Sie alle Personen aus, deren E-Mail-Adresse geändert werden soll."),
775
        blank=True,
776
    )
777

778
    new_email_otp_hash = models.CharField(blank=True, max_length=128)
1✔
779

780
    created_at = models.DateTimeField(auto_now_add=True, editable=False)
1✔
781

782
    status = models.PositiveSmallIntegerField(
1✔
783
        choices=Status.choices,
784
        default=Status.MAGIC_LINK_SENT,
785
        db_index=True,
786
    )
787

788
    dynamic_expiry_time = models.DateTimeField(default=timezone.now, help_text=_("Ab diesem Zeitpunk wird berechnet, ob die Anfrage gültig ist."))
1✔
789

790
    MINUTES_TO_EXPIRE_LINK = 15
1✔
791
    MINUTES_TO_EXPIRE_OTP = 15
1✔
792

793
    @property
1✔
794
    def is_expired(self):
1✔
795
        """
796
        Returns True if minutes to expire passed OR status is already EXPIRED.
797
        """
798
        if self.status == self.Status.EXPIRED:
1✔
799
            return True
×
800
        elif self.status == self.Status.COMPLETED:
1✔
801
            return False
×
802
        elif self.status == self.Status.MAGIC_LINK_SENT :
1✔
803
            expiry_limit = self.dynamic_expiry_time + datetime.timedelta(minutes=EmailChange.MINUTES_TO_EXPIRE_LINK)
×
804

805
        elif self.status == self.Status.OTP_SENT :
1✔
806
            expiry_limit = self.dynamic_expiry_time + datetime.timedelta(minutes=EmailChange.MINUTES_TO_EXPIRE_OTP)
1✔
807

808
        return timezone.now() > expiry_limit
1✔
809

810
    def request_is_valid(self):
1✔
811
        """
812
        Call this method in view to check validity of request.
813
        """
814
        if self.is_expired and self.status != self.Status.EXPIRED:
1✔
815
            self.status = self.Status.EXPIRED
×
816
            self.save(update_fields=['status']) # remove this part if background tasks are added
×
817
            return False
×
818
        return self.status != self.Status.EXPIRED
1✔
819
    
820

821
    def generate_otp_and_hash(self, length=16) :
1✔
822
        """
823
        Generates a complex one-time password and its hash.
824
        """
825
        alphabet = (
×
826
            string.ascii_letters +
827
            string.digits +
828
            "!@#$%^&*()-_=+[]{}:?"
829
        )
830

831
        otp = ''.join(secrets.choice(alphabet) for _ in range(length))
×
832

833
        return otp, make_password(otp)
×
834

835
    def verify_otp(self, otp: str) -> bool:
1✔
836
        return check_password(otp, self.new_email_otp_hash)
×
837

838
    class Meta:
1✔
839
        verbose_name = _('E-Mail-Änderung')
1✔
840
        verbose_name_plural = _('E-Mail-Änderungen')
1✔
841
        app_label = 'feedback'
1✔
842

843

844
class BarcodeScanner(models.Model):
1✔
845
    """Repräsentiert einen Barcodescanner."""
846
    token = models.CharField(max_length=64, unique=True)
1✔
847
    description = models.TextField()
1✔
848

849
    def __str__(self):
1✔
850
        return self.description
×
851

852
    class Meta:
1✔
853
        verbose_name = 'Barcode Scanner'
1✔
854
        verbose_name_plural = 'Barcode Scanner'
1✔
855
        app_label = 'feedback'
1✔
856

857

858
class BarcodeAllowedState(models.Model):
1✔
859
    """Repräsentiert die erlaubten Zustände für einen Barcodescanner."""
860
    barcode_scanner = models.ForeignKey(BarcodeScanner, on_delete=models.CASCADE)
1✔
861
    allow_state = models.IntegerField(choices=Veranstaltung.STATUS_CHOICES, null=True)
1✔
862

863
    class Meta:
1✔
864
        verbose_name = _('Erlaubter Zustand')
1✔
865
        verbose_name_plural = _('Erlaubte Zustände')
1✔
866
        unique_together = (('barcode_scanner', 'allow_state'),)
1✔
867
        app_label = 'feedback'
1✔
868

869

870
class BarcodeScannEvent(models.Model):
1✔
871
    """Repräsentiert einen Scan-Event für einen Barcodescanner"""
872
    veranstaltung = models.ForeignKey(Veranstaltung, on_delete=models.CASCADE)
1✔
873
    scanner = models.ForeignKey(BarcodeScanner, on_delete=models.CASCADE)
1✔
874
    tutorgroup = models.ForeignKey(Tutor, null=True, blank=True, on_delete=models.CASCADE)
1✔
875
    barcode = models.PositiveIntegerField()
1✔
876
    timestamp = models.DateTimeField(auto_now_add=True)
1✔
877

878
    class Meta:
1✔
879
        verbose_name = 'BarcodeScannEvent'
1✔
880
        verbose_name_plural = 'BarcodeScannEvents'
1✔
881
        app_label = 'feedback'
1✔
882

883
    def save(self, *args, **kwargs):
1✔
884
        """Extrahiere die Veranstaltungsdaten aus dem Barcode teil zwei zum ModelForm"""
885
        barcode_decode = Veranstaltung.decode_barcode(self.barcode)
×
886
        verst_obj = Veranstaltung.objects.get(pk=barcode_decode['veranstaltung'])
×
887
        self.veranstaltung = verst_obj
×
888
        self.veranstaltung.log(self.scanner)
×
889

890
        if barcode_decode['tutorgroup'] >= 1:
×
891
            tutorgroup = Tutor.objects.get(veranstaltung=verst_obj, nummer=barcode_decode['tutorgroup'])
×
892
            self.tutorgroup = tutorgroup
×
893

894
        super(BarcodeScannEvent, self).save(*args, **kwargs)
×
895

896

897
class Log(models.Model):
1✔
898
    """Repräsentiert einen Logger für die Zustandsübergänge von Veranstaltungen."""
899
    FRONTEND = 'fe'
1✔
900
    SCANNER = 'bs'
1✔
901
    ADMIN = 'ad'
1✔
902

903
    INTERFACE_CHOICES = (
1✔
904
        (FRONTEND, 'Frontend'),
905
        (SCANNER, 'Barcodescanner'),
906
        (ADMIN, 'Admin')
907
    )
908

909
    veranstaltung = models.ForeignKey(Veranstaltung, null=True, related_name='veranstaltung', on_delete=models.CASCADE)
1✔
910
    user = models.ForeignKey(User, null=True, related_name='user', on_delete=models.CASCADE)
1✔
911
    scanner = models.ForeignKey(BarcodeScanner, null=True, related_name='scanner', on_delete=models.CASCADE)
1✔
912
    timestamp = models.DateTimeField(auto_now_add=True)
1✔
913
    status = models.IntegerField(choices=Veranstaltung.STATUS_CHOICES, default=Veranstaltung.STATUS_ANGELEGT)
1✔
914
    interface = models.CharField(max_length=2, choices=INTERFACE_CHOICES)
1✔
915

916
    class Meta:
1✔
917
        verbose_name = 'Log'
1✔
918
        verbose_name_plural = 'Logs'
1✔
919
        app_label = 'feedback'
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