[untested] finished models permissions

This commit is contained in:
faraphel 2023-12-06 14:04:30 +01:00
parent 7acc292bad
commit 5ce9a808aa
3 changed files with 413 additions and 46 deletions

View file

@ -3,67 +3,111 @@ Serializers for the Palto project's API v1.
A serializers tell the API how should a model should be serialized to be used by an external user. A serializers tell the API how should a model should be serialized to be used by an external user.
""" """
from typing import Type
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from Palto.Palto import models from Palto.Palto import models
# TODO(Raphaël): Voir pour les related_name # TODO: voir les relations inversées ?
class UserSerializer(serializers.ModelSerializer): class ModelSerializerContrains(serializers.ModelSerializer):
"""
Similar to the base ModelSerializer, but automatically check for contrains for the user
when trying to create a new instance or modifying a field.
"""
class Meta:
model: Type[models.ModelPermissionHelper]
def create(self, validated_data):
# get the fields that this user can modify
field_contrains = self.Meta.model.user_fields_contrains(self.context["request"].user)
# for every constrains
for field, constrains in field_contrains.items():
# check if the value is in the constrains.
value = validated_data.get(field)
if value is not None and value not in constrains:
raise PermissionDenied(f"You are not allowed to use this value for the field {field}.")
return super().create(validated_data)
def update(self, instance, validated_data):
# get the fields that this user can modify
field_contrains = self.Meta.model.user_fields_contrains(self.context["request"].user)
# for every constrains
for field, constrains in field_contrains.items():
# check if the value of the request is in the constrains.
value = validated_data.get(field)
if value is not None and value not in constrains:
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 constrains.
value = getattr(instance, field, None)
if value is not None and value not in constrains:
raise PermissionDenied(f"You are not allowed to use this value for the field {field}.")
# check that the user is managing the department
if instance.department not in self.context["request"].user.managing_departments:
raise PermissionDenied("You don't manage this department.")
return super().update(instance, validated_data)
class UserSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.User model = models.User
fields = ['id', 'username', 'first_name', 'last_name', 'email'] fields = ['id', 'username', 'first_name', 'last_name', 'email']
class DepartmentSerializer(serializers.ModelSerializer): class DepartmentSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.Department model = models.Department
fields = ['id', 'name', 'email', 'managers'] fields = ['id', 'name', 'email', 'managers', 'teachers', 'students']
# NOTE: teachers, students
class StudentGroupSerializer(serializers.ModelSerializer): class StudentGroupSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.StudentGroup model = models.StudentGroup
fields = ['id', 'name', 'owner', 'department'] fields = ['id', 'name', 'owner', 'department', 'students']
# NOTE: students
class TeachingUnitSerializer(serializers.ModelSerializer): class TeachingUnitSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.TeachingUnit model = models.TeachingUnit
fields = ['id', 'name', 'department'] fields = ['id', 'name', 'department', 'managers', 'teachers', 'student_groups']
# NOTE: managers, teachers, student_groups
class StudentCardSerializer(serializers.ModelSerializer): class StudentCardSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.StudentCard model = models.StudentCard
fields = ['id', 'uid', 'department', 'owner'] fields = ['id', 'uid', 'department', 'owner']
class TeachingSessionSerializer(serializers.ModelSerializer): class TeachingSessionSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.TeachingSession model = models.TeachingSession
fields = ['id', 'start', 'duration', 'note', 'unit', 'group', 'teacher'] fields = ['id', 'start', 'duration', 'note', 'unit', 'group', 'teacher']
class AttendanceSerializer(serializers.ModelSerializer): class AttendanceSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.Attendance model = models.Attendance
fields = ['id', 'date', 'student', 'session'] fields = ['id', 'date', 'student', 'session']
class AbsenceSerializer(serializers.ModelSerializer): class AbsenceSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.Absence model = models.Absence
fields = ['id', 'message', 'student', 'session'] fields = ['id', 'message', 'student', 'session']
class AbsenceAttachmentSerializer(serializers.ModelSerializer): class AbsenceAttachmentSerializer(ModelSerializerContrains):
class Meta: class Meta:
model = models.AbsenceAttachment model = models.AbsenceAttachment
fields = ['id', 'content', 'absence'] fields = ['id', 'content', 'absence']

View file

@ -158,14 +158,10 @@ class DepartmentApiTestCase(test.APITestCase):
self.client.force_login(student1) self.client.force_login(student1)
"""
TODO: this test require to show the field students before creating it.
# check for a get request and that he can see the other student # check for a get request and that he can see the other student
response = self.client.get("/api/v1/departments/") response = self.client.get("/api/v1/departments/")
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn(serializers.UserSerializer(student2).data, response.json()["results"]) self.assertIn(serializers.UserSerializer(student2).data, response.json()["results"])
"""
# check for a post request # check for a post request
response = self.client.post("/api/v1/departments/", data=self.DEPARTMENT_CREATION_DATA) response = self.client.post("/api/v1/departments/", data=self.DEPARTMENT_CREATION_DATA)
@ -173,8 +169,114 @@ class DepartmentApiTestCase(test.APITestCase):
class StudentGroupApiTestCase(test.APITestCase): class StudentGroupApiTestCase(test.APITestCase):
pass def setUp(self):
self.user_admin = factories.FakeUserFactory(is_superuser=True)
self.user_other = factories.FakeUserFactory()
# fake group creation data
self.test_manager_related = factories.FakeUserFactory()
self.test_manager_other = factories.FakeUserFactory()
self.test_teacher_owner = factories.FakeUserFactory()
self.test_teacher_other = factories.FakeUserFactory()
self.test_students_group = [factories.FakeUserFactory() for _ in range(10)]
self.test_students_other = [factories.FakeUserFactory() for _ in range(10)]
self.test_department = factories.FakeDepartmentFactory(
managers=[self.test_manager_related],
teachers=[self.test_teacher_owner, self.test_teacher_other],
students=[*self.test_students_group, *self.test_students_other],
)
self.student_group_creation_data: dict = {
"name": "Groupe 1",
"owner": self.test_teacher_owner.pk,
"department": self.test_department.pk,
"students": map(lambda obj: obj.pk, self.test_students_group)
}
def test_permission_admin(self):
""" Test the API permission for an administrator """
# TODO: use reverse to get the url ?
self.client.force_login(self.user_admin)
# check for a get request
response = self.client.get("/api/v1/student_groups/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["count"], models.StudentGroup.objects.count())
# 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_201_CREATED)
def test_permission_anonymous(self):
""" Test the API permission for an anonymous user """
# TODO: use reverse to get the url ?
self.client.logout()
# check for a get request
response = self.client.get("/api/v1/student_groups/")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
# 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_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,10 +3,11 @@ 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 from typing import Iterable
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -15,15 +16,27 @@ from django.db import models
from django.db.models import QuerySet, Q, F from django.db.models import QuerySet, Q, F
class ModelPermissionHelper: # TODO(Raphaël): split permissions from models for readability
# TODO(Raphaël): allow other function for permissions than in
class ModelPermissionHelper:
@classmethod @classmethod
@abstractmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
""" """
Return True if the user can create a new instance of this object Return True if the user can create a new instance of this object
""" """
return user.is_superuser
@classmethod
def user_fields_contrains(cls, user: "User") -> dict[str, QuerySet]:
"""
Return the list of fields in that model that the user can modify
"""
return {}
@classmethod @classmethod
@abstractmethod @abstractmethod
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet:
@ -76,17 +89,24 @@ class User(AbstractUser, ModelPermissionHelper):
@classmethod @classmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin # if the requesting user is admin
return user.is_superuser if user.is_superuser:
# TODO: propriétaire d'établissement return True
# if the user is managing a department, allow him to create user
if user.managing_departments.count() > 0:
return True
@classmethod @classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet:
queryset = QuerySet()
if user.is_superuser: if user.is_superuser:
# if the requesting user is admin # if the requesting user is admin
queryset = cls.objects.all() queryset = cls.objects.all()
else: else:
queryset = QuerySet() # all the users related to a department the user is managing
# TODO: propriétaire d'établissement if user.managing_departments.count() > 0:
queryset = cls.objects.all()
return queryset.order_by("pk") return queryset.order_by("pk")
@ -157,8 +177,10 @@ class Department(models.Model, ModelPermissionHelper):
# if the requesting user is admin # if the requesting user is admin
queryset = cls.objects.all() queryset = cls.objects.all()
else: else:
queryset = QuerySet() queryset = cls.objects.filter(
# TODO: propriétaire d'établissement ? # if the user is the manager of the department
managers=user,
)
return queryset.order_by("pk") return queryset.order_by("pk")
@ -198,9 +220,27 @@ class StudentGroup(models.Model, ModelPermissionHelper):
@classmethod @classmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin # if the requesting user is admin
return user.is_superuser if user.is_superuser:
# TODO: department managers can create group return True
# TODO: can teacher create group ?
# 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_contrains(cls, user: "User") -> dict[str, QuerySet]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# the managers and teachers can only interact with their departments
"department": (user.managing_departments | user.teaching_departments).distinct()
}
@classmethod @classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet:
@ -267,8 +307,23 @@ class TeachingUnit(models.Model, ModelPermissionHelper):
@classmethod @classmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin # if the requesting user is admin
return user.is_superuser if user.is_superuser:
# TODO: allow department manager return True
# if the user is managing a department
if user.managing_departments.count() > 0:
return True
@classmethod
def user_fields_contrains(cls, user: "User") -> dict[str, QuerySet]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# the managers can only interact with their departments
"department": user.managing_departments
}
@classmethod @classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet:
@ -324,8 +379,27 @@ class StudentCard(models.Model, ModelPermissionHelper):
@classmethod @classmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin # if the requesting user is admin
return user.is_superuser if user.is_superuser:
# TODO: Allow new student cards by department managers ? return True
if user.managing_departments.count() > 0:
return True
@classmethod
def user_fields_contrains(cls, user: "User") -> dict[str, QuerySet]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# the managers can only interact with their departments
"department": 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
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet:
@ -390,8 +464,64 @@ class TeachingSession(models.Model, ModelPermissionHelper):
@classmethod @classmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin # if the requesting user is admin
return user.is_superuser if user.is_superuser:
# TODO: Allow new teaching session by managers or teachers 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_contrains(cls, user: "User") -> dict[str, QuerySet]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# the managers can only interact with their departments
"department": (user.managing_departments | user.teaching_departments).distinct(),
"teacher":
# the teacher can be any teacher in a department that the user is managing
reduce(
operator.or_,
(department.teachers.all() for department in user.managing_departments)
) | reduce(
# 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
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet:
@ -465,8 +595,67 @@ class Attendance(models.Model, ModelPermissionHelper):
@classmethod @classmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin # if the requesting user is admin
return user.is_superuser if user.is_superuser:
# TODO: Allow new attendance by managers or teachers 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_contrains(cls, user: "User") -> dict[str, QuerySet]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# the managers can only interact with their departments
"department": user.managing_departments | user.teaching_departments,
"student":
# student can be any student from a department the user is managing or teaching
reduce(
operator.or_,
(
department.students.all()
for department in (user.managing_departments | user.teaching_departments)
)
) |
# 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
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet:
@ -517,7 +706,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="absented_sessions") student: User = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name="absences")
start: datetime = models.DateTimeField() start: datetime = models.DateTimeField()
end: datetime = models.DateTimeField() end: datetime = models.DateTimeField()
@ -554,8 +743,25 @@ class Absence(models.Model, ModelPermissionHelper):
@classmethod @classmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin # if the requesting user is admin
return user.is_superuser if user.is_superuser:
# TODO: Allow new absence by students return True
# if the user is a student
if user.studying_departments.count() > 0:
return True
@classmethod
def user_fields_contrains(cls, user: "User") -> dict[str, QuerySet]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# all the departments the user is studying in
"department": user.studying_departments,
# the student itself
"student": User.objects.filter(pk=user.pk),
}
@classmethod @classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet:
@ -617,8 +823,23 @@ class AbsenceAttachment(models.Model, ModelPermissionHelper):
@classmethod @classmethod
def can_user_create(cls, user: "User") -> bool: def can_user_create(cls, user: "User") -> bool:
# if the requesting user is admin # if the requesting user is admin
return user.is_superuser if user.is_superuser:
# TODO: Allow new absence attachment by students return True
# if the user is a student
if user.objects.count():
return True
@classmethod
def user_fields_contrains(cls, user: "User") -> dict[str, QuerySet]:
# if the user is admin, no contrains
if user.is_superuser:
return {}
return {
# all the departments the user is studying in
"absence": user.absences,
}
@classmethod @classmethod
def all_editable_by_user(cls, user: "User") -> QuerySet: def all_editable_by_user(cls, user: "User") -> QuerySet: