diff --git a/Palto/Palto/api/v1/serializers.py b/Palto/Palto/api/v1/serializers.py index 035e6d8..d283e43 100644 --- a/Palto/Palto/api/v1/serializers.py +++ b/Palto/Palto/api/v1/serializers.py @@ -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. """ +from typing import Type from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied 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: model = models.User fields = ['id', 'username', 'first_name', 'last_name', 'email'] -class DepartmentSerializer(serializers.ModelSerializer): +class DepartmentSerializer(ModelSerializerContrains): class Meta: model = models.Department - fields = ['id', 'name', 'email', 'managers'] - # NOTE: teachers, students + fields = ['id', 'name', 'email', 'managers', 'teachers', 'students'] -class StudentGroupSerializer(serializers.ModelSerializer): +class StudentGroupSerializer(ModelSerializerContrains): class Meta: model = models.StudentGroup - fields = ['id', 'name', 'owner', 'department'] - # NOTE: students + fields = ['id', 'name', 'owner', 'department', 'students'] -class TeachingUnitSerializer(serializers.ModelSerializer): +class TeachingUnitSerializer(ModelSerializerContrains): class Meta: model = models.TeachingUnit - fields = ['id', 'name', 'department'] - # NOTE: managers, teachers, student_groups + fields = ['id', 'name', 'department', 'managers', 'teachers', 'student_groups'] -class StudentCardSerializer(serializers.ModelSerializer): +class StudentCardSerializer(ModelSerializerContrains): class Meta: model = models.StudentCard fields = ['id', 'uid', 'department', 'owner'] -class TeachingSessionSerializer(serializers.ModelSerializer): +class TeachingSessionSerializer(ModelSerializerContrains): class Meta: model = models.TeachingSession fields = ['id', 'start', 'duration', 'note', 'unit', 'group', 'teacher'] -class AttendanceSerializer(serializers.ModelSerializer): +class AttendanceSerializer(ModelSerializerContrains): class Meta: model = models.Attendance fields = ['id', 'date', 'student', 'session'] -class AbsenceSerializer(serializers.ModelSerializer): +class AbsenceSerializer(ModelSerializerContrains): class Meta: model = models.Absence fields = ['id', 'message', 'student', 'session'] -class AbsenceAttachmentSerializer(serializers.ModelSerializer): +class AbsenceAttachmentSerializer(ModelSerializerContrains): class Meta: model = models.AbsenceAttachment fields = ['id', 'content', 'absence'] diff --git a/Palto/Palto/api/v1/tests.py b/Palto/Palto/api/v1/tests.py index 1fa5f0b..81f7c62 100644 --- a/Palto/Palto/api/v1/tests.py +++ b/Palto/Palto/api/v1/tests.py @@ -158,14 +158,10 @@ class DepartmentApiTestCase(test.APITestCase): 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 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) @@ -173,8 +169,114 @@ class DepartmentApiTestCase(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): pass diff --git a/Palto/Palto/models.py b/Palto/Palto/models.py index eadb3b9..215499b 100644 --- a/Palto/Palto/models.py +++ b/Palto/Palto/models.py @@ -3,10 +3,11 @@ Models for the Palto project. Models are the class that represent and abstract the database. """ - +import operator import uuid from abc import abstractmethod from datetime import datetime, timedelta +from functools import reduce from typing import Iterable from django.contrib.auth import get_user_model @@ -15,14 +16,26 @@ from django.db import models 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 - @abstractmethod 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_contrains(cls, user: "User") -> dict[str, QuerySet]: + """ + Return the list of fields in that model that the user can modify + """ + + return {} @classmethod @abstractmethod @@ -76,17 +89,24 @@ class User(AbstractUser, ModelPermissionHelper): @classmethod def can_user_create(cls, user: "User") -> bool: # if the requesting user is admin - return user.is_superuser - # TODO: propriétaire d'établissement + 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: - queryset = QuerySet() - # TODO: propriétaire d'établissement + # 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") @@ -157,8 +177,10 @@ class Department(models.Model, ModelPermissionHelper): # if the requesting user is admin queryset = cls.objects.all() else: - queryset = QuerySet() - # TODO: propriétaire d'établissement ? + queryset = cls.objects.filter( + # if the user is the manager of the department + managers=user, + ) return queryset.order_by("pk") @@ -198,9 +220,27 @@ class StudentGroup(models.Model, ModelPermissionHelper): @classmethod def can_user_create(cls, user: "User") -> bool: # if the requesting user is admin - return user.is_superuser - # TODO: department managers can create group - # TODO: can teacher create group ? + 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_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 def all_editable_by_user(cls, user: "User") -> QuerySet: @@ -267,8 +307,23 @@ class TeachingUnit(models.Model, ModelPermissionHelper): @classmethod def can_user_create(cls, user: "User") -> bool: # if the requesting user is admin - return user.is_superuser - # TODO: allow department manager + 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_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 def all_editable_by_user(cls, user: "User") -> QuerySet: @@ -324,8 +379,27 @@ class StudentCard(models.Model, ModelPermissionHelper): @classmethod def can_user_create(cls, user: "User") -> bool: # if the requesting user is admin - return user.is_superuser - # TODO: Allow new student cards by department managers ? + if user.is_superuser: + 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 def all_editable_by_user(cls, user: "User") -> QuerySet: @@ -390,8 +464,64 @@ class TeachingSession(models.Model, ModelPermissionHelper): @classmethod def can_user_create(cls, user: "User") -> bool: # if the requesting user is admin - return user.is_superuser - # TODO: Allow new teaching session by managers or teachers + 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_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 def all_editable_by_user(cls, user: "User") -> QuerySet: @@ -465,8 +595,67 @@ class Attendance(models.Model, ModelPermissionHelper): @classmethod def can_user_create(cls, user: "User") -> bool: # if the requesting user is admin - return user.is_superuser - # TODO: Allow new attendance by managers or teachers + 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_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 def all_editable_by_user(cls, user: "User") -> QuerySet: @@ -517,7 +706,7 @@ class Absence(models.Model, ModelPermissionHelper): message: str = models.TextField() 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() end: datetime = models.DateTimeField() @@ -554,8 +743,25 @@ class Absence(models.Model, ModelPermissionHelper): @classmethod def can_user_create(cls, user: "User") -> bool: # if the requesting user is admin - return user.is_superuser - # TODO: Allow new absence by students + if user.is_superuser: + 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 def all_editable_by_user(cls, user: "User") -> QuerySet: @@ -617,8 +823,23 @@ class AbsenceAttachment(models.Model, ModelPermissionHelper): @classmethod def can_user_create(cls, user: "User") -> bool: # if the requesting user is admin - return user.is_superuser - # TODO: Allow new absence attachment by students + if user.is_superuser: + 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 def all_editable_by_user(cls, user: "User") -> QuerySet: