joeni

models.py

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