finished permissions

This commit is contained in:
Faraphel 2023-12-10 12:31:30 +01:00
parent cde0cc0dd6
commit 5c588b4cc9
4 changed files with 135 additions and 224 deletions

View file

@ -27,8 +27,6 @@ def permission_from_helper_class(model: Type[models.ModelPermissionHelper]) -> T
# for writing, only allowed users # for writing, only allowed users
return True return True
return False
def has_object_permission(self, request, view, obj: models.User) -> bool: def has_object_permission(self, request, view, obj: models.User) -> bool:
if request.method in permissions.SAFE_METHODS: if request.method in permissions.SAFE_METHODS:
# for reading, only allow if the user can see the object # for reading, only allow if the user can see the object

View file

@ -5,6 +5,7 @@ A serializers tell the API how should a model should be serialized to be used by
""" """
from typing import Type from typing import Type
from django.forms import model_to_dict
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@ -25,31 +26,31 @@ class ModelSerializerContrains(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
# get the fields that this user can modify # get the fields that this user can modify
field_contrains = self.Meta.model.user_fields_contraints(self.context["request"].user) field_contraints = self.Meta.model.user_fields_contraints(self.context["request"].user)
# for every constraints # for every constraint
for field, constraints in field_contrains.items(): for field, constraints in field_contraints.items():
# check if the value is in the constraints. # check if the value is in the constraints.
value = validated_data.get(field) value = validated_data.get(field)
if value is not None and value not in constraints: if value in constraints(validated_data):
raise PermissionDenied(f"You are not allowed to use this value for the field {field}.") raise PermissionDenied(f"You are not allowed to use this value for the field {field}.")
return super().create(validated_data) return super().create(validated_data)
def update(self, instance, validated_data): def update(self, instance, validated_data):
# get the fields that this user can modify # get the fields that this user can modify
field_contrains = self.Meta.model.user_fields_contraints(self.context["request"].user) field_constraints = self.Meta.model.user_fields_contraints(self.context["request"].user)
# for every constraints # for every constraint
for field, constraints in field_contrains.items(): for field, constraints in field_constraints.items():
# check if the value of the request is in the constraints. # check if the value of the request is in the constraints.
value = validated_data.get(field) value = validated_data.get(field)
if value is not None and value not in constraints: if value in constraints(validated_data):
raise PermissionDenied(f"You are not allowed to use this value for the field {field}.") raise PermissionDenied(f"You are not allowed to use this value for the field {field}.")
# check if the value of the already existing instance is in the constraints. # check if the value of the already existing instance is in the constraints.
value = getattr(instance, field, None) value = getattr(instance, field, None)
if value is not None and value not in constraints: if value in constraints(model_to_dict(instance)):
raise PermissionDenied(f"You are not allowed to use this value for the field {field}.") raise PermissionDenied(f"You are not allowed to use this value for the field {field}.")
# check that the user is managing the department # check that the user is managing the department

View file

@ -119,54 +119,6 @@ class DepartmentApiTestCase(test.APITestCase):
response = self.client.post("/api/v1/departments/", data=self.DEPARTMENT_CREATION_DATA) response = self.client.post("/api/v1/departments/", data=self.DEPARTMENT_CREATION_DATA)
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_permission_anonymous(self):
""" Test the API permission for an anonymous user """
# TODO: use reverse to get the url ?
# TODO: use api endpoint as class attribute ?
self.client.logout()
# check for a get request
response = self.client.get("/api/v1/departments/")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# check for a post request
response = self.client.post("/api/v1/departments/", data=self.DEPARTMENT_CREATION_DATA)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_permission_unrelated(self):
""" Test the API permission for an unrelated user """
# TODO: use reverse to get the url ?
self.client.force_login(self.user_other)
# check for a get request and that he can't see anything
response = self.client.get("/api/v1/departments/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["count"], 0)
# check for a post request
response = self.client.post("/api/v1/departments/", data=self.DEPARTMENT_CREATION_DATA)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_permission_related(self):
""" Test the API permission for a related user """
# TODO: use reverse to get the url ?
student1, student2 = factories.FakeUserFactory(), factories.FakeUserFactory()
department = factories.FakeDepartmentFactory(students=(student1, student2))
self.client.force_login(student1)
# check for a get request and that he can see the other student
response = self.client.get("/api/v1/departments/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn(serializers.UserSerializer(student2).data, response.json()["results"])
# check for a post request
response = self.client.post("/api/v1/departments/", data=self.DEPARTMENT_CREATION_DATA)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
class StudentGroupApiTestCase(test.APITestCase): class StudentGroupApiTestCase(test.APITestCase):
def setUp(self): def setUp(self):
@ -225,58 +177,6 @@ class StudentGroupApiTestCase(test.APITestCase):
response = self.client.post("/api/v1/student_groups/", data=self.student_group_creation_data) response = self.client.post("/api/v1/student_groups/", data=self.student_group_creation_data)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_permission_unrelated(self):
""" Test the API permission for an unrelated user """
# TODO: use reverse to get the url ?
for user in (self.user_other, *self.test_students_other):
self.client.force_login(user)
# check for a get request and that he can't see anything
response = self.client.get("/api/v1/student_groups/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["count"], 0)
# check for a post request
response = self.client.post("/api/v1/student_groups/", data=self.student_group_creation_data)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_permission_related(self):
""" Test the API permission for a related user """
for user in self.test_students_group:
# TODO: use reverse to get the url ?
self.client.force_login(user)
# check for a get request and that he can see the students
response = self.client.get("/api/v1/student_groups/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
list(serializers.UserSerializer(student).data for student in self.test_students_group),
response.json()["results"]["students"]
)
# check for a post request
response = self.client.post("/api/v1/student_groups/", data=self.student_group_creation_data)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_permission_owner(self):
""" Test the API permission for the owner """
# TODO: use reverse to get the url ?
self.client.force_login(self.test_teacher_owner)
# check for a get request and that he can see the students
response = self.client.get("/api/v1/student_groups/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
list(serializers.UserSerializer(student).data for student in self.test_students_group),
response.json()["results"]["students"]
)
# check for a post request
response = self.client.post("/api/v1/student_groups/", data=self.student_group_creation_data)
# TODO: autorisé ?
class TeachingUnitApiTestCase(test.APITestCase): class TeachingUnitApiTestCase(test.APITestCase):
pass pass

View file

@ -3,21 +3,19 @@ Models for the Palto project.
Models are the class that represent and abstract the database. Models are the class that represent and abstract the database.
""" """
import operator
import uuid import uuid
from abc import abstractmethod from abc import abstractmethod
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import reduce from typing import Iterable, Callable, Any
from typing import Iterable
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import QuerySet, Q, F from django.db.models import QuerySet, Q, F
# TODO(Raphaël): split permissions from models for readability # TODO(Raphaël): split permissions from models for readability
# TODO(Raphaël): allow other function for permissions than in
class ModelPermissionHelper: class ModelPermissionHelper:
@ -30,9 +28,10 @@ class ModelPermissionHelper:
return user.is_superuser return user.is_superuser
@classmethod @classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, QuerySet]: def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
""" """
Return the list of fields in that model that the user can modify 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 {} return {}
@ -134,9 +133,9 @@ class Department(models.Model, ModelPermissionHelper):
name: str = models.CharField(max_length=64, unique=True) name: str = models.CharField(max_length=64, unique=True)
email: str = models.EmailField() email: str = models.EmailField()
managers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="managing_departments") managers = models.ManyToManyField(to=User, blank=True, related_name="managing_departments")
teachers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="teaching_departments") teachers = models.ManyToManyField(to=User, blank=True, related_name="teaching_departments")
students = models.ManyToManyField(to=get_user_model(), blank=True, related_name="studying_departments") students = models.ManyToManyField(to=User, blank=True, related_name="studying_departments")
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>" return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
@ -205,9 +204,9 @@ class StudentGroup(models.Model, ModelPermissionHelper):
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36) id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
name: str = models.CharField(max_length=128) name: str = models.CharField(max_length=128)
owner = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name="owning_groups")
department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="student_groups") department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="student_groups")
students = models.ManyToManyField(to=get_user_model(), blank=True, 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): def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>" return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
@ -215,6 +214,19 @@ class StudentGroup(models.Model, ModelPermissionHelper):
def __str__(self): def __str__(self):
return self.name 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 # permissions
@classmethod @classmethod
@ -232,14 +244,16 @@ class StudentGroup(models.Model, ModelPermissionHelper):
return True return True
@classmethod @classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, QuerySet]: def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
# if the user is admin, no contrains # if the user is admin, no contrains
if user.is_superuser: if user.is_superuser:
return {} return {}
return { return {
# the managers and teachers can only interact with their departments # the user can only interact with a related departments
"department": (user.managing_departments | user.teaching_departments).distinct() "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 @classmethod
@ -292,8 +306,8 @@ class TeachingUnit(models.Model, ModelPermissionHelper):
department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="teaching_units") department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="teaching_units")
managers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="managing_units") managers = models.ManyToManyField(to=User, blank=True, related_name="managing_units")
teachers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="teaching_units") teachers = models.ManyToManyField(to=User, blank=True, related_name="teaching_units")
student_groups = models.ManyToManyField(to=StudentGroup, blank=True, related_name="studying_units") student_groups = models.ManyToManyField(to=StudentGroup, blank=True, related_name="studying_units")
def __repr__(self): def __repr__(self):
@ -302,6 +316,23 @@ class TeachingUnit(models.Model, ModelPermissionHelper):
def __str__(self): def __str__(self):
return self.name 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 # permissions
@classmethod @classmethod
@ -315,14 +346,14 @@ class TeachingUnit(models.Model, ModelPermissionHelper):
return True return True
@classmethod @classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, QuerySet]: def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
# if the user is admin, no contrains # if the user is admin, no contrains
if user.is_superuser: if user.is_superuser:
return {} return {}
return { return {
# the managers can only interact with their departments # a user can only interact with a related departments
"department": user.managing_departments "department": lambda data: user.managing_departments | user.teaching_departments
} }
@classmethod @classmethod
@ -369,11 +400,20 @@ class StudentCard(models.Model, ModelPermissionHelper):
uid: bytes = models.BinaryField(max_length=7) uid: bytes = models.BinaryField(max_length=7)
department: Department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="student_cards") department: Department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="student_cards")
owner: User = models.ForeignKey(to=get_user_model(), 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): def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} owner={self.owner.username!r}>" 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 # permissions
@classmethod @classmethod
@ -386,19 +426,14 @@ class StudentCard(models.Model, ModelPermissionHelper):
return True return True
@classmethod @classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, QuerySet]: def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[Any, dict], bool]]:
# if the user is admin, no contrains # if the user is admin, no contrains
if user.is_superuser: if user.is_superuser:
return {} return {}
return { return {
# the managers can only interact with their departments # a user can only interact with a related departments
"department": user.managing_departments, "department": lambda field, data: field in user.managing_departments,
# the owner of the card can be any students in a department that is managed by the user
"owner": reduce(
operator.or_,
(department.students.all() for department in user.managing_departments)
)
} }
@classmethod @classmethod
@ -447,7 +482,7 @@ class TeachingSession(models.Model, ModelPermissionHelper):
unit = models.ForeignKey(to=TeachingUnit, on_delete=models.CASCADE, related_name="sessions") 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") group = models.ForeignKey(to=StudentGroup, on_delete=models.CASCADE, related_name="teaching_sessions")
teacher = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name="teaching_sessions") teacher = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="teaching_sessions")
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__} id={str(self.id)[:8]} unit={self.unit.name!r} start={self.start}>" return f"<{self.__class__.__name__} id={str(self.id)[:8]} unit={self.unit.name!r} start={self.start}>"
@ -459,6 +494,19 @@ class TeachingSession(models.Model, ModelPermissionHelper):
def end(self) -> datetime: def end(self) -> datetime:
return self.start + self.duration 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 # permissions
@classmethod @classmethod
@ -480,47 +528,21 @@ class TeachingSession(models.Model, ModelPermissionHelper):
return True return True
@classmethod @classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, QuerySet]: def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[Any, dict], bool]]:
# if the user is admin, no contrains # if the user is admin, no contrains
if user.is_superuser: if user.is_superuser:
return {} return {}
return { return {
# the managers can only interact with their departments # the managers can only interact with their units
"department": (user.managing_departments | user.teaching_departments).distinct(), "unit": lambda data: (
"teacher": # all the units the user is managing
# the teacher can be any teacher in a department that the user is managing user.managing_units |
reduce( # all the units the user is teaching
operator.or_, user.teaching_units |
(department.teachers.all() for department in user.managing_departments) # all the units of the department the user is managing
) | reduce( TeachingUnit.objects.filter(pk__in=user.managing_departments.values("teaching_units"))
# or a teacher in a unit that the user is managing )
operator.or_,
(department.teachers.all() for department in user.managing_units)
) | (
# or the user itself
User.objects.filter(pk=user.pk)
),
"unit":
# the unit can be any unit in the department that the user is managing
reduce(
operator.or_,
(department.teaching_units.all() for department in user.managing_departments)
) | (
# or the units that the user is teaching
user.teaching_sessions
),
"group":
# any group of a department where the user is a manager
reduce(
operator.or_,
(department.student_groups for department in user.managing_departments)
) |
# any group where the user is a manager or a teacher of a unit
reduce(
operator.or_,
(unit.student_groups for unit in (user.managing_units | user.teaching_units))
)
} }
@classmethod @classmethod
@ -571,7 +593,7 @@ class Attendance(models.Model, ModelPermissionHelper):
date: datetime = models.DateTimeField() date: datetime = models.DateTimeField()
student: User = models.ForeignKey( student: User = models.ForeignKey(
to=get_user_model(), to=User,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="attended_sessions" related_name="attended_sessions"
) )
@ -590,6 +612,15 @@ class Attendance(models.Model, ModelPermissionHelper):
f">" 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 # permissions
@classmethod @classmethod
@ -611,50 +642,24 @@ class Attendance(models.Model, ModelPermissionHelper):
return True return True
@classmethod @classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, QuerySet]: def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[Any, dict], bool]]:
# if the user is admin, no contrains # if the user is admin, no contrains
if user.is_superuser: if user.is_superuser:
return {} return {}
return { return {
# the managers can only interact with their departments "session": lambda data: (
"department": user.managing_departments | user.teaching_departments, # the sessions that the user has taught
"student": user.teaching_sessions |
# student can be any student from a department the user is managing or teaching # a session of a unit the user is managing
reduce( TeachingSession.objects.filter(pk__in=user.managing_units.values("sessions")) |
operator.or_, # all the sessions in a department the user is managing
( TeachingSession.objects.filter(
department.students.all() pk__in=TeachingUnit.objects.filter(
for department in (user.managing_departments | user.teaching_departments) pk__in=user.managing_departments.values("teaching_units")
) ).values("sessions")
) |
# or any student from a unit the user is managing or teaching
reduce(
operator.or_,
(
student_group.students.all()
for unit in (user.managing_units | user.teaching_units)
for student_group in unit.student_groups
)
),
"session":
# the session can be any session where the user is managing the department
reduce(
operator.or_,
(
unit.sessions
for department in user.managing_departments
for unit in department.teaching_units
)
) |
# or where is the user is a teacher
reduce(
operator.or_,
(
unit.sessions
for unit in (user.teaching_units | user.managing_units)
)
) )
)
} }
@classmethod @classmethod
@ -706,7 +711,7 @@ class Absence(models.Model, ModelPermissionHelper):
message: str = models.TextField() message: str = models.TextField()
department: Department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="absences") department: Department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="absences")
student: User = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name="absences") student: User = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="absences")
start: datetime = models.DateTimeField() start: datetime = models.DateTimeField()
end: datetime = models.DateTimeField() end: datetime = models.DateTimeField()
@ -724,6 +729,15 @@ class Absence(models.Model, ModelPermissionHelper):
def __str__(self): def __str__(self):
return f"[{str(self.id)[:8]}] {self.student}" 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 # properties
def related_sessions(self) -> QuerySet[TeachingSession]: def related_sessions(self) -> QuerySet[TeachingSession]:
@ -751,16 +765,14 @@ class Absence(models.Model, ModelPermissionHelper):
return True return True
@classmethod @classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, QuerySet]: def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
# if the user is admin, no contrains # if the user is admin, no contrains
if user.is_superuser: if user.is_superuser:
return {} return {}
return { return {
# all the departments the user is studying in # all the departments the user is studying in
"department": user.studying_departments, "department": lambda data: user.studying_departments,
# the student itself
"student": User.objects.filter(pk=user.pk),
} }
@classmethod @classmethod
@ -831,14 +843,14 @@ class AbsenceAttachment(models.Model, ModelPermissionHelper):
return True return True
@classmethod @classmethod
def user_fields_contraints(cls, user: "User") -> dict[str, QuerySet]: def user_fields_contraints(cls, user: "User") -> dict[str, Callable[[dict], QuerySet]]:
# if the user is admin, no contrains # if the user is admin, no contrains
if user.is_superuser: if user.is_superuser:
return {} return {}
return { return {
# all the departments the user is studying in # all the departments the user is studying in
"absence": user.absences, "absence": lambda data: user.absences,
} }
@classmethod @classmethod