diff --git a/Palto/Palto/api/urls.py b/Palto/Palto/api/urls.py index 5682479..89fa036 100644 --- a/Palto/Palto/api/urls.py +++ b/Palto/Palto/api/urls.py @@ -1,7 +1,14 @@ 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/permissions.py b/Palto/Palto/api/v1/permissions.py new file mode 100644 index 0000000..68cd269 --- /dev/null +++ b/Palto/Palto/api/v1/permissions.py @@ -0,0 +1,205 @@ +from rest_framework import permissions + +from Palto.Palto.models import (Department, TeachingUnit, StudentCard, StudentGroup, User, TeachingSession, Attendance, + Absence, AbsenceAttachment) + + +class UserPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: User) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + if request.method in permissions.SAFE_METHODS: + # if the user is in one of the same department as the requesting user, allow read + if obj in Department.multiple_related_users(request.user.related_departments): + return True + + return False + + +class DepartmentPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: Department) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + # if the group department is managed by the user, allow all + if obj in request.user.managing_departments: + return True + + if request.method in permissions.SAFE_METHODS: + # allow read to everybody + return True + + return False + + +class StudentGroupPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: StudentGroup) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + # if the group department is managed by the user, allow all + if obj.department in request.user.managing_departments: + return True + + # if the user is the owner of the group, allow all + if obj.owner is request.user: + return True + + if request.method in permissions.SAFE_METHODS: + # if the student is in the group, allow read + if obj in request.user.student_groups: + return True + + # if the user is a teacher from the same department, allow read + if obj.department in request.user.teaching_departments: + return True + + return False + + +class TeachingUnitPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: TeachingUnit) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + # if the teaching unit department is managed by the user, allow all + if obj.department in request.user.managing_departments: + return True + + # if the teaching unit is managed by the user, allow all + if obj in request.user.managing_units: + return True + + if request.method in permissions.SAFE_METHODS: + # if the user is related to the department, allow read + if obj.department in request.user.related_departments: + return True + + return False + + +class StudentCardPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: StudentCard) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + # if the card department is managed by the user, allow all + if obj.department in request.user.managing_departments: + return True + + if request.method in permissions.SAFE_METHODS: + # if the owner of the card is the user, allow read + if obj.owner is request.user: + return True + + return False + + +class TeachingSessionPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: TeachingSession) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + # if the teacher is the user, allow all + if obj.teacher is request.user: + return True + + # if the unit of the session is managed by the user, allow all + if obj.unit in request.user.managing_units: + return True + + # if the department of the session is managed by the user, allow all + if obj.unit.department in request.user.managing_departments: + return True + + if request.method in permissions.SAFE_METHODS: + # if the user was one of the student, allow read + if request.user in obj.group.students: + return True + + return False + + +class AttendancePermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: Attendance) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + # if the teacher is the user, allow all + if obj.session.teacher is request.user: + return True + + # if the unit of the session is managed by the user, allow all + if obj.session.unit in request.user.managing_units: + return True + + # if the department of the session is managed by the user, allow all + if obj.session.unit.department in request.user.managing_departments: + return True + + if request.method in permissions.SAFE_METHODS: + # if the user was the student, allow read + if obj.student is request.user: + return True + + return False + + +class AbsencePermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: Absence) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + # if the department of the session is managed by the user, allow all + if obj.session.unit.department in request.user.managing_departments: + return True + + # if the user was the student, allow all + if obj.student is request.user: + return True + + if request.method in permissions.SAFE_METHODS: + # if the unit of the session is managed by the user, allow read + if obj.session.unit in request.user.managing_units: + return True + + # if the teacher is the user, allow read + if obj.session.teacher is request.user: + return True + + return False + + +class AbsenceAttachmentPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj: AbsenceAttachment) -> bool: + # if the requesting user is admin, allow all + if request.user.is_superuser: + return True + + # if the department of the session is managed by the user, allow all + if obj.absence.session.unit.department in request.user.managing_departments: + return True + + # if the user was the student, allow all + if obj.absence.student is request.user: + return True + + if request.method in permissions.SAFE_METHODS: + # if the unit of the session is managed by the user, allow read + if obj.absence.session.unit in request.user.managing_units: + return True + + # if the teacher is the user, allow read + if obj.absence.session.teacher is request.user: + return True + + return False diff --git a/Palto/Palto/api/v1/serializers.py b/Palto/Palto/api/v1/serializers.py index 85783cc..5c853f5 100644 --- a/Palto/Palto/api/v1/serializers.py +++ b/Palto/Palto/api/v1/serializers.py @@ -4,39 +4,40 @@ from Palto.Palto.models import (User, Department, TeachingUnit, StudentCard, Tea AbsenceAttachment, StudentGroup) -# TODO(Raphaël): Les champs sont-ils sûr ? (carte uid ?) -# TODO(Raphaël): Connection à l'API avec token ? -# TODO(Raphaël): Voir pour les relations +# TODO(Raphaël): Voir pour les related_name class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'username', 'first_name', 'last_name'] + fields = ['id', 'username', 'first_name', 'last_name', 'email'] class DepartmentSerializer(serializers.ModelSerializer): class Meta: model = Department - fields = ['id', 'name', 'mail'] + fields = ['id', 'name', 'email', 'managers'] + # NOTE: teachers, students class StudentGroupSerializer(serializers.ModelSerializer): class Meta: model = StudentGroup - fields = ['id', 'name'] + fields = ['id', 'name', 'owner', 'department'] + # NOTE: students class TeachingUnitSerializer(serializers.ModelSerializer): class Meta: model = TeachingUnit fields = ['id', 'name', 'department'] + # NOTE: managers, teachers, student_groups class StudentCardSerializer(serializers.ModelSerializer): class Meta: model = StudentCard - fields = ['id', 'uid', 'owner'] + fields = ['id', 'uid', 'department', 'owner'] class TeachingSessionSerializer(serializers.ModelSerializer): @@ -48,13 +49,13 @@ class TeachingSessionSerializer(serializers.ModelSerializer): class AttendanceSerializer(serializers.ModelSerializer): class Meta: model = Attendance - fields = ['id', 'date', 'student'] + fields = ['id', 'date', 'student', 'session'] class AbsenceSerializer(serializers.ModelSerializer): class Meta: model = Absence - fields = ['id', 'message', 'student'] + fields = ['id', 'message', 'student', 'session'] class AbsenceAttachmentSerializer(serializers.ModelSerializer): diff --git a/Palto/Palto/api/v1/views.py b/Palto/Palto/api/v1/views.py index 9007ebc..c1b4a1e 100644 --- a/Palto/Palto/api/v1/views.py +++ b/Palto/Palto/api/v1/views.py @@ -1,6 +1,9 @@ from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated +from .permissions import (UserPermission, DepartmentPermission, StudentGroupPermission, TeachingUnitPermission, + StudentCardPermission, TeachingSessionPermission, AttendancePermission, AbsencePermission, + AbsenceAttachmentPermission) from .serializers import (UserSerializer, AbsenceAttachmentSerializer, AbsenceSerializer, AttendanceSerializer, TeachingSessionSerializer, StudentCardSerializer, StudentGroupSerializer, DepartmentSerializer, TeachingUnitSerializer) @@ -10,72 +13,53 @@ from ...models import (User, AbsenceAttachment, Absence, Attendance, TeachingSes class UserViewSet(viewsets.ModelViewSet): serializer_class = UserSerializer - - def get_queryset(self): - return User.all_visible_to(self.request.user) - - def get_permissions(self): - return User.permissions_for(self.request.user, self.request.method) + queryset = User.objects.all() + permission_classes = [IsAuthenticated, UserPermission] class DepartmentViewSet(UserViewSet): serializer_class = DepartmentSerializer - - def get_queryset(self): - return Department.all_visible_to(self.request.user) + queryset = Department.objects.all() + permission_classes = [DepartmentPermission] class StudentGroupViewSet(UserViewSet): serializer_class = StudentGroupSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - return StudentGroup.all_visible_to(self.request.user) + queryset = StudentGroup.objects.all() + permission_classes = [IsAuthenticated, StudentGroupPermission] class TeachingUnitViewSet(UserViewSet): serializer_class = TeachingUnitSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - return TeachingUnit.all_visible_to(self.request.user) + queryset = TeachingUnit.objects.all() + permission_classes = [IsAuthenticated, TeachingUnitPermission] class StudentCardViewSet(UserViewSet): serializer_class = StudentCardSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - return StudentCard.all_visible_to(self.request.user) + queryset = StudentCard.objects.all() + permission_classes = [IsAuthenticated, StudentCardPermission] class TeachingSessionViewSet(UserViewSet): serializer_class = TeachingSessionSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - return TeachingSession.all_visible_to(self.request.user) + queryset = TeachingSession.objects.all() + permission_classes = [IsAuthenticated, TeachingSessionPermission] class AttendanceViewSet(UserViewSet): serializer_class = AttendanceSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - return Attendance.all_visible_to(self.request.user) + queryset = Attendance.objects.all() + permission_classes = [IsAuthenticated, AttendancePermission] class AbsenceViewSet(UserViewSet): serializer_class = AbsenceSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - return Absence.all_visible_to(self.request.user) + queryset = Absence.objects.all() + permission_classes = [IsAuthenticated, AbsencePermission] class AbsenceAttachmentViewSet(UserViewSet): serializer_class = AbsenceAttachmentSerializer - permission_classes = [IsAuthenticated] - - def get_queryset(self): - return AbsenceAttachment.all_visible_to(self.request.user) + queryset = AbsenceAttachment.objects.all() + permission_classes = [IsAuthenticated, AbsenceAttachmentPermission] diff --git a/Palto/Palto/models.py b/Palto/Palto/models.py index be7787f..62343c0 100644 --- a/Palto/Palto/models.py +++ b/Palto/Palto/models.py @@ -1,5 +1,4 @@ import uuid -from abc import abstractmethod, ABC from datetime import datetime, timedelta from typing import Iterable @@ -7,27 +6,9 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import QuerySet, Q -from rest_framework import permissions -# Create your models here. -class ModelApiMixin(ABC): - @classmethod - @abstractmethod - def all_visible_to(cls, user: "User") -> QuerySet: - """ - Return all the objects visible to a user. - """ - - @classmethod - @abstractmethod - def permissions_for(cls, user: "User", method: str) -> permissions.BasePermission: - """ - Return the permissions for a user and the method used to access the object. - """ - - -class User(AbstractUser, ModelApiMixin): +class User(AbstractUser): """ A user. @@ -59,25 +40,8 @@ class User(AbstractUser, ModelApiMixin): return self.multiple_related_departments([self]) - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - if user.is_superuser: - queryset = User.objects.all() - else: - queryset = Department.multiple_related_users(user.related_departments) - return queryset.order_by("pk") - - @classmethod - def permissions_for(cls, user: "User", method: str) -> permissions.BasePermission: - # TODO: ??? - if method in permissions.SAFE_METHODS: - return permissions.AllowAny() - - return permissions.IsAdminUser() - - -class Department(models.Model, ModelApiMixin): +class Department(models.Model): """ A scholar department. @@ -87,7 +51,7 @@ class Department(models.Model, ModelApiMixin): 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() + 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") @@ -119,12 +83,8 @@ class Department(models.Model, ModelApiMixin): return self.multiple_related_users([self]) - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - return cls.objects.all().order_by("pk") - -class StudentGroup(models.Model, ModelApiMixin): +class StudentGroup(models.Model): """ A student group. @@ -137,6 +97,7 @@ class StudentGroup(models.Model, ModelApiMixin): id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36) name: str = models.CharField(max_length=128) + owner = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name="owning_groups") department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="student_groups") students = models.ManyToManyField(to=get_user_model(), blank=True, related_name="student_groups") @@ -146,24 +107,8 @@ class StudentGroup(models.Model, ModelApiMixin): def __str__(self): return self.name - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - if user.is_superuser: - queryset = cls.objects.all() - else: - queryset = cls.objects.filter( - # get all the groups where the user is - Q(students=user) | - # get all the groups where the department is managed by the user - Q(department=user.managing_departments) - # TODO: prof ? rôle créateur du groupe ? - ).distinct() - - return queryset.order_by("pk") - - -class TeachingUnit(models.Model, ModelApiMixin): +class TeachingUnit(models.Model): """ A teaching unit. @@ -188,21 +133,8 @@ class TeachingUnit(models.Model, ModelApiMixin): def __str__(self): return self.name - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - if user.is_superuser: - queryset = cls.objects.all() - else: - queryset = cls.objects.filter( - # get all the units with a common department with the user - Q(department__in=user.related_departments) - ) - - return queryset.order_by("pk") - - -class StudentCard(models.Model, ModelApiMixin): +class StudentCard(models.Model): """ A student card. @@ -212,28 +144,14 @@ class StudentCard(models.Model, ModelApiMixin): id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36) uid: bytes = models.BinaryField(max_length=7) + department: Department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="student_cards") owner: User = models.ForeignKey(to=get_user_model(), 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}>" - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - if user.is_superuser: - queryset = cls.objects.all() - else: - queryset = cls.objects.filter( - # get all the cards that are owned by the user - Q(owner=user) | - # get all the cards where the owner is studying in a department where the user is a manager - Q(owner__studying_departments__managers=user) - ).distinct() - - return queryset.order_by("pk") - - -class TeachingSession(models.Model, ModelApiMixin): +class TeachingSession(models.Model): """ A session of a teaching unit. @@ -262,27 +180,8 @@ class TeachingSession(models.Model, ModelApiMixin): def end(self) -> datetime: return self.start + self.duration - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - if user.is_superuser: - queryset = cls.objects.all() - else: - queryset = cls.objects.filter( - # get all the sessions where the user is a teacher - Q(teacher=user) | - # get all the sessions where the user is in the group - Q(group__students=user) | - # get all the sessions where the user is managing the unit - Q(unit__managers=user) | - # get all the sessions where the user is managing the department - Q(unit__department__managers=user) - ).distinct() - - return queryset.order_by("pk") - - -class Attendance(models.Model, ModelApiMixin): +class Attendance(models.Model): """ A student attendance to a session. @@ -312,27 +211,8 @@ class Attendance(models.Model, ModelApiMixin): f">" ) - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - if user.is_superuser: - queryset = cls.objects.all() - else: - queryset = cls.objects.filter( - # get all the session where the user was the teacher - Q(session__teacher=user) | - # get all the session where the user was the student - Q(student=user) | - # get all the sessions where the user is managing the unit - Q(session__unit__managers=user) | - # get all the sessions where the user is managing the department - Q(session__unit__department__managers=user) - ).distinct() - - return queryset.order_by("pk") - - -class Absence(models.Model, ModelApiMixin): +class Absence(models.Model): """ A student justified absence to a session. @@ -357,27 +237,8 @@ class Absence(models.Model, ModelApiMixin): def __str__(self): return f"[{str(self.id)[:8]}] {self.student}" - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - if user.is_superuser: - queryset = cls.objects.all() - else: - queryset = cls.objects.filter( - # get all the absence where the user was the teacher - Q(session__teacher=user) | - # get all the absence where the user was the student - Q(student=user) | - # get all the absences where the user is managing the unit - Q(session__unit__managers=user) | - # get all the absences where the user is managing the department - Q(session__unit__department__managers=user) - ).distinct() - - return queryset.order_by("pk") - - -class AbsenceAttachment(models.Model, ModelApiMixin): +class AbsenceAttachment(models.Model): """ An attachment to a student justified absence. @@ -391,22 +252,3 @@ class AbsenceAttachment(models.Model, ModelApiMixin): def __repr__(self): return f"<{self.__class__.__name__} id={str(self.id)[:8]} content={self.content!r}>" - - @classmethod - def all_visible_to(cls, user: "User") -> QuerySet["User"]: - if user.is_superuser: - queryset = cls.objects.all() - - else: - queryset = cls.objects.filter( - # get all the absence attachments where the user was the teacher - Q(absence__session__teacher=user) | - # get all the absence attachments where the user was the student - Q(absence__student=user) | - # get all the absence attachments where the user is managing the unit - Q(absence__session__unit__managers=user) | - # get all the absence attachments where the user is managing the department - Q(absence__session__unit__department__managers=user) - ).distinct() - - return queryset.order_by("pk") diff --git a/Palto/settings.py b/Palto/settings.py index cf4ad64..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,6 +151,13 @@ 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': [ @@ -168,5 +179,23 @@ REST_FRAMEWORK = { '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 62ebde1..463604a 100644 --- a/Palto/urls.py +++ b/Palto/urls.py @@ -14,7 +14,7 @@ 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')) """ -import debug_toolbar + from django.contrib import admin from django.urls import path, re_path, include from django.views.static import serve @@ -32,7 +32,7 @@ urlpatterns = [ # 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 86d73a9..9bbc21b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,8 @@ django djangorestframework django-debug-toolbar django-extensions +djangorestframework-simplejwt[crypto] +django-cors-headers Werkzeug markdown