M1-Palto-Server/Palto/Palto/models.py
2023-12-10 12:31:30 +01:00

893 lines
30 KiB
Python

"""
Models for the Palto project.
Models are the class that represent and abstract the database.
"""
import uuid
from abc import abstractmethod
from datetime import datetime, timedelta
from typing import Iterable, Callable, Any
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import QuerySet, Q, F
# TODO(Raphaël): split permissions from models for readability
class ModelPermissionHelper:
@classmethod
def can_user_create(cls, user: "User") -> bool:
"""
Return True if the user can create a new instance of this object
"""
return user.is_superuser
@classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
"""
Return a dictionary of field associated to a function giving all the allowed values for this field.
For example, this can be used to check that a user manage a department before allowing modification.
"""
return {}
@classmethod
@abstractmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
"""
Return True if the user can edit this object
"""
@classmethod
@abstractmethod
def all_visible_by_user(cls, user: "User") -> QuerySet:
"""
Return True if the user can see this object
"""
class User(AbstractUser, ModelPermissionHelper):
"""
A user.
Same as the base Django user, but the id is now an uuid instead of an int.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} username={self.username!r}>"
@staticmethod
def multiple_related_departments(users: Iterable["User"]) -> QuerySet["Department"]:
"""
Return all the related departments from multiple users.
"""
return Department.objects.filter(
Q(managers__in=users) |
Q(teachers__in=users) |
Q(students__in=users)
).distinct()
@property
def related_departments(self) -> QuerySet["Department"]:
"""
The list of departments related with the user.
"""
return self.multiple_related_departments([self])
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
if user.is_superuser:
return True
# if the user is managing a department, allow him to create user
if user.managing_departments.count() > 0:
return True
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
queryset = QuerySet()
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
# all the users related to a department the user is managing
if user.managing_departments.count() > 0:
queryset = cls.objects.all()
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
# if the user is in one of the same department as the requesting user
queryset = Department.multiple_related_users(user.related_departments)
return queryset.order_by("pk")
class Department(models.Model, ModelPermissionHelper):
"""
A scholar department.
For example, a same server can handle both a science department and a sport department.
ALl have their own managers, teachers and student.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
name: str = models.CharField(max_length=64, unique=True)
email: str = models.EmailField()
managers = models.ManyToManyField(to=User, blank=True, related_name="managing_departments")
teachers = models.ManyToManyField(to=User, blank=True, related_name="teaching_departments")
students = models.ManyToManyField(to=User, blank=True, related_name="studying_departments")
def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
def __str__(self):
return self.name
@staticmethod
def multiple_related_users(departments: Iterable["Department"]) -> QuerySet["User"]:
"""
Return all the related users from multiple departments.
"""
return User.objects.filter(
Q(managing_departments__in=departments) |
Q(teaching_departments__in=departments) |
Q(studying_departments__in=departments)
).distinct()
@property
def related_users(self) -> QuerySet["User"]:
"""
The list of users related with the department.
"""
return self.multiple_related_users([self])
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
return user.is_superuser
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the manager of the department
managers=user,
)
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
# everybody can see all the departments
queryset = cls.objects.all()
return queryset.order_by("pk")
class StudentGroup(models.Model, ModelPermissionHelper):
"""
A student group.
This make selecting multiple students with a specificity easier.
For example, if students are registered to an English course,
putting them in a same group make them easier to select.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
name: str = models.CharField(max_length=128)
department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="student_groups")
owner = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="owning_groups")
students = models.ManyToManyField(to=User, blank=True, related_name="student_groups")
def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
def __str__(self):
return self.name
# validators
def clean(self):
super().clean()
# owner check
if self.department not in self.owner.teaching_departments:
raise ValidationError("The owner is not related to the department.")
# students check
if not all(self.department in student.studying_departments for student in self.students.all()):
raise ValidationError("A student is not related to the department.")
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
if user.is_superuser:
return True
# if the user is managing a department
if user.managing_departments.count() > 0:
return True
# if the user is teaching a department
if user.teaching_departments.count() > 0:
return True
@classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# the user can only interact with a related departments
"department": lambda data: user.managing_departments | user.teaching_departments,
# the owner must be a teacher or a manager of this department
"owner": lambda data: data["department"].managers | data["department"].teachers,
}
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the owner of the group, allow write
Q(owner=user) |
# if the user is a department manager, allow write
Q(department__managers=user)
).distinct()
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the owner of the group, allow read
Q(owner=user) |
# if the user is one of the student, allow read
Q(students=user) |
# if the user is a department manager, allow read
Q(department__managers=user) |
# if the user is one of the teachers, allow read
Q(department__teachers=user)
).distinct()
return queryset.order_by("pk")
class TeachingUnit(models.Model, ModelPermissionHelper):
"""
A teaching unit.
This represents a unit that can be taught to groups of student.
For example, Maths, English, French, Computer Science are all teaching units.
The registered groups are groups of student allowed to participate in these units.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
name: str = models.CharField(max_length=64)
department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="teaching_units")
managers = models.ManyToManyField(to=User, blank=True, related_name="managing_units")
teachers = models.ManyToManyField(to=User, blank=True, related_name="teaching_units")
student_groups = models.ManyToManyField(to=StudentGroup, blank=True, related_name="studying_units")
def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
def __str__(self):
return self.name
# validations
def clean(self):
super().clean()
# managers check
if not all(self.department in manager.managing_departments for manager in self.managers.all()):
raise ValidationError("A manager is not related to the department.")
# teachers check
if not all(self.department in teacher.teaching_departments for teacher in self.teachers.all()):
raise ValidationError("A teacher is not related to the department.")
# student groups check
if not all(self.department in student_group.department for student_group in self.student_groups.all()):
raise ValidationError("A student group is not related to the department.")
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
if user.is_superuser:
return True
# if the user is managing a department
if user.managing_departments.count() > 0:
return True
@classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# a user can only interact with a related departments
"department": lambda data: user.managing_departments | user.teaching_departments
}
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is a manager of the department, allow write
Q(department__managers=user) |
# if the user is the manager of the unit, allow write
Q(managers=user)
).distinct()
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is a manager of the department, allow read
Q(department__managers=user) |
# if the user is the manager of the unit, allow read
Q(managers=user) |
# if the department is related to the user, allow read
Q(department=user.related_departments)
).distinct()
return queryset.order_by("pk")
class StudentCard(models.Model, ModelPermissionHelper):
"""
A student card.
This represents a student NFC card.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
uid: bytes = models.BinaryField(max_length=7)
department: Department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="student_cards")
owner: User = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="student_cards")
def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} owner={self.owner.username!r}>"
# validations
def clean(self):
super().clean()
# owner check
if self.department not in self.owner.studying_departments:
raise ValidationError("The student is not related to the department.")
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
if user.is_superuser:
return True
if user.managing_departments.count() > 0:
return True
@classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[Any, dict], bool]]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# a user can only interact with a related departments
"department": lambda field, data: field in user.managing_departments,
}
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is a manager of the department
Q(department__managers=user)
).distinct()
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the owner
Q(owner=user) |
# if the user is a manager of the department
Q(department__managers=user)
).distinct()
return queryset.order_by("pk")
class TeachingSession(models.Model, ModelPermissionHelper):
"""
A session of a teaching unit.
For example, a session of English would be a single course of this unit.
It references a teacher responsible for scanning the student cards, student attendances and student absences.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
start: datetime = models.DateTimeField()
duration: timedelta = models.DurationField()
note: str = models.TextField(blank=True)
unit = models.ForeignKey(to=TeachingUnit, on_delete=models.CASCADE, related_name="sessions")
group = models.ForeignKey(to=StudentGroup, on_delete=models.CASCADE, related_name="teaching_sessions")
teacher = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="teaching_sessions")
def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} unit={self.unit.name!r} start={self.start}>"
def __str__(self):
return f"{self.unit.name} ({self.start})"
@property
def end(self) -> datetime:
return self.start + self.duration
# validations
def clean(self):
super().clean()
# group check
if self.unit.department not in self.group.department:
raise ValidationError("The group is not related to the unit department.")
# teacher check
if self.unit not in self.teacher.teaching_units:
raise ValidationError("The teacher is not related to the unit.")
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
if user.is_superuser:
return True
# if the user is managing a department
if user.managing_departments.count() > 0:
return True
# if the user is managing a unit
if user.managing_units.count() > 0:
return True
# if the user is teaching a unit
if user.teaching_units.count() > 0:
return True
@classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[Any, dict], bool]]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# the managers can only interact with their units
"unit": lambda data: (
# all the units the user is managing
user.managing_units |
# all the units the user is teaching
user.teaching_units |
# all the units of the department the user is managing
TeachingUnit.objects.filter(pk__in=user.managing_departments.values("teaching_units"))
)
}
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the teacher, allow write
Q(teacher=user) |
# if the user is managing the unit, allow write
Q(unit__managers=user) |
# if the user is managing the department, allow write
Q(unit__department__managers=user)
).distinct()
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the teacher, allow read
Q(teacher=user) |
# if the user is managing the unit, allow read
Q(unit__managers=user) |
# if the user is managing the department, allow read
Q(unit__department__managers=user) |
# if the user is part of the group, allow read
Q(group__students=user)
).distinct()
return queryset.order_by("pk")
class Attendance(models.Model, ModelPermissionHelper):
"""
A student attendance to a session.
When a student confirm his presence to a session, this is represented by this model.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
date: datetime = models.DateTimeField()
student: User = models.ForeignKey(
to=User,
on_delete=models.CASCADE,
related_name="attended_sessions"
)
session: TeachingSession = models.ForeignKey(
to=TeachingSession,
on_delete=models.CASCADE,
related_name="attendances"
)
def __repr__(self):
return (
f"<{self.__class__.__name__} "
f"id={str(self.id)[:8]} "
f"student={self.student.username} "
f"session={str(self.session.id)[:8]}"
f">"
)
# validations
def clean(self):
super().clean()
# student check
if self.student not in self.session.group.students:
raise ValidationError("The student is not related to the student group.")
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
if user.is_superuser:
return True
# if the user is managing a department
if user.managing_departments.count() > 0:
return True
# if the user is managing a unit
if user.managing_units.count() > 0:
return True
# if the user is teaching a unit
if user.teaching_units.count() > 0:
return True
@classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[Any, dict], bool]]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
"session": lambda data: (
# the sessions that the user has taught
user.teaching_sessions |
# a session of a unit the user is managing
TeachingSession.objects.filter(pk__in=user.managing_units.values("sessions")) |
# all the sessions in a department the user is managing
TeachingSession.objects.filter(
pk__in=TeachingUnit.objects.filter(
pk__in=user.managing_departments.values("teaching_units")
).values("sessions")
)
)
}
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user was the teacher, allow write
Q(session__teacher=user) |
# if the user is manager of the unit, allow write
Q(session__unit__managers=user) |
# if the user is manager of the department, allow write
Q(session__unit__department__managers=user)
).distinct()
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user was the teacher, allow read
Q(session__teacher=user) |
# if the user is manager of the unit, allow read
Q(session__unit__managers=user) |
# if the user is manager of the department, allow read
Q(session__unit__department__managers=user) |
# if the user is the student, allow read
Q(student=user)
).distinct()
return queryset.order_by("pk")
class Absence(models.Model, ModelPermissionHelper):
"""
A student justified absence to a session.
When a student signal his absence to a session, this is represented by this model.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
message: str = models.TextField()
department: Department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="absences")
student: User = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="absences")
start: datetime = models.DateTimeField()
end: datetime = models.DateTimeField()
def __repr__(self):
return (
f"<{self.__class__.__name__} "
f"id={str(self.id)[:8]} "
f"department={self.department} "
f"student={self.student.username} "
f"start={self.start} "
f"end={self.end}"
f">"
)
def __str__(self):
return f"[{str(self.id)[:8]}] {self.student}"
# validations
def clean(self):
super().clean()
# student check
if self.department not in self.student.studying_departments:
raise ValidationError("The student is not related to the department.")
# properties
def related_sessions(self) -> QuerySet[TeachingSession]:
"""
Return the sessions that match the user absence
"""
return TeachingSession.objects.filter(
# every session where the student participate
Q(group__students=self.student) &
# every session that start between the start and the end of our absence
Q(start__range=(self.start, self.end))
).distinct()
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
if user.is_superuser:
return True
# if the user is a student
if user.studying_departments.count() > 0:
return True
@classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# all the departments the user is studying in
"department": lambda data: user.studying_departments,
}
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the student, allow write
Q(student=user)
).distinct()
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the student, allow read
Q(student=user) |
# if the user is related with the session, allow read
(
# if the sessions start between the start and the end of the absence
Q(department__teaching_units__sessions__start__range=(F("start"), F("end"))) &
(
# the user is a manager of the department
Q(department__managers=user) |
# the user is a manager of the unit
Q(department__teaching_units__teachers=user) |
# the user is the teacher of the session
Q(department__teaching_units__sessions__teacher=user)
)
)
).distinct()
return queryset.order_by("pk")
class AbsenceAttachment(models.Model, ModelPermissionHelper):
"""
An attachment to a student justified absence.
The student can add additional files to justify his absence.
"""
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
content = models.FileField(upload_to="absence/attachment/")
absence = models.ForeignKey(to=Absence, on_delete=models.CASCADE, related_name="attachments")
def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} content={self.content!r}>"
# permissions
@classmethod
def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin
if user.is_superuser:
return True
# if the user is a student
if user.objects.count():
return True
@classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# all the departments the user is studying in
"absence": lambda data: user.absences,
}
@classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet:
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the student, allow write
Q(absence__student=user)
).distinct()
return queryset.order_by("pk")
@classmethod
def all_visible_by_user(cls, user: "User"):
if user.is_superuser:
# if the requesting user is admin
queryset = cls.objects.all()
else:
queryset = cls.objects.filter(
# if the user is the student, allow read
Q(absence__student=user) |
# if the user is related with the session, allow read
(
# if the sessions start between the start and the end of the absence
Q(absence__department__teaching_units__sessions__start__range=(F("start"), F("end"))) &
(
# the user is a manager of the department
Q(absence__department__managers=user) |
# the user is a manager of the unit
Q(absence__department__teaching_units__teachers=user) |
# the user is the teacher of the session
Q(absence__department__teaching_units__sessions__teacher=user)
)
)
).distinct()
return queryset.order_by("pk")