diff --git a/.github/workflows/django-test.yaml b/.github/workflows/django-test.yaml new file mode 100644 index 0000000..d87bbad --- /dev/null +++ b/.github/workflows/django-test.yaml @@ -0,0 +1,37 @@ +name: Django CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: [3.12] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Check Database Migrations + run: | + python manage.py makemigrations + env: + DJANGO_SECRET: ${{ secrets.DJANGO_SECRET }} + - name: Run Tests + run: | + python manage.py test + env: + DJANGO_SECRET: ${{ secrets.DJANGO_SECRET }} diff --git a/.idea/Palto-Server.iml b/.idea/Palto-Server.iml index 39864a8..e92e3f9 100644 --- a/.idea/Palto-Server.iml +++ b/.idea/Palto-Server.iml @@ -1,5 +1,17 @@ + + + + + + @@ -7,4 +19,11 @@ + + + + \ No newline at end of file diff --git a/Palto/Palto/admin.py b/Palto/Palto/admin.py index c64a972..388aed0 100644 --- a/Palto/Palto/admin.py +++ b/Palto/Palto/admin.py @@ -1,63 +1,72 @@ +""" +Admin for the Palto project. + +The admin is the admin page configuration, describing which model should be visible and which field in the admin page. +""" + from django.contrib import admin -from .models import (Department, StudentGroup, TeachingUnit, StudentCard, TeachingSession, Attendance, Absence, - AbsenceAttachment, User) +from . import models + + +# TODO: plus de list_filter sur "department" ? # Register your models here. -@admin.register(User) +@admin.register(models.User) class AdminUser(admin.ModelAdmin): list_display = ("id", "username", "email", "first_name", "last_name", "is_staff") search_fields = ("id", "username", "email", "first_name", "last_name", "is_staff") list_filter = ("is_staff",) -@admin.register(Department) +@admin.register(models.Department) class AdminDepartment(admin.ModelAdmin): - list_display = ("id", "name") - search_fields = ("id", "name") + list_display = ("id", "name", "email") + search_fields = ("id", "name", "email") -@admin.register(StudentGroup) +@admin.register(models.StudentGroup) class AdminStudentGroup(admin.ModelAdmin): - list_display = ("id", "name") - search_fields = ("id", "name") + list_display = ("id", "name", "owner", "department") + search_fields = ("id", "name", "owner", "department") -@admin.register(TeachingUnit) +@admin.register(models.TeachingUnit) class AdminTeachingUnit(admin.ModelAdmin): list_display = ("id", "name") search_fields = ("id", "name") -@admin.register(StudentCard) +@admin.register(models.StudentCard) class AdminStudentCard(admin.ModelAdmin): list_display = ("id", "uid", "owner") search_fields = ("id", "uid", "owner") readonly_fields = ("uid",) -@admin.register(TeachingSession) +@admin.register(models.TeachingSession) class AdminTeachingSession(admin.ModelAdmin): list_display = ("id", "start", "end", "duration", "teacher") search_fields = ("id", "start", "end", "duration", "teacher") list_filter = ("start", "duration") -@admin.register(Attendance) +@admin.register(models.Attendance) class AdminAttendance(admin.ModelAdmin): list_display = ("id", "date", "student") search_fields = ("id", "date", "student") list_filter = ("date",) -@admin.register(Absence) +@admin.register(models.Absence) class AdminAbsence(admin.ModelAdmin): - list_display = ("id", "message", "student") - search_fields = ("id", "message", "student") + list_display = ("id", "message", "student", "department", "start", "end") + search_fields = ("id", "message", "student", "department", "start", "end") + list_filter = ("department", "start", "end") -@admin.register(AbsenceAttachment) +@admin.register(models.AbsenceAttachment) class AdminAbsenceAttachment(admin.ModelAdmin): list_display = ("id", "content", "absence") search_fields = ("id", "content", "absence") diff --git a/Palto/Palto/api/__init__.py b/Palto/Palto/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Palto/Palto/api/urls.py b/Palto/Palto/api/urls.py new file mode 100644 index 0000000..23f5062 --- /dev/null +++ b/Palto/Palto/api/urls.py @@ -0,0 +1,20 @@ +""" +Urls for the Palto project's API. + +This file list all the urls for the Palto API. +""" + +from django.urls import path, include +from rest_framework_simplejwt.views import TokenRefreshView, TokenObtainPairView, TokenVerifyView + +import Palto.Palto.api.v1.urls as v1_urls + +urlpatterns = [ + # Authentification (JWT) + path('auth/jwt/token/', TokenObtainPairView.as_view(), name='token'), + path('auth/jwt/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('auth/jwt/token/verify/', TokenVerifyView.as_view(), name='token_verify'), + + # API + path('v1/', include(v1_urls)), +] diff --git a/Palto/Palto/api/v1/__init__.py b/Palto/Palto/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Palto/Palto/api/v1/permissions.py b/Palto/Palto/api/v1/permissions.py new file mode 100644 index 0000000..a64c0e0 --- /dev/null +++ b/Palto/Palto/api/v1/permissions.py @@ -0,0 +1,50 @@ +""" +Permissions for the Palto project's API v1. + +A permission describe which user is allowed to see and modify which objet with the API +""" + +from typing import Type + +from rest_framework import permissions + +from Palto.Palto import models + + +def permission_from_helper_class(model: Type[models.ModelPermissionHelper]) -> Type[permissions.BasePermission]: + """ + Create a permission class from a model if it implements ModelPermissionHelper. + This make creating permission easier to understand and less redundant. + """ + + class Permission(permissions.BasePermission): + def has_permission(self, request, view) -> bool: + if request.method in permissions.SAFE_METHODS: + # for reading, allow everybody + return True + + if model.can_user_create(request.user): + # for writing, only allowed users + return True + + def has_object_permission(self, request, view, obj: models.User) -> bool: + if request.method in permissions.SAFE_METHODS: + # for reading, only allow if the user can see the object + return obj in model.all_visible_by_user(request.user) + + else: + # for writing, only allow if the user can edit the object + return obj in model.all_editable_by_user(request.user) + + return Permission + + +UserPermission = permission_from_helper_class(models.User) +DepartmentPermission = permission_from_helper_class(models.Department) +StudentGroupPermission = permission_from_helper_class(models.StudentGroup) +TeachingUnitPermission = permission_from_helper_class(models.TeachingUnit) +StudentCardPermission = permission_from_helper_class(models.StudentCard) +TeachingSessionPermission = permission_from_helper_class(models.TeachingSession) +AttendancePermission = permission_from_helper_class(models.Attendance) +AbsencePermission = permission_from_helper_class(models.Absence) +AbsenceAttachmentPermission = permission_from_helper_class(models.AbsenceAttachment) diff --git a/Palto/Palto/api/v1/serializers.py b/Palto/Palto/api/v1/serializers.py new file mode 100644 index 0000000..f0aadef --- /dev/null +++ b/Palto/Palto/api/v1/serializers.py @@ -0,0 +1,114 @@ +""" +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 django.forms import model_to_dict +from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied + +from Palto.Palto import models + + +# TODO: voir les relations inversées ? + + +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_contraints = self.Meta.model.user_fields_contraints(self.context["request"].user) + + # for every constraint + for field, constraints in field_contraints.items(): + # check if the value is in the constraints. + value = validated_data.get(field) + if value in constraints(validated_data): + 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_constraints = self.Meta.model.user_fields_contraints(self.context["request"].user) + + # for every constraint + for field, constraints in field_constraints.items(): + # check if the value of the request is in the constraints. + value = validated_data.get(field) + if value in constraints(validated_data): + 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. + value = getattr(instance, field, None) + if value in constraints(model_to_dict(instance)): + 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(ModelSerializerContrains): + class Meta: + model = models.Department + fields = ['id', 'name', 'email', 'managers', 'teachers', 'students'] + + +class StudentGroupSerializer(ModelSerializerContrains): + class Meta: + model = models.StudentGroup + fields = ['id', 'name', 'owner', 'department', 'students'] + + +class TeachingUnitSerializer(ModelSerializerContrains): + class Meta: + model = models.TeachingUnit + fields = ['id', 'name', 'department', 'managers', 'teachers', 'student_groups'] + + +class StudentCardSerializer(ModelSerializerContrains): + class Meta: + model = models.StudentCard + fields = ['id', 'uid', 'department', 'owner'] + + +class TeachingSessionSerializer(ModelSerializerContrains): + class Meta: + model = models.TeachingSession + fields = ['id', 'start', 'duration', 'note', 'unit', 'group', 'teacher'] + + +class AttendanceSerializer(ModelSerializerContrains): + class Meta: + model = models.Attendance + fields = ['id', 'date', 'student', 'session'] + + +class AbsenceSerializer(ModelSerializerContrains): + class Meta: + model = models.Absence + fields = ['id', 'message', 'student', 'session'] + + +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 new file mode 100644 index 0000000..f391479 --- /dev/null +++ b/Palto/Palto/api/v1/tests.py @@ -0,0 +1,202 @@ +""" +Tests for the Palto project's API v1. + +Everything to test the API v1 is described here. +""" + +from rest_framework import status +from rest_framework import test + +from Palto.Palto import factories, models +from Palto.Palto.api.v1 import serializers + + +class TokenJwtTestCase(test.APITestCase): + """ + Test the JWT token creation + """ + + +class UserApiTestCase(test.APITestCase): + # fake user data for creations test + USER_CREATION_DATA: dict = { + "username": "billybob", + "first_name": "Billy", + "last_name": "Bob", + "email": "billy.bob@billybob.fr" + } + + def setUp(self): + self.user_admin = factories.FakeUserFactory(is_superuser=True) + self.user_other = factories.FakeUserFactory() + + 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/users/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["count"], models.User.objects.count()) + + # check for a post request + response = self.client.post("/api/v1/users/", data=self.USER_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/users/") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # check for a post request + response = self.client.post("/api/v1/users/", data=self.USER_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 anybody + response = self.client.get("/api/v1/users/") + 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/users/", data=self.USER_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/users/") + 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/users/", data=self.USER_CREATION_DATA) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class DepartmentApiTestCase(test.APITestCase): + # fake department creation test + DEPARTMENT_CREATION_DATA: dict = { + "name": "UFR des Sciences", + "email": "ufr.sciences@university.fr", + } + + def setUp(self): + self.user_admin = factories.FakeUserFactory(is_superuser=True) + self.user_other = factories.FakeUserFactory() + + 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/departments/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()["count"], models.Department.objects.count()) + + # check for a post request + response = self.client.post("/api/v1/departments/", data=self.DEPARTMENT_CREATION_DATA) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + +class StudentGroupApiTestCase(test.APITestCase): + 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) + + +class TeachingUnitApiTestCase(test.APITestCase): + pass + + +class StudentCardApiTestCase(test.APITestCase): + pass + + +class TeachingSessionApiTestCase(test.APITestCase): + pass + + +class AttendanceApiTestCase(test.APITestCase): + pass + + +class AbsenceApiTestCase(test.APITestCase): + pass + + +class AbsenceAttachmentApiTestCase(test.APITestCase): + pass diff --git a/Palto/Palto/api/v1/urls.py b/Palto/Palto/api/v1/urls.py new file mode 100644 index 0000000..221ea5f --- /dev/null +++ b/Palto/Palto/api/v1/urls.py @@ -0,0 +1,25 @@ +""" +Urls for the Palto project's API v1. + +All the urls for every model of the API are described here. +""" + + +from rest_framework import routers + +from . import views + +router = routers.DefaultRouter() + +router.register(r'users', views.UserViewSet, basename="User") +router.register(r'departments', views.DepartmentViewSet, basename="Department") +router.register(r'student_groups', views.StudentGroupViewSet, basename="StudentGroup") +router.register(r'teaching_units', views.TeachingUnitViewSet, basename="TeachingUnit") +router.register(r'student_cards', views.StudentCardViewSet, basename="StudentCard") +router.register(r'teaching_sessions', views.TeachingSessionViewSet, basename="TeachingSession") +router.register(r'attendances', views.AttendanceViewSet, basename="Attendance") +router.register(r'absences', views.AbsenceViewSet, basename="Absence") +router.register(r'absence_attachments', views.AbsenceAttachmentViewSet, basename="AbsenceAttachment") + + +urlpatterns = router.urls diff --git a/Palto/Palto/api/v1/views.py b/Palto/Palto/api/v1/views.py new file mode 100644 index 0000000..632225b --- /dev/null +++ b/Palto/Palto/api/v1/views.py @@ -0,0 +1,86 @@ +""" +Views for the Palto project's API v1. + +An API view describe which models should display which files to user with which permissions. +""" +from typing import Type + +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated, BasePermission +from rest_framework.serializers import BaseSerializer + +from . import permissions +from . import serializers +from ... import models + + +def view_from_helper_class( + model_class: Type[models.ModelPermissionHelper], + serializer_class: Type[BaseSerializer], + permission_classes: list[Type[BasePermission]], +) -> Type[viewsets.ModelViewSet]: + """ + Create a view class from a model if it implements ModelPermissionHelper. + This make creating view easier to understand and less redundant. + """ + + class ViewSet(viewsets.ModelViewSet): + nonlocal serializer_class, permission_classes, model_class + + def get_serializer_class(self): + return serializer_class + + def get_permissions(self): + return [permission() for permission in permission_classes] + + def get_queryset(self): + return model_class.all_visible_by_user(self.request.user) + + return ViewSet + + +UserViewSet = view_from_helper_class( + model_class=models.User, + serializer_class=serializers.UserSerializer, + permission_classes=[IsAuthenticated, permissions.UserPermission] +) +DepartmentViewSet = view_from_helper_class( + model_class=models.Department, + serializer_class=serializers.DepartmentSerializer, + permission_classes=[IsAuthenticated, permissions.DepartmentPermission] +) +StudentGroupViewSet = view_from_helper_class( + model_class=models.StudentGroup, + serializer_class=serializers.StudentGroupSerializer, + permission_classes=[IsAuthenticated, permissions.StudentGroupPermission] +) +TeachingUnitViewSet = view_from_helper_class( + model_class=models.TeachingUnit, + serializer_class=serializers.TeachingUnitSerializer, + permission_classes=[IsAuthenticated, permissions.TeachingUnitPermission] +) +StudentCardViewSet = view_from_helper_class( + model_class=models.StudentCard, + serializer_class=serializers.StudentCardSerializer, + permission_classes=[IsAuthenticated, permissions.StudentCardPermission] +) +TeachingSessionViewSet = view_from_helper_class( + model_class=models.TeachingSession, + serializer_class=serializers.TeachingSessionSerializer, + permission_classes=[IsAuthenticated, permissions.TeachingSessionPermission] +) +AttendanceViewSet = view_from_helper_class( + model_class=models.Attendance, + serializer_class=serializers.AttendanceSerializer, + permission_classes=[IsAuthenticated, permissions.AttendancePermission] +) +AbsenceViewSet = view_from_helper_class( + model_class=models.Absence, + serializer_class=serializers.AbsenceSerializer, + permission_classes=[IsAuthenticated, permissions.AbsencePermission] +) +AbsenceAttachmentViewSet = view_from_helper_class( + model_class=models.AbsenceAttachment, + serializer_class=serializers.AbsenceAttachmentSerializer, + permission_classes=[IsAuthenticated, permissions.AbsenceAttachmentPermission] +) diff --git a/Palto/Palto/apps.py b/Palto/Palto/apps.py index 93c779b..b1e7d4e 100644 --- a/Palto/Palto/apps.py +++ b/Palto/Palto/apps.py @@ -1,3 +1,9 @@ +""" +Apps for the Palto project. + +The app is the configuration for this part of the project. +""" + from django.apps import AppConfig diff --git a/Palto/Palto/factories.py b/Palto/Palto/factories.py new file mode 100644 index 0000000..21a7baa --- /dev/null +++ b/Palto/Palto/factories.py @@ -0,0 +1,187 @@ +""" +Factories for the Palto project. + +Factories are class that allow for the automatic creation of instances of our models, primarily for testing purpose. +""" +import random +from datetime import datetime, timedelta + +import factory +import faker +from django.utils import timezone + +from Palto.Palto import models + + +fake = faker.Faker() + + +class FakeUserFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.User + + username: str = factory.Sequence(lambda obj: f"{fake.user_name()}{random.randint(1000, 9999)}") + first_name: str = factory.Faker("first_name") + last_name: str = factory.Faker("last_name") + email: str = factory.Faker("email") + + +class FakeDepartmentFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Department + + name: str = factory.Faker("company") + email: str = factory.Faker("company_email") + + @factory.post_generation + def managers(self, create, extracted, **kwargs): + if not create: + return + + if extracted is not None: + self.managers.add(*extracted) + else: + self.managers.add(*[FakeUserFactory() for _ in range(random.randint(1, 3))]) + + @factory.post_generation + def teachers(self, create, extracted, **kwargs): + if not create: + return + + if extracted is not None: + self.teachers.add(*extracted) + else: + self.teachers.add(*[FakeUserFactory() for _ in range(random.randint(2, 10))]) + + @factory.post_generation + def students(self, create, extracted, **kwargs): + if not create: + return + + if extracted is not None: + self.students.add(*extracted) + else: + self.students.add(*[FakeUserFactory() for _ in range(random.randint(50, 150))]) + + +class FakeStudentGroupFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.StudentGroup + + name: str = factory.Faker("administrative_unit") + + owner: models.User = factory.SubFactory(FakeUserFactory) + department: models.Department = factory.SubFactory(FakeDepartmentFactory) + + @factory.post_generation + def students(self, create, extracted, **kwargs): + if not create: + return + + if extracted is not None: + self.students.add(*extracted) + else: + # create a group of between 5 and 50 students from this department + self.students.add(*self.department.students.order_by('?')[:random.randint(5, 50)]) + + +class FakeTeachingUnitFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.TeachingUnit + + name: str = factory.Faker("administrative_unit") + + department: models.Department = factory.SubFactory( + FakeDepartmentFactory + ) + + @factory.post_generation + def managers(self, create, extracted, **kwargs): + if not create: + return + + if extracted is not None: + self.managers.add(*extracted) + else: + # create a group of between 1 and 2 managers from the teacher's department + self.managers.add(*self.department.teachers.order_by('?')[:random.randint(1, 2)]) + + @factory.post_generation + def teachers(self, create, extracted, **kwargs): + if not create: + return + + if extracted is not None: + self.teachers.add(*extracted) + else: + # create a group of between 2 and 10 teachers from the teacher's department + self.teachers.add(*self.department.teachers.order_by('?')[:random.randint(2, 10)]) + + @factory.post_generation + def student_groups(self, create, extracted, **kwargs): + if not create: + return + + if extracted is not None: + self.student_groups.add(*extracted) + else: + # create a group of between 1 and 2 student groups from the department + self.student_groups.add(*[ + FakeStudentGroupFactory.create(department=self.department) + for _ in range(random.randint(1, 2)) + ]) + + +class FakeStudentCardFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.StudentCard + + uid: bytes = factory.Faker("binary", length=7) + + department: models.Department = factory.SubFactory(FakeDepartmentFactory) + owner: models.User = factory.SubFactory(FakeUserFactory) + + +class FakeTeachingSessionFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.TeachingSession + + start: timedelta = factory.Faker("date_time", tzinfo=timezone.get_current_timezone()) + duration: timedelta = factory.Faker("time_delta") + note: str = factory.Faker("paragraph") + + unit: models.TeachingUnit = factory.SubFactory(FakeTeachingUnitFactory) + + group: models.StudentGroup = factory.SubFactory(FakeStudentGroupFactory) + teacher: models.User = factory.SubFactory(FakeUserFactory) + + +class FakeAttendanceFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Attendance + + date: datetime = factory.Faker("date_time", tzinfo=timezone.get_current_timezone()) + + student: models.User = factory.SubFactory(FakeUserFactory) + session: models.TeachingSession = factory.SubFactory(FakeTeachingSessionFactory) + + +class FakeAbsenceFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.Absence + + message: str = factory.Faker("paragraph") + + department: models.Department = factory.SubFactory(FakeDepartmentFactory) + student: models.User = factory.SubFactory(FakeUserFactory) + start: datetime = factory.Faker("date_time", tzinfo=timezone.get_current_timezone()) + end: datetime = factory.LazyAttribute(lambda obj: obj.start + timedelta(days=random.randint(1, 8))) + + +class FakeAbsenceAttachmentFactory(factory.django.DjangoModelFactory): + class Meta: + model = models.AbsenceAttachment + + content: str = factory.django.FileField() + + absence: models.Absence = factory.SubFactory(FakeAbsenceFactory) diff --git a/Palto/Palto/models.py b/Palto/Palto/models.py index 841a76f..ac7f3d2 100644 --- a/Palto/Palto/models.py +++ b/Palto/Palto/models.py @@ -1,13 +1,57 @@ +""" +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 import get_user_model 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 -# Create your models here. -class User(AbstractUser): +# 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. @@ -19,8 +63,65 @@ class User(AbstractUser): 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. + """ -class Department(models.Model): + 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. @@ -29,12 +130,12 @@ class Department(models.Model): """ id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36) - name: str = models.CharField(max_length=64) - mail: str = models.EmailField() + name: str = models.CharField(max_length=64, unique=True) + email: str = models.EmailField() - managers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="managing_departments") - teachers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="teaching_departments") - students = models.ManyToManyField(to=get_user_model(), blank=True, related_name="studying_departments") + 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}>" @@ -42,8 +143,55 @@ class Department(models.Model): def __str__(self): return self.name + @staticmethod + def multiple_related_users(departments: Iterable["Department"]) -> QuerySet["User"]: + """ + Return all the related users from multiple departments. + """ -class StudentGroup(models.Model): + 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. @@ -56,7 +204,9 @@ class StudentGroup(models.Model): id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36) name: str = models.CharField(max_length=128) - students = models.ManyToManyField(to=get_user_model(), blank=True, related_name="student_groups") + 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}>" @@ -64,8 +214,84 @@ class StudentGroup(models.Model): def __str__(self): return self.name + # validators -class TeachingUnit(models.Model): + 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. @@ -80,8 +306,8 @@ class TeachingUnit(models.Model): 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") - teachers = models.ManyToManyField(to=get_user_model(), blank=True, 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): @@ -90,8 +316,80 @@ class TeachingUnit(models.Model): def __str__(self): return self.name + # validations -class StudentCard(models.Model): + 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. @@ -101,13 +399,73 @@ class StudentCard(models.Model): id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36) uid: bytes = models.BinaryField(max_length=7) - owner: User = models.ForeignKey(to=get_user_model(), 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=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 -class TeachingSession(models.Model): + 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. @@ -124,7 +482,7 @@ class TeachingSession(models.Model): 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=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): return f"<{self.__class__.__name__} id={str(self.id)[:8]} unit={self.unit.name!r} start={self.start}>" @@ -136,8 +494,95 @@ class TeachingSession(models.Model): def end(self) -> datetime: return self.start + self.duration + # validations -class Attendance(models.Model): + 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. @@ -148,7 +593,7 @@ class Attendance(models.Model): date: datetime = models.DateTimeField() student: User = models.ForeignKey( - to=get_user_model(), + to=User, on_delete=models.CASCADE, related_name="attended_sessions" ) @@ -167,8 +612,95 @@ class Attendance(models.Model): f">" ) + # validations -class Absence(models.Model): + 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. @@ -178,23 +710,112 @@ class Absence(models.Model): id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36) message: str = models.TextField() - student: User = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name="absented_sessions") - session: TeachingSession = models.ManyToManyField(to=TeachingSession, blank=True, related_name="absences") + 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"session={str(self.session.id)[:8]}" + f"start={self.start} " + f"end={self.end}" f">" ) def __str__(self): return f"[{str(self.id)[:8]}] {self.student}" + # validations -class AbsenceAttachment(models.Model): + 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. @@ -208,3 +829,65 @@ class AbsenceAttachment(models.Model): 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") diff --git a/Palto/Palto/tests.py b/Palto/Palto/tests.py index 7ce503c..ed27c6f 100644 --- a/Palto/Palto/tests.py +++ b/Palto/Palto/tests.py @@ -1,3 +1,64 @@ -from django.test import TestCase +""" +Tests for the Palto project. + +Tests allow to easily check after modifying the logic behind a feature that everything still work as intended. +""" + +from django import test + +from Palto.Palto import factories + # Create your tests here. +class UserTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeUserFactory() + + +class DepartmentTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeDepartmentFactory() + + +class StudentGroupTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeStudentGroupFactory() + + +class TeachingUnitTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeTeachingUnitFactory() + + +class StudentCardTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeStudentCardFactory() + + +class TeachingSessionTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeTeachingSessionFactory() + + +class AttendanceTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeAttendanceFactory() + + +class AbsenceTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeAbsenceFactory() + + +class AbsenceAttachmentTestCase(test.TestCase): + @staticmethod + def test_creation(): + factories.FakeAbsenceAttachmentFactory() diff --git a/Palto/Palto/views.py b/Palto/Palto/views.py index 91ea44a..49785f0 100644 --- a/Palto/Palto/views.py +++ b/Palto/Palto/views.py @@ -1,3 +1,9 @@ +""" +Views for the Palto project. + +A view is what control the content of a page, prepare the correct data, react to a form, render the correct template. +""" + from django.shortcuts import render # Create your views here. diff --git a/Palto/settings.py b/Palto/settings.py index 7d3d7cb..32f44ff 100644 --- a/Palto/settings.py +++ b/Palto/settings.py @@ -10,6 +10,7 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.2/ref/settings/ """ import os +from datetime import timedelta from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -50,12 +51,15 @@ INSTALLED_APPS = [ 'rest_framework', "debug_toolbar", 'django_extensions', + 'rest_framework_simplejwt', + 'corsheaders', "Palto.Palto", ] MIDDLEWARE = [ "debug_toolbar.middleware.DebugToolbarMiddleware", + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -147,12 +151,51 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Rest API configuration # https://www.django-rest-framework.org/ REST_FRAMEWORK = { + # Default way to authenticate to the REST api. + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', + ], + # Use Django's standard `django.contrib.auth` permissions, # or allow read-only access for unauthenticated users. 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' - ] + ], + + # Default rate limiting + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '2/min', + 'user': '15/min' + }, + + # Allow up to 30 elements per page + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 30 } +# JWT Authentification +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), +} + + # User model AUTH_USER_MODEL = "Palto.User" + + +# CORS settings +# TODO(Raphaël): Only in debug ! +CORS_ORIGIN_ALLOW_ALL = True + + +# Login +LOGIN_URL = "/admin/login/" +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/" diff --git a/Palto/urls.py b/Palto/urls.py index 17dadbc..463604a 100644 --- a/Palto/urls.py +++ b/Palto/urls.py @@ -14,10 +14,12 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin -from django.urls import path, include, re_path +from django.urls import path, re_path, include from django.views.static import serve +from Palto.Palto.api import urls as api_urls from Palto import settings @@ -26,11 +28,11 @@ urlpatterns = [ # ... # API - path('api/', include('rest_framework.urls')), # API REST + path('api/', include(api_urls)), # Api REST # Debug path('admin/', admin.site.urls), # Admin page - path("__debug__/", include("debug_toolbar.urls")), # Debug toolbar + path("__debug__/", include('debug_toolbar.urls')), # Debug toolbar ] diff --git a/requirements.txt b/requirements.txt index b1ba11c..7207d04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,15 @@ +# Django libraries django djangorestframework -django-filter django-debug-toolbar django-extensions +djangorestframework-simplejwt[crypto] +django-cors-headers Werkzeug +# Tests libraries +faker +factory_boy + +# Other librairies markdown