commit
70c9977e2b
19 changed files with 1605 additions and 48 deletions
37
.github/workflows/django-test.yaml
vendored
Normal file
37
.github/workflows/django-test.yaml
vendored
Normal file
|
@ -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 }}
|
|
@ -1,5 +1,17 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<module type="PYTHON_MODULE" version="4">
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="FacetManager">
|
||||||
|
<facet type="django" name="Django">
|
||||||
|
<configuration>
|
||||||
|
<option name="rootFolder" value="$MODULE_DIR$" />
|
||||||
|
<option name="settingsModule" value="Palto/settings.py" />
|
||||||
|
<option name="manageScript" value="manage.py" />
|
||||||
|
<option name="environment" value="<map/>" />
|
||||||
|
<option name="doNotUseTestRunner" value="false" />
|
||||||
|
<option name="trackFilePattern" value="" />
|
||||||
|
</configuration>
|
||||||
|
</facet>
|
||||||
|
</component>
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||||
|
@ -7,4 +19,11 @@
|
||||||
<orderEntry type="jdk" jdkName="Python 3.11 (Palto-Server)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.11 (Palto-Server)" jdkType="Python SDK" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
<component name="TemplatesService">
|
||||||
|
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||||
|
</component>
|
||||||
</module>
|
</module>
|
|
@ -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 django.contrib import admin
|
||||||
|
|
||||||
from .models import (Department, StudentGroup, TeachingUnit, StudentCard, TeachingSession, Attendance, Absence,
|
from . import models
|
||||||
AbsenceAttachment, User)
|
|
||||||
|
|
||||||
|
# TODO: plus de list_filter sur "department" ?
|
||||||
|
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
@admin.register(User)
|
@admin.register(models.User)
|
||||||
class AdminUser(admin.ModelAdmin):
|
class AdminUser(admin.ModelAdmin):
|
||||||
list_display = ("id", "username", "email", "first_name", "last_name", "is_staff")
|
list_display = ("id", "username", "email", "first_name", "last_name", "is_staff")
|
||||||
search_fields = ("id", "username", "email", "first_name", "last_name", "is_staff")
|
search_fields = ("id", "username", "email", "first_name", "last_name", "is_staff")
|
||||||
list_filter = ("is_staff",)
|
list_filter = ("is_staff",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Department)
|
@admin.register(models.Department)
|
||||||
class AdminDepartment(admin.ModelAdmin):
|
class AdminDepartment(admin.ModelAdmin):
|
||||||
list_display = ("id", "name")
|
list_display = ("id", "name", "email")
|
||||||
search_fields = ("id", "name")
|
search_fields = ("id", "name", "email")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(StudentGroup)
|
@admin.register(models.StudentGroup)
|
||||||
class AdminStudentGroup(admin.ModelAdmin):
|
class AdminStudentGroup(admin.ModelAdmin):
|
||||||
list_display = ("id", "name")
|
list_display = ("id", "name", "owner", "department")
|
||||||
search_fields = ("id", "name")
|
search_fields = ("id", "name", "owner", "department")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(TeachingUnit)
|
@admin.register(models.TeachingUnit)
|
||||||
class AdminTeachingUnit(admin.ModelAdmin):
|
class AdminTeachingUnit(admin.ModelAdmin):
|
||||||
list_display = ("id", "name")
|
list_display = ("id", "name")
|
||||||
search_fields = ("id", "name")
|
search_fields = ("id", "name")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(StudentCard)
|
@admin.register(models.StudentCard)
|
||||||
class AdminStudentCard(admin.ModelAdmin):
|
class AdminStudentCard(admin.ModelAdmin):
|
||||||
list_display = ("id", "uid", "owner")
|
list_display = ("id", "uid", "owner")
|
||||||
search_fields = ("id", "uid", "owner")
|
search_fields = ("id", "uid", "owner")
|
||||||
readonly_fields = ("uid",)
|
readonly_fields = ("uid",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(TeachingSession)
|
@admin.register(models.TeachingSession)
|
||||||
class AdminTeachingSession(admin.ModelAdmin):
|
class AdminTeachingSession(admin.ModelAdmin):
|
||||||
list_display = ("id", "start", "end", "duration", "teacher")
|
list_display = ("id", "start", "end", "duration", "teacher")
|
||||||
search_fields = ("id", "start", "end", "duration", "teacher")
|
search_fields = ("id", "start", "end", "duration", "teacher")
|
||||||
list_filter = ("start", "duration")
|
list_filter = ("start", "duration")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Attendance)
|
@admin.register(models.Attendance)
|
||||||
class AdminAttendance(admin.ModelAdmin):
|
class AdminAttendance(admin.ModelAdmin):
|
||||||
list_display = ("id", "date", "student")
|
list_display = ("id", "date", "student")
|
||||||
search_fields = ("id", "date", "student")
|
search_fields = ("id", "date", "student")
|
||||||
list_filter = ("date",)
|
list_filter = ("date",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Absence)
|
@admin.register(models.Absence)
|
||||||
class AdminAbsence(admin.ModelAdmin):
|
class AdminAbsence(admin.ModelAdmin):
|
||||||
list_display = ("id", "message", "student")
|
list_display = ("id", "message", "student", "department", "start", "end")
|
||||||
search_fields = ("id", "message", "student")
|
search_fields = ("id", "message", "student", "department", "start", "end")
|
||||||
|
list_filter = ("department", "start", "end")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(AbsenceAttachment)
|
@admin.register(models.AbsenceAttachment)
|
||||||
class AdminAbsenceAttachment(admin.ModelAdmin):
|
class AdminAbsenceAttachment(admin.ModelAdmin):
|
||||||
list_display = ("id", "content", "absence")
|
list_display = ("id", "content", "absence")
|
||||||
search_fields = ("id", "content", "absence")
|
search_fields = ("id", "content", "absence")
|
||||||
|
|
0
Palto/Palto/api/__init__.py
Normal file
0
Palto/Palto/api/__init__.py
Normal file
20
Palto/Palto/api/urls.py
Normal file
20
Palto/Palto/api/urls.py
Normal file
|
@ -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)),
|
||||||
|
]
|
0
Palto/Palto/api/v1/__init__.py
Normal file
0
Palto/Palto/api/v1/__init__.py
Normal file
50
Palto/Palto/api/v1/permissions.py
Normal file
50
Palto/Palto/api/v1/permissions.py
Normal file
|
@ -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)
|
114
Palto/Palto/api/v1/serializers.py
Normal file
114
Palto/Palto/api/v1/serializers.py
Normal file
|
@ -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']
|
202
Palto/Palto/api/v1/tests.py
Normal file
202
Palto/Palto/api/v1/tests.py
Normal file
|
@ -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
|
25
Palto/Palto/api/v1/urls.py
Normal file
25
Palto/Palto/api/v1/urls.py
Normal file
|
@ -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
|
86
Palto/Palto/api/v1/views.py
Normal file
86
Palto/Palto/api/v1/views.py
Normal file
|
@ -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]
|
||||||
|
)
|
|
@ -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
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
|
187
Palto/Palto/factories.py
Normal file
187
Palto/Palto/factories.py
Normal file
|
@ -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)
|
|
@ -1,13 +1,57 @@
|
||||||
|
"""
|
||||||
|
Models for the Palto project.
|
||||||
|
|
||||||
|
Models are the class that represent and abstract the database.
|
||||||
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from abc import abstractmethod
|
||||||
from datetime import datetime, timedelta
|
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.contrib.auth.models import AbstractUser
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import QuerySet, Q, F
|
||||||
|
|
||||||
|
|
||||||
# Create your models here.
|
# TODO(Raphaël): split permissions from models for readability
|
||||||
class User(AbstractUser):
|
|
||||||
|
|
||||||
|
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.
|
A user.
|
||||||
|
|
||||||
|
@ -19,8 +63,65 @@ class User(AbstractUser):
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<{self.__class__.__name__} id={str(self.id)[:8]} username={self.username!r}>"
|
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.
|
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)
|
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
|
||||||
name: str = models.CharField(max_length=64)
|
name: str = models.CharField(max_length=64, unique=True)
|
||||||
mail: str = models.EmailField()
|
email: str = models.EmailField()
|
||||||
|
|
||||||
managers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="managing_departments")
|
managers = models.ManyToManyField(to=User, blank=True, related_name="managing_departments")
|
||||||
teachers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="teaching_departments")
|
teachers = models.ManyToManyField(to=User, blank=True, related_name="teaching_departments")
|
||||||
students = models.ManyToManyField(to=get_user_model(), blank=True, related_name="studying_departments")
|
students = models.ManyToManyField(to=User, blank=True, related_name="studying_departments")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
|
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
|
||||||
|
@ -42,8 +143,55 @@ class Department(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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.
|
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)
|
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
|
||||||
name: str = models.CharField(max_length=128)
|
name: str = models.CharField(max_length=128)
|
||||||
|
|
||||||
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):
|
def __repr__(self):
|
||||||
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
|
return f"<{self.__class__.__name__} id={str(self.id)[:8]} name={self.name!r}>"
|
||||||
|
@ -64,8 +214,84 @@ class StudentGroup(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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.
|
A teaching unit.
|
||||||
|
|
||||||
|
@ -80,8 +306,8 @@ class TeachingUnit(models.Model):
|
||||||
|
|
||||||
department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="teaching_units")
|
department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="teaching_units")
|
||||||
|
|
||||||
managers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="managing_units")
|
managers = models.ManyToManyField(to=User, blank=True, related_name="managing_units")
|
||||||
teachers = models.ManyToManyField(to=get_user_model(), blank=True, related_name="teaching_units")
|
teachers = models.ManyToManyField(to=User, blank=True, related_name="teaching_units")
|
||||||
student_groups = models.ManyToManyField(to=StudentGroup, blank=True, related_name="studying_units")
|
student_groups = models.ManyToManyField(to=StudentGroup, blank=True, related_name="studying_units")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
@ -90,8 +316,80 @@ class TeachingUnit(models.Model):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
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.
|
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)
|
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
|
||||||
uid: bytes = models.BinaryField(max_length=7)
|
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):
|
def __repr__(self):
|
||||||
return f"<{self.__class__.__name__} id={str(self.id)[:8]} owner={self.owner.username!r}>"
|
return f"<{self.__class__.__name__} id={str(self.id)[:8]} owner={self.owner.username!r}>"
|
||||||
|
|
||||||
|
# validations
|
||||||
|
|
||||||
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.
|
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")
|
unit = models.ForeignKey(to=TeachingUnit, on_delete=models.CASCADE, related_name="sessions")
|
||||||
|
|
||||||
group = models.ForeignKey(to=StudentGroup, on_delete=models.CASCADE, related_name="teaching_sessions")
|
group = models.ForeignKey(to=StudentGroup, on_delete=models.CASCADE, related_name="teaching_sessions")
|
||||||
teacher = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name="teaching_sessions")
|
teacher = models.ForeignKey(to=User, on_delete=models.CASCADE, related_name="teaching_sessions")
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<{self.__class__.__name__} id={str(self.id)[:8]} unit={self.unit.name!r} start={self.start}>"
|
return f"<{self.__class__.__name__} id={str(self.id)[:8]} unit={self.unit.name!r} start={self.start}>"
|
||||||
|
@ -136,8 +494,95 @@ class TeachingSession(models.Model):
|
||||||
def end(self) -> datetime:
|
def end(self) -> datetime:
|
||||||
return self.start + self.duration
|
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.
|
A student attendance to a session.
|
||||||
|
|
||||||
|
@ -148,7 +593,7 @@ class Attendance(models.Model):
|
||||||
date: datetime = models.DateTimeField()
|
date: datetime = models.DateTimeField()
|
||||||
|
|
||||||
student: User = models.ForeignKey(
|
student: User = models.ForeignKey(
|
||||||
to=get_user_model(),
|
to=User,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="attended_sessions"
|
related_name="attended_sessions"
|
||||||
)
|
)
|
||||||
|
@ -167,8 +612,95 @@ class Attendance(models.Model):
|
||||||
f">"
|
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.
|
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)
|
id: uuid.UUID = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False, max_length=36)
|
||||||
message: str = models.TextField()
|
message: str = models.TextField()
|
||||||
|
|
||||||
student: User = models.ForeignKey(to=get_user_model(), on_delete=models.CASCADE, related_name="absented_sessions")
|
department: Department = models.ForeignKey(to=Department, on_delete=models.CASCADE, related_name="absences")
|
||||||
session: TeachingSession = models.ManyToManyField(to=TeachingSession, blank=True, 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):
|
def __repr__(self):
|
||||||
return (
|
return (
|
||||||
f"<{self.__class__.__name__} "
|
f"<{self.__class__.__name__} "
|
||||||
f"id={str(self.id)[:8]} "
|
f"id={str(self.id)[:8]} "
|
||||||
|
f"department={self.department} "
|
||||||
f"student={self.student.username} "
|
f"student={self.student.username} "
|
||||||
f"session={str(self.session.id)[:8]}"
|
f"start={self.start} "
|
||||||
|
f"end={self.end}"
|
||||||
f">"
|
f">"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"[{str(self.id)[:8]}] {self.student}"
|
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.
|
An attachment to a student justified absence.
|
||||||
|
|
||||||
|
@ -208,3 +829,65 @@ class AbsenceAttachment(models.Model):
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<{self.__class__.__name__} id={str(self.id)[:8]} content={self.content!r}>"
|
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")
|
||||||
|
|
|
@ -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.
|
# 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()
|
||||||
|
|
|
@ -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
|
from django.shortcuts import render
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
|
@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
||||||
https://docs.djangoproject.com/en/4.2/ref/settings/
|
https://docs.djangoproject.com/en/4.2/ref/settings/
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
@ -50,12 +51,15 @@ INSTALLED_APPS = [
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
"debug_toolbar",
|
"debug_toolbar",
|
||||||
'django_extensions',
|
'django_extensions',
|
||||||
|
'rest_framework_simplejwt',
|
||||||
|
'corsheaders',
|
||||||
|
|
||||||
"Palto.Palto",
|
"Palto.Palto",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
@ -147,12 +151,51 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
# Rest API configuration
|
# Rest API configuration
|
||||||
# https://www.django-rest-framework.org/
|
# https://www.django-rest-framework.org/
|
||||||
REST_FRAMEWORK = {
|
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,
|
# Use Django's standard `django.contrib.auth` permissions,
|
||||||
# or allow read-only access for unauthenticated users.
|
# or allow read-only access for unauthenticated users.
|
||||||
'DEFAULT_PERMISSION_CLASSES': [
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
|
'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
|
# User model
|
||||||
AUTH_USER_MODEL = "Palto.User"
|
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 = "/"
|
||||||
|
|
|
@ -14,10 +14,12 @@ Including another URLconf
|
||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib import admin
|
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 django.views.static import serve
|
||||||
|
|
||||||
|
from Palto.Palto.api import urls as api_urls
|
||||||
from Palto import settings
|
from Palto import settings
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,11 +28,11 @@ urlpatterns = [
|
||||||
# ...
|
# ...
|
||||||
|
|
||||||
# API
|
# API
|
||||||
path('api/', include('rest_framework.urls')), # API REST
|
path('api/', include(api_urls)), # Api REST
|
||||||
|
|
||||||
# Debug
|
# Debug
|
||||||
path('admin/', admin.site.urls), # Admin page
|
path('admin/', admin.site.urls), # Admin page
|
||||||
path("__debug__/", include("debug_toolbar.urls")), # Debug toolbar
|
path("__debug__/", include('debug_toolbar.urls')), # Debug toolbar
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
|
# Django libraries
|
||||||
django
|
django
|
||||||
djangorestframework
|
djangorestframework
|
||||||
django-filter
|
|
||||||
django-debug-toolbar
|
django-debug-toolbar
|
||||||
django-extensions
|
django-extensions
|
||||||
|
djangorestframework-simplejwt[crypto]
|
||||||
|
django-cors-headers
|
||||||
Werkzeug
|
Werkzeug
|
||||||
|
|
||||||
|
# Tests libraries
|
||||||
|
faker
|
||||||
|
factory_boy
|
||||||
|
|
||||||
|
# Other librairies
|
||||||
markdown
|
markdown
|
||||||
|
|
Loading…
Reference in a new issue