Merge pull request #17 from Faraphel/api

Implemented APÏ
This commit is contained in:
Faraphel 2023-12-11 21:45:54 +01:00 committed by GitHub
commit 70c9977e2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1605 additions and 48 deletions

37
.github/workflows/django-test.yaml vendored Normal file
View 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 }}

View file

@ -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="&lt;map/&gt;" />
<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>

View file

@ -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")

View file

20
Palto/Palto/api/urls.py Normal file
View 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)),
]

View file

View 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)

View 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
View 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

View 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

View 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]
)

View file

@ -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
View 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)

View file

@ -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")

View file

@ -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()

View file

@ -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.

View file

@ -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 = "/"

View file

@ -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
] ]

View file

@ -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