joeni

Change collection of course results to be error proof

It's possible a course result does not exist (yet). Until now, that would cause an error when requesting the results, but now it just returns None.

Author
Maarten 'Vngngdn' Vangeneugden
Date
April 15, 2018, 7:16 p.m.
Hash
cb27d1718c59fdf3dd92bb0d423059778fdf6588
Parent
56eb93dad8d6d31a2bf667cf2960a4cbc080c858
Modified file
administration/models.py

administration/models.py

5 additions and 2 deletions.

View changes Hide changes
1
1
from django.core.exceptions import ValidationError
2
2
from django.core.validators import MaxValueValidator
3
3
from django.utils.translation import ugettext_lazy as _
4
4
from django.utils.text import slugify
5
5
from django.contrib.auth.models import AbstractUser
6
6
import datetime
7
7
import os
8
8
import uuid
9
9
#from . import roster
10
10
11
11
def validate_IBAN(value):
12
12
    """ Validates if the given value qualifies as a valid IBAN number.
13
13
    This validator checks if the structure is valid, and calculates the control
14
14
    number if the structure is correct. If the control number fails, or the
15
15
    structure is invalid, a ValidationError will be raised. In that case,
16
16
    the Error will specify whether the structure is incorrect, or the control
17
17
    number is not valid.
18
18
    """
19
19
    # FIXME: This function is not complete. When there's time, implement
20
20
    # as specified at https://nl.wikipedia.org/wiki/International_Bank_Account_Number#Structuur
21
21
    if False:
22
22
        raise ValidationError(
23
23
            _('%(value)s is not a valid IBAN number.'),
24
24
            params={'value': value},)
25
25
def validate_BIC(value):
26
26
    """ Same functionality as validate_IBAN, but for BIC-codes. """
27
27
    # FIXME: This function is not complete. When there's time, implement
28
28
    # as specified at https://nl.wikipedia.org/wiki/Business_Identifier_Code
29
29
    pass
30
30
31
31
class User(AbstractUser):
32
32
    """ Replacement for the standard Django User model. """
33
33
    number = models.AutoField(
34
34
        primary_key=True,
35
35
        help_text=_("The number assigned to this user."),
36
36
        )
37
37
    created = models.DateField(auto_now_add=True)
38
38
39
39
    def __str__(self):
40
40
        user_data = UserData.objects.filter(user=self)
41
41
        if len(user_data) == 0:
42
42
            return self.username
43
43
        else:
44
44
            user_data = user_data[0]
45
45
            name = user_data.first_name +" "+ user_data.last_name
46
46
            titles = user_data.title.split()
47
47
            if len(titles) == 0:
48
48
                return name
49
49
            else:
50
50
                prefix_titles = ""
51
51
                suffix_titles = ""
52
52
                for title in titles:
53
53
                    if title in ["lic.", "prof.", "dr.", "drs.", "bc.", "bacc.", "ing.", "ir." "cand.", "mr.", "dr.h.c.mult.", "dr.h.c.", "dr.mult.", "em. prof.", "prof. em.", "lec."]:
54
54
                        prefix_titles += title + " "
55
55
                    elif title in ["MSc", "BSc", "MA", "BA", "LLM", "LLB", "PhD", "MD"]:
56
56
                        suffix_titles += title + " "
57
57
            return prefix_titles + name + " " + suffix_titles.strip()
58
58
59
59
class UserData(models.Model):
60
60
    user = models.OneToOneField("User", on_delete=models.CASCADE, related_name="user_data")
61
61
    first_name = models.CharField(max_length=64, blank=False)
62
62
    last_name = models.CharField(max_length=64, blank=False)
63
63
    title = models.CharField(
64
64
        max_length=64,
65
65
        blank=True,
66
66
        help_text=_("The academic title of this user, if applicable."),
67
67
        )
68
68
    DOB = models.DateField(
69
69
        blank=False,
70
70
        #editable=False,  # For testing purposes, decomment in deployment!
71
71
        help_text=_("The date of birth of this user."),
72
72
        )
73
73
    POB = models.CharField(
74
74
        max_length=64,
75
75
        blank=False,
76
76
        #editable=False,  # For testing purposes, decomment in deployment!
77
77
        help_text=_("The place of birth of this user."),
78
78
        )
79
79
    nationality = models.CharField(
80
80
        max_length=64,
81
81
        blank=False,
82
82
        help_text=_("The current nationality of this user."),
83
83
        default="Belg",
84
84
        )
85
85
    # XXX: What if this starts with zeros?
86
86
    national_registry_number = models.BigIntegerField(
87
87
        blank=True,  # Only possible if Belgian
88
88
        # TODO Validator!
89
89
        #editable=False,
90
90
        help_text=_("The assigned national registry number of this user."),
91
91
        )
92
92
    civil_status = models.CharField(
93
93
        max_length=32,
94
94
        choices = (
95
95
            ("Single", _("Single")),
96
96
            ("Married", _("Married")),
97
97
            ("Divorced", _("Divorced")),
98
98
            ("Widowed", _("Widowed")),
99
99
            ("Partnership", _("Partnership")),
100
100
            ),
101
101
        blank=False,
102
102
        # There may be more; consult http://www.aantrekkingskracht.com/trefwoord/burgerlijke-staat
103
103
        # for more information.
104
104
        help_text=_("The civil/marital status of the user."),
105
105
        )
106
106
107
107
    is_staff = models.BooleanField(
108
108
        default=False,
109
109
        help_text=_("Determines if this user is part of the university's staff."),
110
110
        )
111
111
    is_student = models.BooleanField(
112
112
        default=True,
113
113
        help_text=_("Indicates if this user is a student at the university."),
114
114
        )
115
115
116
116
    def slug_name(self):
117
117
        """Returns a slug name for this user which can be used to reference in
118
118
        URLs."""
119
119
        same_names = UserData.objects.filter(first_name=self.first_name).filter(last_name=self.last_name)
120
120
        if len(same_names) == 1 and same_names[0] == self:
121
121
            return slugify(self.first_name +"-"+ self.last_name, allow_unicode=True)
122
122
        else:
123
123
            number = self.user.number
124
124
            return slugify(self.first_name +"-"+ self.last_name +"-"+ str(number), allow_unicode=True)
125
125
126
126
    # Home address
127
127
    home_street = models.CharField(max_length=64, blank=False)
128
128
    home_number = models.PositiveSmallIntegerField(blank=False)
129
129
    home_bus = models.CharField(max_length=10, null=True, blank=True)
130
130
    home_postal_code = models.PositiveIntegerField(blank=False)
131
131
    home_city = models.CharField(max_length=64, blank=False)
132
132
    home_country = models.CharField(max_length=64, blank=False, default="België")
133
133
    home_telephone = models.CharField(
134
134
        max_length=64,
135
135
        help_text=_("The telephone number for the house address. Prefix 0 can be presented with the national call code in the system (\"32\" for Belgium)."),
136
136
        )
137
137
    # Study address
138
138
    study_street = models.CharField(max_length=64, blank=True, null=True)
139
139
    study_number = models.PositiveSmallIntegerField(blank=True, null=True)
140
140
    study_bus = models.CharField(max_length=10, null=True, blank=True)
141
141
    study_postal_code = models.PositiveSmallIntegerField(blank=True, null=True)
142
142
    study_country = models.CharField(max_length=64, blank=True, null=True)
143
143
    study_telephone = models.CharField(
144
144
        blank=True, null=True,
145
145
        max_length=64,
146
146
        help_text=_("The telephone number for the study address. Prefix 0 can be presented with the national call code in the system."),
147
147
        )
148
148
    study_cellphone = models.CharField(
149
149
        max_length=64, null=True, blank=True,
150
150
        help_text=_("The cellphone number of the person. Prefix 0 can be presented with then national call code in the system."),
151
151
        )
152
152
    # Titularis address
153
153
    # XXX: These fields are only required if this differs from the user itself.
154
154
    titularis_street = models.CharField(max_length=64, null=True, blank=True)
155
155
    titularis_number = models.PositiveSmallIntegerField(null=True, blank=True)
156
156
    titularis_bus = models.CharField(max_length=10, null=True, blank=True)
157
157
    titularis_postal_code = models.PositiveSmallIntegerField(null=True, blank=True)
158
158
    titularis_country = models.CharField(max_length=64, null=True, blank=True)
159
159
    titularis_telephone = models.CharField(
160
160
        max_length=64,
161
161
        help_text=_("The telephone number of the titularis. Prefix 0 can be presented with the national call code in the system."),
162
162
        null=True,
163
163
        blank=True,
164
164
        )
165
165
166
166
    # Financial details
167
167
    bank_account_number = models.CharField(
168
168
        max_length=34,  # Max length of all IBAN account numbers
169
169
        validators=[validate_IBAN],
170
170
        help_text=_("The IBAN of this user. No spaces!"),
171
171
        )
172
172
    BIC = models.CharField(
173
173
        max_length=11,
174
174
        validators=[validate_BIC],
175
175
        help_text=_("The BIC of this user's bank."),
176
176
        )
177
177
178
178
""" NOTE: What about all the other features that should be in the administration?
179
179
While there are a lot of things to cover, as of now, I have no way to know which
180
180
ones are still valid, which are deprecated, and so on...
181
181
Additionally, every feature may have a different set of requirements, data,
182
182
and it's very likely making an abstract class won't do any good. Thus I have
183
183
decided to postpone making additional tables and forms for these features until
184
184
I have clearance about certain aspects. """
185
185
186
186
class Curriculum(models.Model):
187
187
    """ The curriculum of a particular student.
188
188
    Every academic year, a student has to hand in a curriculum (s)he wishes to
189
189
    follow. This is then reviewed by a committee. A curriculum exists of all the
190
190
    courses one wants to partake in in a certain year. """
191
191
    student = models.ForeignKey(
192
192
        "User",
193
193
        on_delete=models.CASCADE,
194
194
        limit_choices_to={'groups': 1},  # 1 = Students group ID
195
195
        null=False,
196
196
        #editable=False,
197
197
        #unique_for_year="year",  # Only 1 curriculum per year # FIXME to work with integer!
198
198
        )
199
199
    year = models.PositiveIntegerField(
200
200
        null=False,
201
201
        default=datetime.date.today().year,
202
202
        help_text=_("The academic year for which this curriculum is. "
203
203
                    "If this field is equal to 2008, then that means "
204
204
                    "this curriculum is for the academic year "
205
205
                    "2008-2009."),
206
206
        )
207
207
    # TODO: Validate changes: A curriculum cannot undergo another change if the
208
208
    # academic year it was made in is history.
209
209
    last_modified = models.DateTimeField(
210
210
        auto_now=True,
211
211
        help_text=_("The last timestamp that this was updated."),
212
212
        )
213
213
    course_programmes = models.ManyToManyField(
214
214
        "courses.CourseProgramme",
215
215
        blank=False,  # An empty curriculum makes no sense
216
216
        help_text=_("All the course programmes included in this curriculum."),
217
217
        )
218
218
    approved = models.NullBooleanField(
219
219
        default=None,
220
220
        help_text=_("Indicates if this curriculum has been approved. If true, "
221
221
                    "that means the responsible committee has reviewed and "
222
222
                    "approved the student for this curriculum. False otherwise. "
223
223
                    "If review is still pending, the value is NULL. Modifying "
224
224
                    "the curriculum implies this setting is set to NULL again."),
225
225
        )
226
226
    note = models.TextField(
227
227
        blank=True,
228
228
        help_text=_("Additional notes regarding this curriculum. This has "
229
229
                    "multiple uses. For the student, it is used to clarify "
230
230
                    "any questions, or to motivate why (s)he wants to take a "
231
231
                    "course for which the requirements were not met. "
232
232
                    "The reviewing committee can use this field to argument "
233
233
                    "their decision, especially for when the curriculum is "
234
234
                    "denied."),
235
235
        )
236
236
237
237
    def course_programmes_results(self):
238
238
        """ Returns a dictionary, where the keys are the course_programmes
239
239
        in this curriculum, and the values are the course_results associated
240
240
        with them."""
241
241
        join_dict = dict()
242
242
        for course_program in self.course_programmes.all():
243
243
            result = CourseResult.objects.filter(
244
244
                student=self.student).filter(
245
245
                    course_programme=course_program).filter(
246
246
                        year=self.year)[0]
247
-
            join_dict[course_program] = result
248
-
        return join_dict
+
247
            if len(result) == 0:
+
248
                join_dict[course_program] = None
+
249
            else:
+
250
                join_dict[course_program] = result[0]
+
251
        return join_dict
249
252
250
253
    def courses(self):
251
254
        """ Returns a set of all the courses that are in this curriculum.
252
255
        This is not the same as CourseProgrammes, as these can differ depending
253
256
        on which study one follows. """
254
257
        course_set = set()
255
258
        for course_programme in self.course_programmes.all():
256
259
            course_set.add(course_programme.course)
257
260
        return course_set
258
261
259
262
    def curriculum_type(self):
260
263
        """ Returns the type of this curriculum. At the moment, this is
261
264
        either a standard programme, or an individualized programme. """
262
265
        # Currently: A standard programme means: All courses are from the
263
266
        # same study, ánd from the same year. Additionally, all courses
264
267
        # from that year must've been taken.
265
268
        # FIXME: Need a way to determine what is the standard programme.
266
269
        # If not possible, make this a charfield with options or something
267
270
        pass
268
271
269
272
    def __str__(self):
270
273
        return str(self.student) +" | "+ str(self.year) +"-"+ str(self.year+1)
271
274
272
275
273
276
class CourseResult(models.Model):
274
277
    """ A student has to obtain a certain course result. These are stored here,
275
278
    together with all the appropriate information. """
276
279
    # TODO: Validate that a course programme for a student can only be made once per year for each course, if possible.
277
280
    CRED = _("Credit acquired")
278
281
    FAIL = _("Credit not acquired")
279
282
    TLRD = _("Tolerated")
280
283
    ITLR = _("Tolerance used")
281
284
    BDRG = _("Fraud committed")
282
285
    VRST = _("Exemption")
283
286
    STOP = _("Course cancelled")
284
287
    # Possible to add more in the future
285
288
286
289
    student = models.ForeignKey(
287
290
        "User",
288
291
        on_delete=models.CASCADE,
289
292
        #limit_choices_to={'is_student': True},
290
293
        null=False,
291
294
        db_index=True,
292
295
        )
293
296
    course_programme = models.ForeignKey(
294
297
        "courses.CourseProgramme",
295
298
        on_delete=models.PROTECT,
296
299
        null=False,
297
300
        )
298
301
    year = models.PositiveIntegerField(
299
302
        null=False,
300
303
        default=datetime.date.today().year,
301
304
        help_text=_("The academic year this course took place in. If 2018 is entered, "
302
305
                    "then that means academic year '2018-2019'."),
303
306
        )
304
307
    released = models.DateField(
305
308
        auto_now=True,
306
309
        help_text=_("The date that this result was last updated."),
307
310
        )
308
311
    first_score = models.PositiveSmallIntegerField(
309
312
        null=True,  # It's possible a score does not exist.
310
313
        blank=True,
311
314
        validators=[MaxValueValidator(
312
315
            20,
313
316
            _("The score mustn't be higher than 20."),
314
317
            )],
315
318
        )
316
319
    second_score = models.PositiveSmallIntegerField(
317
320
        null=True,
318
321
        blank=True,
319
322
        validators=[MaxValueValidator(
320
323
            20,
321
324
            _("The score mustn't be higher than 20."),
322
325
            )],
323
326
        )
324
327
    result = models.CharField(
325
328
        max_length=10,
326
329
        choices = (
327
330
            ("CRED", CRED),
328
331
            ("FAIL", FAIL),
329
332
            ("TLRD", TLRD),
330
333
            ("ITLR", ITLR),
331
334
            ("BDRG", BDRG),
332
335
            ("VRST", VRST),
333
336
            ("STOP", STOP),
334
337
            ),
335
338
        blank=False,
336
339
        help_text=_("The final result this record constitutes."),
337
340
        )
338
341
339
342
    def __str__(self):
340
343
        stdnum = str(self.student.number)
341
344
        result = self.result
342
345
        if result == "CRED":
343
346
            if self.first_score < 10:
344
347
                result = "C" + str(self.first_score) + "1"
345
348
            else:
346
349
                result = "C" + str(self.second_score) + "2"
347
350
        course = str(self.course_programme.course)
348
351
        return stdnum +" ("+ result +") | "+ course
349
352
350
353
class PreRegistration(models.Model):
351
354
    """ At the beginning of the new academic year, students can register
352
355
    themselves at the university. Online, they can do a preregistration already.
353
356
    These records are stored here and can later be retrieved for the actual
354
357
    registration process.
355
358
    Note: The current system in use at Hasselt University provides a password system.
356
359
    That will be eliminated here. Just make sure that the entered details are correct.
357
360
    Should there be an error, and the same email address is used to update something,
358
361
    a mail will be sent to that address to verify this was a genuine update."""
359
362
    created = models.DateField(auto_now_add=True)
360
363
    first_name = models.CharField(max_length=64, blank=False, help_text=_("Your first name."))
361
364
    last_name = models.CharField(max_length=64, blank=False, help_text=_("Your last name."))
362
365
    additional_names = models.CharField(max_length=64, blank=True, help_text=_("Any additional names."))
363
366
    title = models.CharField(
364
367
        max_length=64,
365
368
        blank=True,
366
369
        help_text=_("Any additional titles, prefixes, ..."),
367
370
        )
368
371
    DOB = models.DateField(
369
372
        blank=False,
370
373
        #editable=False,
371
374
        help_text=_("Your date of birth."),
372
375
        )
373
376
    POB = models.CharField(
374
377
        max_length=64,
375
378
        blank=False,
376
379
        #editable=False,
377
380
        help_text=_("The place you were born."),
378
381
        )
379
382
    nationality = models.CharField(
380
383
        max_length=64,
381
384
        blank=False,
382
385
        help_text=_("Your current nationality."),
383
386
        )
384
387
    national_registry_number = models.BigIntegerField(
385
388
        null=True,
386
389
        help_text=_("If you have one, your national registry number."),
387
390
        )
388
391
    civil_status = models.CharField(
389
392
        max_length=32,
390
393
        choices = (
391
394
            ("Single", _("Single")),
392
395
            ("Married", _("Married")),
393
396
            ("Divorced", _("Divorced")),
394
397
            ("Widowed", _("Widowed")),
395
398
            ("Partnership", _("Partnership")),
396
399
            ),
397
400
        blank=False,
398
401
        # There may be more; consult http://www.aantrekkingskracht.com/trefwoord/burgerlijke-staat
399
402
        # for more information.
400
403
        help_text=_("Your civil/marital status."),
401
404
        )
402
405
    email = models.EmailField(
403
406
        blank=False,
404
407
        unique=True,
405
408
        help_text=_("The e-mail address we will use to communicate until your actual registration."),
406
409
        )
407
410
    study = models.ForeignKey(
408
411
        "courses.Study",
409
412
        on_delete=models.PROTECT,
410
413
        null=False,
411
414
        help_text=_("The study you wish to follow. Be sure to provide all legal"
412
415
                    "documents that are required for this study with this "
413
416
                    "application, or bring them with you to the final registration."),
414
417
        )
415
418
    study_type = models.CharField(
416
419
        max_length=32,
417
420
        choices = (
418
421
            ("Diplom contract", _("Diplom contract")),
419
422
            ("Exam contract", _("Exam contract")),
420
423
            ("Credit contract", _("Credit contract")),
421
424
            ),
422
425
        blank=False,
423
426
        help_text=_("The type of study contract you wish to follow."),
424
427
        )
425
428
    document = models.FileField(
426
429
        upload_to="pre-enrollment/%Y",
427
430
        help_text=_("Any legal documents regarding your enrollment."),
428
431
        )
429
432
    # XXX: If the database in production is PostgreSQL, comment document, and
430
433
    # uncomment the next column.
431
434
    """documents = models.ArrayField(
432
435
        models.FileField(upload_to="pre-enrollment/%Y"),
433
436
        help_text=_("Any legal documents regarding your enrollment."),
434
437
        )"""
435
438
436
439
    def __str__(self):
437
440
        name = self.last_name +" "+ self.first_name
438
441
        dob = self.DOB.strftime("%d/%m/%Y")
439
442
        return name +" | "+ dob
440
443
441
444
442
445
# Planning and organization related tables
443
446
class Room(models.Model):
444
447
    """ Represents a room in the university.
445
448
    Rooms can have a number of properties, which are stored in the database.
446
449
    """
447
450
    # Types of rooms
448
451
    LABORATORY = _("Laboratory")  # Chemistry/Physics equipped rooms
449
452
    CLASS_ROOM = _("Class room")  # Simple class rooms
450
453
    AUDITORIUM = _("Auditorium")  # Large rooms with ample seating and equipment for lectures
451
454
    PC_ROOM    = _("PC room"   )  # Rooms equipped for executing PC related tasks
452
455
    PUBLIC_ROOM= _("Public room") # Restaurants, restrooms, ... general public spaces
453
456
    OFFICE     = _("Office"    )  # Private offices for staff
454
457
    PRIVATE_ROOM = _("Private room")  # Rooms accessible for a limited public; cleaning cupboards, kitchens, ...
455
458
    WORKSHOP   = _("Workshop"  )  # Rooms with hardware equipment to build and work on materials
456
459
    OTHER      = _("Other"     )  # Rooms that do not fit in any other category
457
460
458
461
459
462
    name = models.CharField(
460
463
        max_length=20,
461
464
        primary_key=True,
462
465
        blank=False,
463
466
        help_text=_("The name of this room. If more appropriate, this can be the colloquial name."),
464
467
        )
465
468
    seats = models.PositiveSmallIntegerField(
466
469
        help_text=_("The amount of available seats in this room."),
467
470
        )
468
471
    wheelchair_accessible = models.BooleanField(default=True)
469
472
    exams_equipped = models.BooleanField(
470
473
        default=True,
471
474
        help_text=_("Indicates if exams can reasonably be held in this room."),
472
475
        )
473
476
    loose_tables = models.BooleanField(
474
477
        default=True,
475
478
        help_text=_("If true, the tables in this room can be moved freely. "
476
479
                    "If false, they're bolted down in their positions."),
477
480
        )
478
481
    electrical_plugs = models.PositiveSmallIntegerField(
479
482
        help_text=_("The amount of electrical plugs that are available to the "
480
483
                    "people for free use. Electrical plugs that are more or "
481
484
                    "less constantly occupied by permanent equipment (such as "
482
485
                    "computers, beamers, ...) are excluded from counting."),
483
486
        )
484
487
    exterior_window = models.BooleanField(
485
488
        default=True,
486
489
        help_text=_("Indicates if this room has a window to the outside."),
487
490
        )
488
491
    software_available = models.TextField(
489
492
        blank=True,
490
493
        help_text=_("Some software used at the university is proprietary, and "
491
494
                    "thus not available at every system. If certain "
492
495
                    "software is installed on the computers in this room that "
493
496
                    "cannot be found on other computers, list them here."),
494
497
        )
495
498
    computers_available = models.PositiveSmallIntegerField(
496
499
        default=0,
497
500
        help_text=_("Indicates how many computers are available in this room."),
498
501
        )
499
502
    projector_available = models.BooleanField(
500
503
        default=False,
501
504
        help_text=_("Indicates if a projector is available at this room."),
502
505
        )
503
506
    blackboards_available = models.PositiveSmallIntegerField(
504
507
        help_text=_("The amount of blackboards available in this room."),
505
508
        )
506
509
    whiteboards_available = models.PositiveSmallIntegerField(
507
510
        help_text=_("The amount of whiteboards available in this room."),
508
511
        )
509
512
    category = models.CharField(
510
513
        max_length=16,
511
514
        blank=False,
512
515
        choices = (
513
516
            ("LABORATORY", LABORATORY),
514
517
            ("CLASS_ROOM", CLASS_ROOM),
515
518
            ("AUDITORIUM", AUDITORIUM),
516
519
            ("PC_ROOM", PC_ROOM),
517
520
            ("PUBLIC_ROOM", PUBLIC_ROOM),
518
521
            ("OFFICE", OFFICE),
519
522
            ("PRIVATE_ROOM", PRIVATE_ROOM),
520
523
            ("WORKSHOP", WORKSHOP),
521
524
            ("OTHER", OTHER),
522
525
            ),
523
526
        help_text=_("The category that best suits the character of this room."),
524
527
        )
525
528
    reservable = models.BooleanField(
526
529
        default=True,
527
530
        help_text=_("Indicates if this room can be reserved for something."),
528
531
        )
529
532
    note = models.TextField(
530
533
        blank=True,
531
534
        help_text=_("If some additional info is required for this room, like a "
532
535
                    "characteristic property (e.g. 'Usually occupied by 2BACH "
533
536
                    "informatics'), state it here."),
534
537
        )
535
538
    # TODO: Add a campus/building field or not?
536
539
537
540
    def next_reservation(self, time):
538
541
        """ Returns the next reservation starting from the given time, or, if
539
542
        the next reservation starts on the given time, that reservation.
540
543
        Returns None if there is no reservation from this moment on."""
541
544
        reservations = RoomReservation.objects.filter(room=self).filter(begin_time__gte=time).order_by('begin_time')
542
545
        if len(reservations) == 0:
543
546
            return None
544
547
        else:
545
548
            return reservations[0]
546
549
    def next_event(self, time):
547
550
        """ Returns the next event starting from the given time, or, if
548
551
        the next event starts on the given time, that event.
549
552
        Returns None if there is no event from this moment on."""
550
553
        events = CourseEvent.objects.filter(room=self).filter(begin_time__gte=time).order_by('begin_time')
551
554
        if len(events) == 0:
552
555
            return None
553
556
        else:
554
557
            return events[0]
555
558
556
559
557
560
    def reservation_possible(self, begin, end, seats=None):
558
561
        # TODO: Include events in the check for possibilities!
559
562
        """ Returns a boolean indicating if reservating during the given time
560
563
        is possible. If the begin overlaps with a reservation's end or vice versa,
561
564
        this is regarded as possible.
562
565
        Takes seats as optional argument. If not specified, it is assumed the entire
563
566
        room has to be reserved. """
564
567
        if self.reservable is False:
565
568
            return False
566
569
        if seats is not None and seats < 0: raise ValueError(_("seats ∈ ℕ"))
567
570
568
571
        reservations = RoomReservation.objects.filter(room=self)
569
572
        for reservation in reservations:
570
573
            if reservation.end_time <= begin or reservation.begin_time >= end:
571
574
                continue  # Can be trivially skipped, no overlap here
572
575
            elif seats is None or reservation.seats is None:
573
576
                return False  # The whole room cannot be reserved -> False
574
577
            elif seats + reservation.seats > self.seats:
575
578
                    return False  # Total amount of seats exceeds the available amount -> False
576
579
        return True  # No overlappings found -> True
577
580
578
581
    def __str__(self):
579
582
        return self.name
580
583
581
584
582
585
# Validators that will be used for RoomReservations and Events
583
586
def validate_event_time(time):
584
587
    """Checks if the time is a quarter of an hour (0, 15, 30, or 45)."""
585
588
    if time.minute not in [0, 15, 30, 45] or time.second != 0:
586
589
        raise ValidationError(
587
590
            _('%(time)s is not in the quarter of an hour.'),
588
591
            params={'time': time.strftime("%H:%M")})
589
592
def validate_university_hours(value):
590
593
    """Checks if the datetime value given takes place during the opening hours
591
594
    of the university (08:00 - 20:00)."""
592
595
    if value.hour < 8 or (value.hour == 22 and value.minute != 0) or value.hour >= 23:
593
596
        raise ValidationError(
594
597
            _("All events and reservations must begin and end between 08:00 "
595
598
              "and 22:00."))
596
599
def overlaps(begin_a, end_a, begin_b, end_b):
597
600
    """Checks if timespan a and b overlap with each other. If one of them ends at
598
601
    the same time the other one begins, it does not count as an overlap.
599
602
    This function assumes the end takes place strictly /after/ the begin."""
600
603
    if end_a <= begin_b or end_b <= begin_a:
601
604
        return False
602
605
    if (
603
606
            begin_a < begin_b <= end_a or
604
607
            begin_b < begin_a <= end_b or
605
608
            begin_a <= end_b < end_a or
606
609
            begin_b <= end_a < end_b):
607
610
        return True
608
611
    else:
609
612
        return False
610
613
611
614
612
615
def general_reservation_validator(self):
613
616
    # Check for overlapping reservations
614
617
    # TODO: Try to make it possible to link to the reservator,
615
618
    # to display the reason, to show the available times that a
616
619
    # reservation can be made for that room, and so on... Make it
617
620
    # a bit more interactive.
618
621
    for reservation in RoomReservation.objects.filter(room=self.room):
619
622
        if overlaps(self.begin_time,
620
623
                    self.end_time,
621
624
                    reservation.begin_time,
622
625
                    reservation.end_time):
623
626
            if isinstance(self, RoomReservation):
624
627
                if self.room.reservation_possible(self.begin_time, self.end_time, self.seats):
625
628
                    continue  # Both reservations can take place in the same room
626
629
                raise ValidationError(
627
630
                _("It is not possible to plan this event/reservation in "
628
631
                "%(room)s from %(self_begin)s to %(end_begin)s on %(day)s. "
629
632
                "%(reservator)s has already "
630
633
                "reserved it from %(res_begin)s to %(res_end)s."),
631
634
                params={'room': str(self.room),
632
635
                        'self_begin': self.begin_time.strftime("%H:%M"),
633
636
                        'self_end': self.end_time.strftime("%H:%M"),
634
637
                        'day': self.begin_time.strftime("%A (%d/%m)"),
635
638
                        'reservator': str(reservation.reservator),
636
639
                        'res_begin': reservation.begin_time.strftime("%H:%M"),
637
640
                        'res_end': reservation.end_time.strftime("%H:%M"),
638
641
                        })
639
642
    for course_event in CourseEvent.objects.filter(room=self.room):
640
643
        if overlaps(self.begin_time,
641
644
                    self.end_time,
642
645
                    course_event.begin_time,
643
646
                    course_event.end_time):
644
647
            raise ValidationError(
645
648
                _("%(docent)s has organized a %(subject)s in %(room)s from "
646
649
                    "%(res_begin)s to %(res_end)s on %(day)s, so you cannot "
647
650
                    "place a reservation there from %(self_begin)s to "
648
651
                    "%(self_end)s."),
649
652
                params={'room': str(self.room),
650
653
                        'self_begin': self.begin_time.strftime("%H:%M"),
651
654
                        'self_end': self.end_time.strftime("%H:%M"),
652
655
                        'day': self.begin_time.strftime("%A (%d/%m)"),
653
656
                        'docent': str(course_event.docent),
654
657
                        'subject': course_event.subject,
655
658
                        'res_begin': course_event.begin_time.strftime("%H:%M"),
656
659
                        'res_end': course_event.end_time.strftime("%H:%M"),})
657
660
658
661
    # Checking for correct timings:
659
662
    if self.begin_time >= self.end_time:
660
663
        raise ValidationError(
661
664
            _("The begin time (%(begin)) must take place <em>before</em> "
662
665
                "the end time (%(end))."),
663
666
            params={'begin': self.begin_time.strftime("%H:%M"),
664
667
                    'end': self.end_time.strftime("%H:%M"),})
665
668
    """if not roster.same_day(self.begin_time, self.end_time):
666
669
        raise ValidationError(
667
670
            _("The event/reservation must begin and end on the same day."))"""
668
671
669
672
670
673
class RoomReservation(models.Model):
671
674
    """ Rooms are to be reserved from time to time. They can be reserved
672
675
    by externals, for something else, and whatnot. That is stored in this table.
673
676
    """
674
677
    room = models.ForeignKey(
675
678
        "Room",
676
679
        on_delete=models.CASCADE,
677
680
        null=False,
678
681
        #editable=False,
679
682
        db_index=True,
680
683
        limit_choices_to={"reservable": True},
681
684
        help_text=_("The room that is being reserved at this point."),
682
685
    )
683
686
    reservator = models.ForeignKey(
684
687
        "User",
685
688
        on_delete=models.CASCADE,
686
689
        null=False,
687
690
        #editable=False,
688
691
        help_text=_("The person that made the reservation (and thus responsible)."),
689
692
    )
690
693
    timestamp = models.DateTimeField(auto_now_add=True)
691
694
    begin_time = models.DateTimeField(
692
695
        null=False,
693
696
        help_text=_("The time that this reservation begin."),
694
697
        validators=[validate_event_time,validate_university_hours],
695
698
    )
696
699
    end_time = models.DateTimeField(
697
700
        null=False,
698
701
        help_text=_("The time that this reservation ends."),
699
702
        validators=[validate_event_time,validate_university_hours],
700
703
    )
701
704
    seats = models.PositiveSmallIntegerField(
702
705
        null=True,
703
706
        blank=True,
704
707
        help_text=_("Indicates how many seats are required. If this is left empty, "
705
708
                    "it is assumed the entire room has to be reserved."),
706
709
    )
707
710
    reason = models.CharField(
708
711
        max_length=64,
709
712
        blank=True,
710
713
        help_text=_("The reason for this reservation, if useful."),
711
714
    )
712
715
    note = models.TextField(
713
716
        blank=True,
714
717
        help_text=_("If some additional info is required for this reservation, "
715
718
                    "state it here."),
716
719
    )
717
720
718
721
    def __str__(self):
719
722
        start = self.start_time.strftime("%H:%M")
720
723
        end = self.end_time.strftime("%H:%M")
721
724
        return str(self.room) +" | "+ start +"-"+ end
722
725
723
726
    def clean(self):
724
727
        general_reservation_validator(self)
725
728
726
729
class Degree(models.Model):
727
730
    """ Contains all degrees that were achieved at this university.
728
731
    There are no foreign keys in this field. This allows system
729
732
    administrators to safely remove accounts from alumni, without
730
733
    the risk of breaking referential integrity or accidentally removing
731
734
    degrees.
732
735
    While keeping some fields editable that look like they shouldn't be
733
736
    (e.g. first_name), this makes it possible for alumni to have a name change
734
737
    later in their life, and still being able to get a copy of their degree. """
735
738
    """ Reason for an ID field for every degree:
736
739
    This system allows for employers to verify that a certain applicant has indeed,
737
740
    achieved the degrees (s)he proclaims to have. Because of privacy concerns,
738
741
    a university cannot disclose information about alumni.
739
742
    That's where the degree ID comes in. This ID can be printed on all future
740
743
    degrees. The employer can then visit the university's website, and simply
741
744
    enter the ID. The website will then simply print what study is attached to
742
745
    this degree, but not disclose names or anything identifiable. This strikes
743
746
    thé perfect balance between (easy and digital) degree verification for employers, and maintaining
744
747
    alumni privacy to the highest extent possible. """
745
748
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
746
749
    first_name = models.CharField(
747
750
        max_length=64,
748
751
        blank=False,
749
752
        )
750
753
    last_name = models.CharField(
751
754
        max_length=64,
752
755
        blank=False,
753
756
        )
754
757
    additional_names = models.CharField(
755
758
        max_length=64,
756
759
        blank=True,
757
760
        )
758
761
    DOB = models.DateField(null=False)#editable=False, null=False)  # This can't be changed, of course
759
762
    POB = models.CharField(
760
763
        max_length=64,
761
764
        blank=False,
762
765
        #editable=False,
763
766
        )
764
767
    # The study also has to be a charfield, because if a study is removed,
765
768
    # The information will be lost.
766
769
    study = models.CharField(
767
770
        max_length=64,
768
771
        blank=False,
769
772
        #editable=False,
770
773
        )
771
774
    achieved = models.DateField(null=False)#editable=False, null=False)
772
775
    user = models.ForeignKey(
773
776
        "User",
774
777
        on_delete=models.SET_NULL,
775
778
        null=True,
776
779
        help_text=_("The person that achieved this degree, if (s)he still has "
777
780
                    "an account at this university. If the account is deleted "
778
781
                    "at a later date, this field will be set to NULL, but the "
779
782
                    "other fields will be retained."),
780
783
        )
781
784
782
785
    def __str__(self):
783
786
        return self.first_name +" "+ self.last_name +" | "+ self.study
784
787
785
788
786
789
# Classes regarding roster items
787
790
788
791
789
792
class Event(models.Model):
790
793
    """An event that will show up in the roster of accounts that need to be
791
794
    aware of this event. This can be a multitude of things, like colleges
792
795
    for certain courses, meetings like blood donations, and so on. There are
793
796
    specialized classes for certain types of events that take place."""
794
797
    begin_time = models.DateTimeField(
795
798
        null=False,
796
799
        help_text=_("The begin date and time that this event takes place. "
797
800
                    "This value must be a quarter of an hour (0, 15, 30, 45), "
798
801
                    "and take place <em>before</em> this event's end time."),
799
802
        verbose_name=_("begin time"),
800
803
        validators=[validate_event_time, validate_university_hours],
801
804
        )
802
805
    end_time = models.DateTimeField(
803
806
        null=False,
804
807
        help_text=_("The end date and time that this event takes place. "
805
808
                    "This value must be a quarter of an hour (0, 15, 30, 45), "
806
809
                    "and take place <em>after</em> this event's begin time, "
807
810
                    "but it must end on the same day as it begins!"),
808
811
        verbose_name=_("end time"),
809
812
        validators=[validate_event_time, validate_university_hours],
810
813
        )
811
814
    note = models.TextField(
812
815
        blank=True,
813
816
        help_text=_("Optional. If necessary, this field allows for additional "
814
817
                    "information that can be shown to the people for whom this "
815
818
                    "event is."),
816
819
        )
817
820
    created = models.DateTimeField(
818
821
        auto_now_add=True,
819
822
        )
820
823
    last_update = models.DateTimeField(
821
824
        auto_now=True,
822
825
        )
823
826
824
827
    def recently_created(self):
825
828
        """Indicates if this event was created in the last 5 days."""
826
829
        return (datetime.datetime.now(datetime.timezone.utc) - self.created).days <= 5
827
830
    def recently_updated(self):
828
831
        """Indicates if this event was updated in the last 5 days."""
829
832
        return (datetime.datetime.now(datetime.timezone.utc) - self.last_update).days <= 5
830
833
831
834
class CourseEvent(Event):
832
835
    """An event related to a particular course. This includes a location,
833
836
    a group (if applicable), and other data."""
834
837
    course = models.ForeignKey(
835
838
        "courses.CourseProgramme",
836
839
        on_delete=models.CASCADE,
837
840
        null=False,
838
841
        )
839
842
    docent = models.ForeignKey(
840
843
        "User",
841
844
        on_delete=models.PROTECT,
842
845
        null=False,
843
846
        limit_choices_to={'is_staff': True},
844
847
        help_text=_("The person who will be the main overseer of this event."),
845
848
        )
846
849
    room = models.ForeignKey(
847
850
        "Room",
848
851
        on_delete=models.PROTECT,
849
852
        limit_choices_to={'reservable': True},
850
853
        null=False,
851
854
        help_text=_("The room in which this event will be held."),
852
855
        )
853
856
    subject = models.CharField(
854
857
        max_length=32,
855
858
        blank=False,
856
859
        help_text=_("The subject of this event. Examples are 'Hoorcollege', "
857
860
                    "'Zelfstudie', ..."),
858
861
        )
859
862
    group = models.ForeignKey(
860
863
        "courses.CourseGroup",
861
864
        on_delete = models.CASCADE,
862
865
        null=True,
863
866
        blank=True,
864
867
        help_text=_("Some courses have multiple groups. If that's the case, "
865
868
                    "and this event is only for a specific group, then that "
866
869
                    "group must be referenced here."),
867
870
        )
868
871
869
872
    def clean(self):
870
873
        general_reservation_validator(self)
871
874
872
875
873
876
class UniversityEvent(Event):
874
877
    """University wide events. These include events like blood donations for the
875
878
    Red Cross, for example."""
876
879
    pass
877
880
878
881
class StudyEvent(Event):
879
882
    """An event that is linked to a particular study, like lectures from guest
880
883
    speakers about a certain subject, the Flemish Programming Contest, ..."""
881
884
    pass
882
885
883
886
class ExamCommissionDecision(models.Model):
884
887
    """The Exam commission can make certain decisions regarding individual
885
888
    students. Every decision on its own is stored in this table, and is linked
886
889
    to the recipient's account."""
887
890
    user = models.ForeignKey(
888
891
        User,
889
892
        on_delete=models.CASCADE,
890
893
        null=False,
891
894
        help_text=_("The recipient of this decision."),
892
895
        )
893
896
    date = models.DateField(auto_now_add=True)
894
897
    text = models.TextField(
895
898
        blank=False,
896
899
        help_text=_("The text describing the decision. Org syntax available.")
897
900
        )
898
901
    def __str__(self):
899
902
        return str(self.user) + " | " + str(self.date)
900
903
901
904
    class Meta:
902
905
        verbose_name = _("Decision of the exam commission")
903
906
        verbose_name_plural = _("Decisions of the exam commission")
904
907
905
908
class EducationDepartmentMessages(models.Model):
906
909
    """The department of education can issue messages that are to be shown to
907
910
    all students. Their contents are stored here."""
908
911
    date = models.DateField(auto_now_add=True)
909
912
    title = models.CharField(
910
913
        max_length=64,
911
914
        blank=False,
912
915
        help_text=_("A short, well-describing title for this message."),
913
916
        )
914
917
    text = models.TextField(
915
918
        blank=False,
916
919
        help_text=_("The message text. Org syntax available.")
917
920
        )
918
921
    def __str__(self):
919
922
        return str(self.date) + " | " + str(self.title)
920
923
921
924
    class Meta:
922
925
        verbose_name = _("Message of the education department")
923
926
        verbose_name_plural = _("Messages of the education department")
924
927