diff --git a/.env b/.env new file mode 100644 index 0000000..1951496 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg" \ No newline at end of file diff --git a/.gitignore b/.gitignore index a37a203..bf46567 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,3 @@ -Back-End/*/Configuration/application.json .venv/ -__pycache__/ node_modules/ -Back-End/*/migrations/* -Back-End/documents -Back-End/*.dmp -Back-End/staticfiles hardcoded-strings-report.md \ No newline at end of file diff --git a/Back-End/.gitignore b/Back-End/.gitignore new file mode 100644 index 0000000..636e5aa --- /dev/null +++ b/Back-End/.gitignore @@ -0,0 +1,7 @@ +__pycache__ +/*/migrations/* +documents +data +*.dmp +staticfiles +/*/Configuration/application.json \ No newline at end of file diff --git a/Back-End/GestionLogin/__init__.py b/Back-End/Auth/__init__.py similarity index 100% rename from Back-End/GestionLogin/__init__.py rename to Back-End/Auth/__init__.py diff --git a/Back-End/GestionEnseignants/admin.py b/Back-End/Auth/admin.py similarity index 100% rename from Back-End/GestionEnseignants/admin.py rename to Back-End/Auth/admin.py diff --git a/Back-End/GestionLogin/apps.py b/Back-End/Auth/apps.py similarity index 87% rename from Back-End/GestionLogin/apps.py rename to Back-End/Auth/apps.py index 8aa6c1e..3116bfa 100644 --- a/Back-End/GestionLogin/apps.py +++ b/Back-End/Auth/apps.py @@ -3,5 +3,5 @@ from django.db.models.signals import post_migrate class GestionloginConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'GestionLogin' + name = 'Auth' diff --git a/Back-End/GestionLogin/backends.py b/Back-End/Auth/backends.py similarity index 70% rename from Back-End/GestionLogin/backends.py rename to Back-End/Auth/backends.py index 0db862f..474407e 100644 --- a/Back-End/GestionLogin/backends.py +++ b/Back-End/Auth/backends.py @@ -1,20 +1,20 @@ from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend -from GestionLogin.models import Profil +from Auth.models import Profile from N3wtSchool import bdd class EmailBackend(ModelBackend): def authenticate(self, request, username=None, password=None, **kwargs): if username is None: - username = kwargs.get(Profil.USERNAME_FIELD) + username = kwargs.get(Profile.USERNAME_FIELD) try: - user = Profil.objects.get(email=username) + user = Profile.objects.get(email=username) # Vérifie le mot de passe de l'utilisateur if user.check_password(password): return user - except Profil.DoesNotExist: + except Profile.DoesNotExist: return None diff --git a/Back-End/Auth/models.py b/Back-End/Auth/models.py new file mode 100644 index 0000000..cbbceac --- /dev/null +++ b/Back-End/Auth/models.py @@ -0,0 +1,32 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.core.validators import EmailValidator +from Establishment.models import Establishment + +class Profile(AbstractUser): + email = models.EmailField(max_length=255, unique=True, default="", validators=[EmailValidator()]) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ('password', ) + + code = models.CharField(max_length=200, default="", blank=True) + datePeremption = models.CharField(max_length=200, default="", blank=True) + + def __str__(self): + return self.email + +class ProfileRole(models.Model): + class RoleType(models.IntegerChoices): + PROFIL_UNDEFINED = -1, _('NON DEFINI') + PROFIL_ECOLE = 0, _('ECOLE') + PROFIL_ADMIN = 1, _('ADMIN') + PROFIL_PARENT = 2, _('PARENT') + + profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name='roles') + role_type = models.IntegerField(choices=RoleType.choices, default=RoleType.PROFIL_UNDEFINED) + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='profile_roles') + is_active = models.BooleanField(default=False) + + def __str__(self): + return f"{self.profile.email} - {self.get_role_type_display()} - {self.establishment.name}" \ No newline at end of file diff --git a/Back-End/Auth/serializers.py b/Back-End/Auth/serializers.py new file mode 100644 index 0000000..bee22ac --- /dev/null +++ b/Back-End/Auth/serializers.py @@ -0,0 +1,144 @@ +from rest_framework import serializers +from Auth.models import Profile, ProfileRole +from Establishment.models import Establishment +from Subscriptions.models import Guardian, RegistrationForm +from School.models import Teacher + +class ProfileSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + password = serializers.CharField(write_only=True) + roles = serializers.SerializerMethodField() + + class Meta: + model = Profile + fields = ['id', 'password', 'email', 'code', 'datePeremption', 'username', 'roles'] + extra_kwargs = {'password': {'write_only': True}} + + def get_roles(self, obj): + roles = ProfileRole.objects.filter(profile=obj) + roles_data = [] + for role in roles: + # Récupérer l'ID de l'associated_person en fonction du type de rôle + if role.role_type == ProfileRole.RoleType.PROFIL_PARENT: + guardian = Guardian.objects.filter(profile_role=role).first() + id_associated_person = guardian.id if guardian else None + else: + teacher = Teacher.objects.filter(profile_role=role).first() + id_associated_person = teacher.id if teacher else None + + roles_data.append({ + 'id_associated_person': id_associated_person, + 'role_type': role.role_type, + 'establishment': role.establishment.id, + 'establishment_name': role.establishment.name, + 'is_active': role.is_active, + }) + return roles_data + + def create(self, validated_data): + user = Profile( + username=validated_data['username'], + email=validated_data['email'], + code=validated_data.get('code', ''), + datePeremption=validated_data.get('datePeremption', '') + ) + user.set_password(validated_data['password']) + user.full_clean() + user.save() + return user + + def update(self, instance, validated_data): + password = validated_data.pop('password', None) + instance = super().update(instance, validated_data) + + if password: + instance.set_password(password) + + instance.full_clean() + instance.save() + return instance + + def to_representation(self, instance): + ret = super().to_representation(instance) + ret['password'] = '********' + ret['roles'] = self.get_roles(instance) + return ret + +class ProfileRoleSerializer(serializers.ModelSerializer): + profile = serializers.PrimaryKeyRelatedField(queryset=Profile.objects.all(), required=False) + profile_data = ProfileSerializer(write_only=True, required=False) + associated_profile_email = serializers.SerializerMethodField() + associated_person = serializers.SerializerMethodField() + + class Meta: + model = ProfileRole + fields = ['id', 'role_type', 'establishment', 'is_active', 'profile', 'profile_data', 'associated_profile_email', 'associated_person'] + + def create(self, validated_data): + profile_data = validated_data.pop('profile_data', None) + profile = validated_data.pop('profile', None) + + if profile_data: + profile_serializer = ProfileSerializer(data=profile_data) + profile_serializer.is_valid(raise_exception=True) + profile = profile_serializer.save() + elif profile: + profile = Profile.objects.get(id=profile.id) + + profile_role = ProfileRole.objects.create(profile=profile, **validated_data) + return profile_role + + def update(self, instance, validated_data): + profile_data = validated_data.pop('profile_data', None) + profile = validated_data.pop('profile', None) + + if profile_data: + profile_serializer = ProfileSerializer(instance.profile, data=profile_data) + profile_serializer.is_valid(raise_exception=True) + profile = profile_serializer.save() + elif profile: + profile = Profile.objects.get(id=profile.id) + + instance.role_type = validated_data.get('role_type', instance.role_type) + instance.establishment_id = validated_data.get('establishment', instance.establishment.id) + instance.is_active = validated_data.get('is_active', instance.is_active) + instance.save() + return instance + + def get_associated_profile_email(self, obj): + if obj.profile: + return obj.profile.email + return None + + def get_associated_person(self, obj): + if obj.role_type == ProfileRole.RoleType.PROFIL_PARENT: + guardian = Guardian.objects.filter(profile_role=obj).first() + if guardian: + students = guardian.student_set.all() + students_list = [] + for student in students: + registration_form = RegistrationForm.objects.filter(student=student).first() + registration_status = registration_form.status if registration_form else None + students_list.append({ + "student_name": f"{student.last_name} {student.first_name}", + "registration_status": registration_status + }) + return { + "id": guardian.id, + "guardian_name": f"{guardian.last_name} {guardian.first_name}", + "students": students_list + } + else: + teacher = Teacher.objects.filter(profile_role=obj).first() + if teacher: + classes = teacher.schoolclass_set.all() + classes_list = [{"id": classe.id, "name": classe.atmosphere_name} for classe in classes] + specialities = teacher.specialities.all() + specialities_list = [{"name": speciality.name, "color_code": speciality.color_code} for speciality in specialities] + return { + "id": teacher.id, + "teacher_name": f"{teacher.last_name} {teacher.first_name}", + "classes": classes_list, + "specialities": specialities_list + } + return None \ No newline at end of file diff --git a/Back-End/GestionLogin/templates/GestionLogin/login.html b/Back-End/Auth/templates/GestionLogin/login.html similarity index 100% rename from Back-End/GestionLogin/templates/GestionLogin/login.html rename to Back-End/Auth/templates/GestionLogin/login.html diff --git a/Back-End/GestionLogin/templates/GestionLogin/new-password.html b/Back-End/Auth/templates/GestionLogin/new-password.html similarity index 100% rename from Back-End/GestionLogin/templates/GestionLogin/new-password.html rename to Back-End/Auth/templates/GestionLogin/new-password.html diff --git a/Back-End/GestionLogin/templates/GestionLogin/reset-password.html b/Back-End/Auth/templates/GestionLogin/reset-password.html similarity index 100% rename from Back-End/GestionLogin/templates/GestionLogin/reset-password.html rename to Back-End/Auth/templates/GestionLogin/reset-password.html diff --git a/Back-End/GestionLogin/templates/GestionLogin/subscribe.html b/Back-End/Auth/templates/GestionLogin/subscribe.html similarity index 100% rename from Back-End/GestionLogin/templates/GestionLogin/subscribe.html rename to Back-End/Auth/templates/GestionLogin/subscribe.html diff --git a/Back-End/Auth/urls.py b/Back-End/Auth/urls.py new file mode 100644 index 0000000..a918c19 --- /dev/null +++ b/Back-End/Auth/urls.py @@ -0,0 +1,22 @@ +from django.urls import path, re_path + +from . import views +import Auth.views +from Auth.views import ProfileRoleView, ProfileRoleSimpleView, ProfileSimpleView, ProfileView, SessionView, LoginView, RefreshJWTView, SubscribeView, NewPasswordView, ResetPasswordView + +urlpatterns = [ + re_path(r'^csrf$', Auth.views.csrf, name='csrf'), + + re_path(r'^login$', LoginView.as_view(), name="login"), + re_path(r'^refreshJWT$', RefreshJWTView.as_view(), name="refresh_jwt"), + re_path(r'^subscribe$', SubscribeView.as_view(), name='subscribe'), + re_path(r'^newPassword$', NewPasswordView.as_view(), name='newPassword'), + re_path(r'^resetPassword/(?P[a-zA-Z]+)$', ResetPasswordView.as_view(), name='resetPassword'), + re_path(r'^infoSession$', SessionView.as_view(), name='infoSession'), + + re_path(r'^profiles$', ProfileView.as_view(), name="profile"), + re_path(r'^profiles/(?P[0-9]+)$', ProfileSimpleView.as_view(), name="profile"), + + re_path(r'^profileRoles$', ProfileRoleView.as_view(), name="profileRoles"), + re_path(r'^profileRoles/(?P[0-9]+)$', ProfileRoleSimpleView.as_view(), name="profileRoles"), +] \ No newline at end of file diff --git a/Back-End/GestionLogin/validator.py b/Back-End/Auth/validator.py similarity index 100% rename from Back-End/GestionLogin/validator.py rename to Back-End/Auth/validator.py diff --git a/Back-End/Auth/views.py b/Back-End/Auth/views.py new file mode 100644 index 0000000..a1eb820 --- /dev/null +++ b/Back-End/Auth/views.py @@ -0,0 +1,606 @@ +from django.conf import settings +from django.contrib.auth import login, authenticate, get_user_model +from django.http.response import JsonResponse +from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt, csrf_protect +from django.utils.decorators import method_decorator +from django.core.exceptions import ValidationError +from django.middleware.csrf import get_token +from rest_framework.views import APIView +from rest_framework.parsers import JSONParser +from rest_framework import status + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from datetime import datetime, timedelta +import jwt +from jwt.exceptions import ExpiredSignatureError, InvalidTokenError +import json + +from . import validator +from .models import Profile, ProfileRole +from rest_framework.decorators import action, api_view + +from Auth.serializers import ProfileSerializer, ProfileRoleSerializer +from Subscriptions.models import RegistrationForm +import Subscriptions.mailManager as mailer +import Subscriptions.util as util +import logging +from N3wtSchool import bdd, error + +from rest_framework_simplejwt.authentication import JWTAuthentication + +logger = logging.getLogger("AuthViews") + + +@swagger_auto_schema( + method='get', + operation_description="Obtenir un token CSRF", + responses={200: openapi.Response('Token CSRF', schema=openapi.Schema(type=openapi.TYPE_OBJECT, properties={ + 'csrfToken': openapi.Schema(type=openapi.TYPE_STRING) + }))} +) +@api_view(['GET']) +def csrf(request): + token = get_token(request) + return JsonResponse({'csrfToken': token}) + +class SessionView(APIView): + @swagger_auto_schema( + operation_description="Vérifier une session utilisateur", + manual_parameters=[openapi.Parameter('Authorization', openapi.IN_HEADER, type=openapi.TYPE_STRING, description='Bearer token')], + responses={ + 200: openapi.Response('Session valide', schema=openapi.Schema(type=openapi.TYPE_OBJECT, properties={ + 'user': openapi.Schema(type=openapi.TYPE_OBJECT, properties={ + 'id': openapi.Schema(type=openapi.TYPE_INTEGER), + 'email': openapi.Schema(type=openapi.TYPE_STRING), + 'roles': openapi.Schema(type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_OBJECT, properties={ + 'role_type': openapi.Schema(type=openapi.TYPE_STRING), + 'establishment': openapi.Schema(type=openapi.TYPE_STRING) + })) + }) + })), + 401: openapi.Response('Session invalide') + } + ) + def get(self, request): + token = request.META.get('HTTP_AUTHORIZATION', '').split('Bearer ')[-1] + + try: + decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) + userid = decoded_token.get('user_id') + user = Profile.objects.get(id=userid) + + roles = ProfileRole.objects.filter(profile=user).values('role_type', 'establishment__name') + + response_data = { + 'user': { + 'id': user.id, + 'email': user.email, + 'roles': list(roles) + } + } + return JsonResponse(response_data, status=status.HTTP_200_OK) + except jwt.ExpiredSignatureError: + return JsonResponse({"error": "Token has expired"}, status=status.HTTP_401_UNAUTHORIZED) + except jwt.InvalidTokenError: + return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED) + +class ProfileView(APIView): + @swagger_auto_schema( + operation_description="Obtenir la liste des profils", + responses={200: ProfileSerializer(many=True)} + ) + def get(self, request): + profilsList = bdd.getAllObjects(_objectName=Profile) + profils_serializer = ProfileSerializer(profilsList, many=True) + return JsonResponse(profils_serializer.data, safe=False) + + @swagger_auto_schema( + operation_description="Créer un nouveau profil", + request_body=ProfileSerializer, + responses={ + 200: ProfileSerializer, + 400: 'Données invalides' + } + ) + def post(self, request): + profil_data = JSONParser().parse(request) + profil_serializer = ProfileSerializer(data=profil_data) + + if profil_serializer.is_valid(): + profil = profil_serializer.save() + return JsonResponse(profil_serializer.data, safe=False) + + return JsonResponse(profil_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class ProfileSimpleView(APIView): + @swagger_auto_schema( + operation_description="Obtenir un profil par son ID", + responses={200: ProfileSerializer} + ) + def get(self, request, id): + profil = bdd.getObject(Profile, "id", id) + profil_serializer = ProfileSerializer(profil) + return JsonResponse(profil_serializer.data, safe=False) + + @swagger_auto_schema( + operation_description="Mettre à jour un profil", + request_body=ProfileSerializer, + responses={ + 200: 'Mise à jour réussie', + 400: 'Données invalides' + } + ) + def put(self, request, id): + data = JSONParser().parse(request) + profil = Profile.objects.get(id=id) + profil_serializer = ProfileSerializer(profil, data=data) + if profil_serializer.is_valid(): + profil_serializer.save() + return JsonResponse(profil_serializer.data, safe=False) + + return JsonResponse(profil_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_description="Supprimer un profil", + responses={200: 'Suppression réussie'} + ) + def delete(self, request, id): + return bdd.delete_object(Profile, id) + +@method_decorator(csrf_exempt, name='dispatch') +class LoginView(APIView): + @swagger_auto_schema( + operation_description="Connexion utilisateur", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['email', 'password', 'role_type'], + properties={ + 'email': openapi.Schema(type=openapi.TYPE_STRING), + 'password': openapi.Schema(type=openapi.TYPE_STRING), + 'role_type': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + responses={ + 200: openapi.Response('Connexion réussie', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'token': openapi.Schema(type=openapi.TYPE_STRING), + 'refresh': openapi.Schema(type=openapi.TYPE_STRING) + } + )), + 400: openapi.Response('Connexion échouée', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT), + 'errorMessage': openapi.Schema(type=openapi.TYPE_STRING) + } + )) + } + ) + def post(self, request): + data = JSONParser().parse(request) + validatorAuthentication = validator.ValidatorAuthentication(data=data) + retour = error.returnMessage[error.WRONG_ID] + validationOk, errorFields = validatorAuthentication.validate() + user = None + + if validationOk: + user = authenticate( + email=data.get('email'), + password=data.get('password'), + ) + if user is not None: + role_type = data.get('role_type') + primary_role = ProfileRole.objects.filter(profile=user, role_type=role_type, is_active=True).first() + + if not primary_role: + return JsonResponse({"errorMessage": "Profil inactif"}, status=status.HTTP_401_UNAUTHORIZED) + + login(request, user) + user.save() + retour = '' + + # Récupérer tous les rôles de l'utilisateur avec le type spécifié + roles = ProfileRole.objects.filter(profile=user, role_type=role_type).values('role_type', 'establishment__id', 'establishment__name') + + # Générer le JWT avec la bonne syntaxe datetime + access_payload = { + 'user_id': user.id, + 'email': user.email, + 'roles': list(roles), + 'type': 'access', + 'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'], + 'iat': datetime.utcnow(), + } + + access_token = jwt.encode(access_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM']) + # Générer le Refresh Token (exp: 7 jours) + refresh_payload = { + 'user_id': user.id, + 'type': 'refresh', + 'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'], + 'iat': datetime.utcnow(), + } + refresh_token = jwt.encode(refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM']) + + return JsonResponse({ + 'token': access_token, + 'refresh': refresh_token + }, safe=False) + + else: + retour = error.returnMessage[error.WRONG_ID] + + return JsonResponse({ + 'errorFields': errorFields, + 'errorMessage': retour, + }, safe=False, status=status.HTTP_400_BAD_REQUEST) + +class RefreshJWTView(APIView): + @swagger_auto_schema( + operation_description="Rafraîchir le token d'accès", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['refresh'], + properties={ + 'refresh': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + responses={ + 200: openapi.Response('Token rafraîchi avec succès', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'token': openapi.Schema(type=openapi.TYPE_STRING), + 'refresh': openapi.Schema(type=openapi.TYPE_STRING), + } + )), + 400: openapi.Response('Échec du rafraîchissement', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'errorMessage': openapi.Schema(type=openapi.TYPE_STRING) + } + )) + } + ) + @method_decorator(csrf_exempt, name='dispatch') + def post(self, request): + data = JSONParser().parse(request) + refresh_token = data.get("refresh") + logger.info(f"Token reçu: {refresh_token[:20]}...") # Ne pas logger le token complet pour la sécurité + + if not refresh_token: + return JsonResponse({'errorMessage': 'Refresh token manquant'}, status=400) + + try: + # Décoder le Refresh Token + logger.info("Tentative de décodage du token") + logger.info(f"Algorithme utilisé: {settings.SIMPLE_JWT['ALGORITHM']}") + + # Vérifier le format du token avant décodage + token_parts = refresh_token.split('.') + if len(token_parts) != 3: + logger.error("Format de token invalide - pas 3 parties") + return JsonResponse({'errorMessage': 'Format de token invalide'}, status=400) + + payload = jwt.decode( + refresh_token, + settings.SIMPLE_JWT['SIGNING_KEY'], + algorithms=[settings.SIMPLE_JWT['ALGORITHM']] # Noter le passage en liste + ) + + logger.info(f"Token décodé avec succès. Type: {payload.get('type')}") + # Vérifier s'il s'agit bien d'un Refresh Token + if payload.get('type') != 'refresh': + return JsonResponse({'errorMessage': 'Token invalide'}, status=400) + + # Récupérer les informations utilisateur + user = Profile.objects.get(id=payload['user_id']) + role_type = payload.get('role_type') + + # Récupérer le rôle principal de l'utilisateur + primary_role = ProfileRole.objects.filter(profile=user, role_type=role_type, is_active=True).first() + + if not primary_role: + return JsonResponse({'errorMessage': 'Profil inactif'}, status=400) + + # Générer un nouveau Access Token avec les informations complètes + new_access_payload = { + 'user_id': user.id, + 'email': user.email, + 'role_type': primary_role.get_role_type_display(), + 'establishment': primary_role.establishment.id, + 'type': 'access', + 'exp': datetime.utcnow() + settings.SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'], + 'iat': datetime.utcnow(), + } + + new_access_token = jwt.encode(new_access_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM']) + + new_refresh_payload = { + 'user_id': user.id, + 'role_type': role_type, + 'type': 'refresh', + 'exp': datetime.utcnow() + settings.SIMPLE_JWT['REFRESH_TOKEN_LIFETIME'], + 'iat': datetime.utcnow(), + } + new_refresh_token = jwt.encode(new_refresh_payload, settings.SIMPLE_JWT['SIGNING_KEY'], algorithm=settings.SIMPLE_JWT['ALGORITHM']) + + return JsonResponse({'token': new_access_token, 'refresh': new_refresh_token}, status=200) + + except ExpiredSignatureError as e: + logger.error(f"Token expiré: {str(e)}") + return JsonResponse({'errorMessage': 'Refresh token expiré'}, status=400) + except InvalidTokenError as e: + logger.error(f"Token invalide: {str(e)}") + return JsonResponse({'errorMessage': f'Token invalide: {str(e)}'}, status=400) + except Exception as e: + logger.error(f"Erreur inattendue: {str(e)}") + return JsonResponse({'errorMessage': f'Erreur inattendue: {str(e)}'}, status=400) + + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class SubscribeView(APIView): + @swagger_auto_schema( + operation_description="Inscription utilisateur", + manual_parameters=[ + openapi.Parameter( + 'establishment_id', openapi.IN_QUERY, + description="ID de l'établissement", + type=openapi.TYPE_INTEGER, + required=True + ) + ], + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['email', 'password1', 'password2'], + properties={ + 'email': openapi.Schema(type=openapi.TYPE_STRING), + 'password1': openapi.Schema(type=openapi.TYPE_STRING), + 'password2': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + responses={ + 200: openapi.Response('Inscription réussie', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING), + 'errorMessage': openapi.Schema(type=openapi.TYPE_STRING), + 'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT), + 'id': openapi.Schema(type=openapi.TYPE_INTEGER) + } + )) + } + ) + def post(self, request): + retourErreur = error.returnMessage[error.BAD_URL] + retour = '' + newProfilConnection = JSONParser().parse(request) + establishment_id = newProfilConnection['establishment_id'] + + validatorSubscription = validator.ValidatorSubscription(data=newProfilConnection) + validationOk, errorFields = validatorSubscription.validate() + + if validationOk: + # On vérifie que l'email existe : si ce n'est pas le cas, on retourne une erreur + profil = bdd.getProfile(Profile.objects.all(), newProfilConnection.get('email')) + if profil is None: + retourErreur = error.returnMessage[error.PROFIL_NOT_EXISTS] + else: + # Vérifier si le profil a déjà un rôle actif pour l'établissement donné + active_roles = ProfileRole.objects.filter(profile=profil, establishment=establishment_id, is_active=True) + if active_roles.exists(): + retourErreur = error.returnMessage[error.PROFIL_ACTIVE] + return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields, "id": profil.id}, safe=False) + else: + try: + profil.set_password(newProfilConnection.get('password1')) + profil.full_clean() + profil.save() + + # Récupérer le ProfileRole existant pour l'établissement et le profil + profile_role = ProfileRole.objects.filter(profile=profil, establishment=establishment_id).first() + if profile_role: + profile_role.is_active = True + profile_role.save() + else: + # Si aucun ProfileRole n'existe, en créer un nouveau + role_data = { + 'profile': profil.id, + 'establishment': establishment_id, + 'is_active': True + } + role_serializer = ProfileRoleSerializer(data=role_data) + if role_serializer.is_valid(): + role_serializer.save() + else: + return JsonResponse(role_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + retour = error.returnMessage[error.MESSAGE_ACTIVATION_PROFILE] + retourErreur = '' + return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields, "id": profil.id}, safe=False) + except ValidationError as e: + retourErreur = error.returnMessage[error.WRONG_MAIL_FORMAT] + return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields}, safe=False) + + return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields, "id": -1}, safe=False) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class NewPasswordView(APIView): + @swagger_auto_schema( + operation_description="Demande de nouveau mot de passe", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['email'], + properties={ + 'email': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + responses={ + 200: openapi.Response('Demande réussie', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING), + 'errorMessage': openapi.Schema(type=openapi.TYPE_STRING), + 'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT) + } + )) + } + ) + def post(self, request): + retourErreur = error.returnMessage[error.BAD_URL] + retour = '' + newProfilConnection = JSONParser().parse(request) + + validatorNewPassword = validator.ValidatorNewPassword(data=newProfilConnection) + validationOk, errorFields = validatorNewPassword.validate() + if validationOk: + profil = bdd.getProfile(Profile.objects.all(), newProfilConnection.get('email')) + if profil is None: + retourErreur = error.returnMessage[error.PROFIL_NOT_EXISTS] + else: + try: + # Génération d'une URL provisoire pour modifier le mot de passe + profil.code = util.genereRandomCode(12) + profil.datePeremption = util.calculeDatePeremption(util._now(), settings.EXPIRATION_URL_NB_DAYS) + profil.save() + retourErreur = '' + retour = error.returnMessage[error.MESSAGE_REINIT_PASSWORD] % (newProfilConnection.get('email')) + mailer.envoieReinitMotDePasse(newProfilConnection.get('email'), profil.code) + except ValidationError as e: + retourErreur = error.returnMessage[error.WRONG_MAIL_FORMAT] + return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields}, safe=False) + + return JsonResponse({'message': retour, 'errorMessage': retourErreur, "errorFields": errorFields}, safe=False) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class ResetPasswordView(APIView): + @swagger_auto_schema( + operation_description="Réinitialisation du mot de passe", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['password1', 'password2'], + properties={ + 'password1': openapi.Schema(type=openapi.TYPE_STRING), + 'password2': openapi.Schema(type=openapi.TYPE_STRING) + } + ), + responses={ + 200: openapi.Response('Réinitialisation réussie', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING), + 'errorMessage': openapi.Schema(type=openapi.TYPE_STRING), + 'errorFields': openapi.Schema(type=openapi.TYPE_OBJECT) + } + )) + } + ) + def post(self, request, code): + retourErreur = error.returnMessage[error.BAD_URL] + retour = '' + newProfilConnection = JSONParser().parse(request) + + validatorResetPassword = validator.ValidatorResetPassword(data=newProfilConnection) + validationOk, errorFields = validatorResetPassword.validate() + + profil = bdd.getObject(Profile, "code", code) + if profil: + + if datetime.strptime(util.convertToStr(util._now(), '%d-%m-%Y %H:%M'), '%d-%m-%Y %H:%M') > datetime.strptime(profil.datePeremption, '%d-%m-%Y %H:%M'): + retourErreur = error.returnMessage[error.EXPIRED_URL] % (_uuid) + elif validationOk: + retour = error.returnMessage[error.PASSWORD_CHANGED] + + profil.set_password(newProfilConnection.get('password1')) + profil.code = '' + profil.datePeremption = '' + profil.save() + retourErreur = '' + + return JsonResponse({'message': retour, "errorMessage": retourErreur, "errorFields": errorFields}, safe=False) + +class ProfileRoleView(APIView): + @swagger_auto_schema( + operation_description="Obtenir la liste des profile_roles", + responses={200: ProfileRoleSerializer(many=True)} + ) + def get(self, request): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + profiles_roles_List = bdd.getAllObjects(_objectName=ProfileRole) + if profiles_roles_List: + profiles_roles_List = profiles_roles_List.filter(establishment=establishment_id).distinct() + profile_roles_serializer = ProfileRoleSerializer(profiles_roles_List, many=True) + return JsonResponse(profile_roles_serializer.data, safe=False) + + @swagger_auto_schema( + operation_description="Créer un nouveau profile_role", + request_body=ProfileRoleSerializer, + responses={ + 200: ProfileRoleSerializer, + 400: 'Données invalides' + } + ) + def post(self, request): + profile_role_data = JSONParser().parse(request) + profile_role_serializer = ProfileRoleSerializer(data=profile_role_data) + + if profile_role_serializer.is_valid(): + profile_role = profile_role_serializer.save() + return JsonResponse(profile_role_serializer.data, safe=False) + + return JsonResponse(profile_role_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class ProfileRoleSimpleView(APIView): + @swagger_auto_schema( + operation_description="Obtenir un profile_role par son ID", + responses={200: ProfileRoleSerializer} + ) + def get(self, request, id): + profile_role = bdd.getObject(ProfileRole, "id", id) + profile_role_serializer = ProfileRoleSerializer(profile_role) + return JsonResponse(profile_role_serializer.data, safe=False) + + @swagger_auto_schema( + operation_description="Mettre à jour un profile_role", + request_body=ProfileRoleSerializer, + responses={ + 200: 'Mise à jour réussie', + 400: 'Données invalides' + } + ) + def put(self, request, id): + data = JSONParser().parse(request) + profile_role = ProfileRole.objects.get(id=id) + profile_role_serializer = ProfileRoleSerializer(profile_role, data=data) + if profile_role_serializer.is_valid(): + profile_role_serializer.save() + return JsonResponse(profile_role_serializer.data, safe=False) + + return JsonResponse(profile_role_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_description="Supprimer un profile_role", + responses={200: 'Suppression réussie'} + ) + def delete(self, request, id): + profile_role = ProfileRole.objects.get(id=id) + profile = profile_role.profile + profile_role.delete() + + # Vérifier si le profil n'a plus de rôles associés + if not ProfileRole.objects.filter(profile=profile).exists(): + profile.delete() + + return JsonResponse({'message': 'Suppression réussie'}, safe=False) \ No newline at end of file diff --git a/Back-End/Dockerfile b/Back-End/Dockerfile index 7f46014..0aa05ac 100644 --- a/Back-End/Dockerfile +++ b/Back-End/Dockerfile @@ -3,14 +3,15 @@ # The first instruction is what image we want to base our container on # We Use an official Python runtime as a parent image FROM python:3.12.7 +WORKDIR /Back-End # Allows docker to cache installed dependencies between builds COPY requirements.txt requirements.txt RUN pip install -r requirements.txt +RUN pip install pymupdf # Mounts the application code to the image COPY . . -WORKDIR /Back-End EXPOSE 8080 diff --git a/Back-End/DocuSeal/__init__.py b/Back-End/DocuSeal/__init__.py new file mode 100644 index 0000000..d64ffa4 --- /dev/null +++ b/Back-End/DocuSeal/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left blank to make this directory a Python package. diff --git a/Back-End/DocuSeal/urls.py b/Back-End/DocuSeal/urls.py new file mode 100644 index 0000000..06a434b --- /dev/null +++ b/Back-End/DocuSeal/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, re_path +from .views import generate_jwt_token, clone_template, remove_template, download_template + +urlpatterns = [ + re_path(r'generateToken$', generate_jwt_token, name='generate_jwt_token'), + re_path(r'cloneTemplate$', clone_template, name='clone_template'), + re_path(r'removeTemplate/(?P[0-9]+)$', remove_template, name='remove_template'), + re_path(r'downloadTemplate/(?P[\w-]+)$', download_template, name='download_template') +] diff --git a/Back-End/DocuSeal/views.py b/Back-End/DocuSeal/views.py new file mode 100644 index 0000000..40f6b50 --- /dev/null +++ b/Back-End/DocuSeal/views.py @@ -0,0 +1,164 @@ +from django.conf import settings +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from rest_framework.decorators import api_view +from rest_framework.response import Response +from rest_framework import status +import jwt +import datetime +import requests + +@csrf_exempt +@api_view(['POST']) +def generate_jwt_token(request): + # Vérifier la clé API + api_key = request.headers.get('X-Auth-Token') + if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]: + return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED) + + # Récupérer les données de la requête + user_email = request.data.get('user_email') + documents_urls = request.data.get('documents_urls', []) + id = request.data.get('id') # Récupérer le id + + # Vérifier les données requises + if not user_email: + return Response({'error': 'User email is required'}, status=status.HTTP_400_BAD_REQUEST) + + # Utiliser la configuration JWT de DocuSeal depuis les settings + jwt_secret = settings.DOCUSEAL_JWT['API_KEY'] + jwt_algorithm = settings.DOCUSEAL_JWT['ALGORITHM'] + expiration_delta = settings.DOCUSEAL_JWT['EXPIRATION_DELTA'] + + # Définir le payload + payload = { + 'user_email': user_email, + 'documents_urls': documents_urls, + 'template_id': id, # Ajouter le id au payload + 'exp': datetime.datetime.utcnow() + expiration_delta # Temps d'expiration du token + } + + # Générer le token JWT + token = jwt.encode(payload, jwt_secret, algorithm=jwt_algorithm) + + return Response({'token': token}, status=status.HTTP_200_OK) + +@csrf_exempt +@api_view(['POST']) +def clone_template(request): + # Vérifier la clé API + api_key = request.headers.get('X-Auth-Token') + if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]: + return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED) + + # Récupérer les données de la requête + document_id = request.data.get('templateId') + email = request.data.get('email') + is_required = request.data.get('is_required') + print(f'test is required = {is_required}') + + # Vérifier les données requises + if not document_id : + return Response({'error': 'template ID is required'}, status=status.HTTP_400_BAD_REQUEST) + + # URL de l'API de DocuSeal pour cloner le template + clone_url = f'https://docuseal.com/api/templates/{document_id}/clone' + + # Faire la requête pour cloner le template + try: + response = requests.post(clone_url, headers={ + 'Content-Type': 'application/json', + 'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY'] + }) + + if response.status_code != status.HTTP_200_OK: + return Response({'error': 'Failed to clone template'}, status=response.status_code) + + data = response.json() + + if is_required: + print(f'REQUIRED -> création dune submission') + # URL de l'API de DocuSeal pour créer une submission + submission_url = f'https://docuseal.com/api/submissions' + + # Faire la requête pour cloner le template + try: + clone_id = data['id'] + response = requests.post(submission_url, json={'template_id':clone_id, 'send_email': False, 'submitters': [{'email': email}]}, headers={ + 'Content-Type': 'application/json', + 'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY'] + }) + + if response.status_code != status.HTTP_200_OK: + return Response({'error': 'Failed to create submission'}, status=response.status_code) + + data = response.json() + data[0]['id'] = clone_id + print(f'DATA RESPONSE : {data[0]}') + return Response(data[0], status=status.HTTP_200_OK) + + except requests.RequestException as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + else : + print(f'NOT REQUIRED -> on ne crée pas de submission') + return Response(data, status=status.HTTP_200_OK) + + except requests.RequestException as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +@csrf_exempt +@api_view(['DELETE']) +def remove_template(request, id): + # Vérifier la clé API + api_key = request.headers.get('X-Auth-Token') + if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]: + return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED) + + # URL de l'API de DocuSeal pour cloner le template + clone_url = f'https://docuseal.com/api/templates/{id}' + + # Faire la requête pour cloner le template + try: + response = requests.delete(clone_url, headers={ + 'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY'] + }) + + if response.status_code != status.HTTP_200_OK: + return Response({'error': 'Failed to remove template'}, status=response.status_code) + + data = response.json() + return Response(data, status=status.HTTP_200_OK) + + except requests.RequestException as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +@csrf_exempt +@api_view(['GET']) +def download_template(request, slug): + # Vérifier la clé API + api_key = request.headers.get('X-Auth-Token') + if not api_key or api_key != settings.DOCUSEAL_JWT["API_KEY"]: + return Response({'error': 'Invalid API key'}, status=status.HTTP_401_UNAUTHORIZED) + + # Vérifier les données requises + if not slug : + return Response({'error': 'slug is required'}, status=status.HTTP_400_BAD_REQUEST) + + # URL de l'API de DocuSeal pour cloner le template + download_url = f'https://docuseal.com/submitters/{slug}/download' + + # Faire la requête pour cloner le template + try: + response = requests.get(download_url, headers={ + 'Content-Type': 'application/json', + 'X-Auth-Token': settings.DOCUSEAL_JWT['API_KEY'] + }) + + if response.status_code != status.HTTP_200_OK: + return Response({'error': 'Failed to download template'}, status=response.status_code) + + data = response.json() + return Response(data, status=status.HTTP_200_OK) + + except requests.RequestException as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/Back-End/Establishment/__init__.py b/Back-End/Establishment/__init__.py new file mode 100644 index 0000000..95d2d94 --- /dev/null +++ b/Back-End/Establishment/__init__.py @@ -0,0 +1 @@ +default_app_config = 'Establishment.apps.EstablishmentConfig' diff --git a/Back-End/GestionLogin/admin.py b/Back-End/Establishment/admin.py similarity index 100% rename from Back-End/GestionLogin/admin.py rename to Back-End/Establishment/admin.py diff --git a/Back-End/GestionEnseignants/apps.py b/Back-End/Establishment/apps.py similarity index 55% rename from Back-End/GestionEnseignants/apps.py rename to Back-End/Establishment/apps.py index 16c4573..ee46399 100644 --- a/Back-End/GestionEnseignants/apps.py +++ b/Back-End/Establishment/apps.py @@ -1,6 +1,7 @@ from django.apps import AppConfig - -class GestionenseignantsConfig(AppConfig): +class EstablishmentConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'GestionEnseignants' + name = 'Establishment' + + diff --git a/Back-End/Establishment/models.py b/Back-End/Establishment/models.py new file mode 100644 index 0000000..df5ad80 --- /dev/null +++ b/Back-End/Establishment/models.py @@ -0,0 +1,20 @@ +from django.db import models +from django.contrib.postgres.fields import ArrayField +from django.utils.translation import gettext_lazy as _ + +class StructureType(models.IntegerChoices): + MATERNELLE = 1, _('Maternelle') + PRIMAIRE = 2, _('Primaire') + SECONDAIRE = 3, _('Secondaire') + +class Establishment(models.Model): + name = models.CharField(max_length=255, unique=True) + address = models.CharField(max_length=255) + total_capacity = models.IntegerField() + establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices)) + licence_code = models.CharField(max_length=100, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/Back-End/Establishment/serializers.py b/Back-End/Establishment/serializers.py new file mode 100644 index 0000000..990038c --- /dev/null +++ b/Back-End/Establishment/serializers.py @@ -0,0 +1,91 @@ +from rest_framework import serializers +from .models import Establishment +from School.models import SchoolClass, Teacher, Speciality, Fee, Discount, PaymentMode, PaymentPlan +from Subscriptions.models import RegistrationForm, RegistrationFileGroup +from Auth.models import Profile + +class EstablishmentSerializer(serializers.ModelSerializer): + profile_count = serializers.SerializerMethodField() + profiles = serializers.SerializerMethodField() + school_class_count = serializers.SerializerMethodField() + school_classes = serializers.SerializerMethodField() + teacher_count = serializers.SerializerMethodField() + teachers = serializers.SerializerMethodField() + speciality_count = serializers.SerializerMethodField() + specialities = serializers.SerializerMethodField() + fee_count = serializers.SerializerMethodField() + fees = serializers.SerializerMethodField() + discount_count = serializers.SerializerMethodField() + discounts = serializers.SerializerMethodField() + active_payment_mode_count = serializers.SerializerMethodField() + active_payment_modes = serializers.SerializerMethodField() + active_payment_plan_count = serializers.SerializerMethodField() + active_payment_plans = serializers.SerializerMethodField() + file_group_count = serializers.SerializerMethodField() + file_groups = serializers.SerializerMethodField() + registration_form_count = serializers.SerializerMethodField() + registration_forms = serializers.SerializerMethodField() + + class Meta: + model = Establishment + fields = '__all__' + + def get_profile_count(self, obj): + return Profile.objects.filter(roles__establishment=obj).distinct().count() + + def get_profiles(self, obj): + return list(Profile.objects.filter(roles__establishment=obj).distinct().values_list('email', flat=True)) + + def get_school_class_count(self, obj): + return SchoolClass.objects.filter(establishment=obj).distinct().count() + + def get_school_classes(self, obj): + return list(SchoolClass.objects.filter(establishment=obj).distinct().values_list('atmosphere_name', flat=True)) + + def get_teacher_count(self, obj): + return Teacher.objects.filter(profile_role__establishment=obj).distinct().count() + + def get_teachers(self, obj): + return list(Teacher.objects.filter(profile_role__establishment=obj).distinct().values_list('last_name', 'first_name')) + + def get_speciality_count(self, obj): + return Speciality.objects.filter(establishment=obj).distinct().count() + + def get_specialities(self, obj): + return list(Speciality.objects.filter(establishment=obj).distinct().values_list('name', flat=True)) + + def get_fee_count(self, obj): + return Fee.objects.filter(establishment=obj).distinct().count() + + def get_fees(self, obj): + return list(Fee.objects.filter(establishment=obj).distinct().values_list('name', flat=True)) + + def get_discount_count(self, obj): + return Discount.objects.filter(establishment=obj).distinct().count() + + def get_discounts(self, obj): + return list(Discount.objects.filter(establishment=obj).distinct().values_list('name', flat=True)) + + def get_active_payment_mode_count(self, obj): + return PaymentMode.objects.filter(establishment=obj, is_active=True).distinct().count() + + def get_active_payment_modes(self, obj): + return list(PaymentMode.objects.filter(establishment=obj, is_active=True).distinct().values_list('mode', flat=True)) + + def get_active_payment_plan_count(self, obj): + return PaymentPlan.objects.filter(establishment=obj, is_active=True).distinct().count() + + def get_active_payment_plans(self, obj): + return list(PaymentPlan.objects.filter(establishment=obj, is_active=True).distinct().values_list('frequency', flat=True)) + + def get_file_group_count(self, obj): + return RegistrationFileGroup.objects.filter(establishment=obj).distinct().count() + + def get_file_groups(self, obj): + return list(RegistrationFileGroup.objects.filter(establishment=obj).distinct().values_list('name', flat=True)) + + def get_registration_form_count(self, obj): + return RegistrationForm.objects.filter(establishment=obj).distinct().count() + + def get_registration_forms(self, obj): + return list(RegistrationForm.objects.filter(establishment=obj).distinct().values_list('student__last_name', 'student__first_name')) \ No newline at end of file diff --git a/Back-End/Establishment/urls.py b/Back-End/Establishment/urls.py new file mode 100644 index 0000000..de40903 --- /dev/null +++ b/Back-End/Establishment/urls.py @@ -0,0 +1,7 @@ +from django.urls import path, re_path +from .views import EstablishmentListCreateView, EstablishmentDetailView + +urlpatterns = [ + re_path(r'^establishments$', EstablishmentListCreateView.as_view(), name='establishment_list_create'), + re_path(r'^establishments/(?P[0-9]+)$', EstablishmentDetailView.as_view(), name="establishment_detail"), +] \ No newline at end of file diff --git a/Back-End/Establishment/views.py b/Back-End/Establishment/views.py new file mode 100644 index 0000000..df48324 --- /dev/null +++ b/Back-End/Establishment/views.py @@ -0,0 +1,51 @@ +from django.http.response import JsonResponse +from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect +from django.utils.decorators import method_decorator +from rest_framework.parsers import JSONParser +from rest_framework.views import APIView +from rest_framework import status +from .models import Establishment +from .serializers import EstablishmentSerializer +from N3wtSchool.bdd import delete_object, getAllObjects, getObject + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class EstablishmentListCreateView(APIView): + def get(self, request): + establishments = getAllObjects(Establishment) + establishments_serializer = EstablishmentSerializer(establishments, many=True) + return JsonResponse(establishments_serializer.data, safe=False, status=status.HTTP_200_OK) + + def post(self, request): + establishment_data = JSONParser().parse(request) + establishment_serializer = EstablishmentSerializer(data=establishment_data) + if establishment_serializer.is_valid(): + establishment_serializer.save() + return JsonResponse(establishment_serializer.data, safe=False, status=status.HTTP_201_CREATED) + return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class EstablishmentDetailView(APIView): + def get(self, request, id=None): + try: + establishment = Establishment.objects.get(id=id) + establishment_serializer = EstablishmentSerializer(establishment) + return JsonResponse(establishment_serializer.data, safe=False) + except Establishment.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + def put(self, request, id): + establishment_data = JSONParser().parse(request) + try: + establishment = Establishment.objects.get(id=id) + except Establishment.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + establishment_serializer = EstablishmentSerializer(establishment, data=establishment_data, partial=True) + if establishment_serializer.is_valid(): + establishment_serializer.save() + return JsonResponse(establishment_serializer.data, safe=False) + return JsonResponse(establishment_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, id): + return delete_object(Establishment, id) \ No newline at end of file diff --git a/Back-End/GestionEnseignants/__init__.py b/Back-End/GestionEnseignants/__init__.py deleted file mode 100644 index 04932af..0000000 --- a/Back-End/GestionEnseignants/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'GestionEnseignants.apps.GestionenseignantsConfig' diff --git a/Back-End/GestionEnseignants/models.py b/Back-End/GestionEnseignants/models.py deleted file mode 100644 index 6a2afb8..0000000 --- a/Back-End/GestionEnseignants/models.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.db import models - -class Specialite(models.Model): - nom = models.CharField(max_length=100) - dateCreation = models.DateTimeField(auto_now=True) - codeCouleur = models.CharField(max_length=7, default='#FFFFFF') - - def __str__(self): - return self.nom - -class Enseignant(models.Model): - nom = models.CharField(max_length=100) - prenom = models.CharField(max_length=100) - mail = models.EmailField(unique=True) - specialite = models.ForeignKey(Specialite, on_delete=models.SET_NULL, null=True, blank=True, related_name='enseignants') - - def __str__(self): - return f"{self.nom} {self.prenom}" - -class Classe(models.Model): - nom_ambiance = models.CharField(max_length=255) - tranche_age = models.JSONField() - nombre_eleves = models.PositiveIntegerField() - langue_enseignement = models.CharField(max_length=255) - annee_scolaire = models.CharField(max_length=9) - dateCreation = models.DateTimeField(auto_now_add=True) - specialites = models.ManyToManyField(Specialite, related_name='classes') - enseignant_principal = models.ForeignKey(Enseignant, on_delete=models.SET_NULL, null=True, blank=True, related_name='classes_principal') - - def __str__(self): - return self.nom_ambiance - diff --git a/Back-End/GestionEnseignants/serializers.py b/Back-End/GestionEnseignants/serializers.py deleted file mode 100644 index a3e3a38..0000000 --- a/Back-End/GestionEnseignants/serializers.py +++ /dev/null @@ -1,83 +0,0 @@ -from rest_framework import serializers -from .models import Enseignant, Specialite, Classe -from N3wtSchool import settings -from django.utils import timezone -import pytz - -class SpecialiteSerializer(serializers.ModelSerializer): - dateCreation_formattee = serializers.SerializerMethodField() - class Meta: - model = Specialite - fields = '__all__' - - def get_dateCreation_formattee(self, obj): - utc_time = timezone.localtime(obj.dateCreation) # Convertir en heure locale - local_tz = pytz.timezone(settings.TZ_APPLI) - local_time = utc_time.astimezone(local_tz) - - return local_time.strftime("%d-%m-%Y %H:%M") - -class ClasseSerializer(serializers.ModelSerializer): - specialites = SpecialiteSerializer(many=True, read_only=True) - specialites_ids = serializers.PrimaryKeyRelatedField(queryset=Specialite.objects.all(), many=True, source='specialites') - dateCreation_formattee = serializers.SerializerMethodField() - enseignant_principal = serializers.SerializerMethodField() - enseignant_principal_id = serializers.PrimaryKeyRelatedField(queryset=Enseignant.objects.all(), source='enseignant_principal', write_only=False, read_only=False) - - class Meta: - model = Classe - fields = ['id', 'nom_ambiance', 'tranche_age', 'nombre_eleves', 'langue_enseignement', 'specialites', 'specialites_ids', 'enseignant_principal', 'enseignant_principal_id', 'annee_scolaire', 'dateCreation', 'dateCreation_formattee'] - - def get_enseignant_principal(self, obj): - from .serializers import EnseignantDetailSerializer - if obj.enseignant_principal: - return EnseignantDetailSerializer(obj.enseignant_principal).data - return None - - def create(self, validated_data): - specialites_data = validated_data.pop('specialites', []) - classe = Classe.objects.create(**validated_data) - classe.specialites.set(specialites_data) - return classe - - def update(self, instance, validated_data): - specialites_data = validated_data.pop('specialites', []) - instance.nom_ambiance = validated_data.get('nom_ambiance', instance.nom_ambiance) - instance.tranche_age = validated_data.get('tranche_age', instance.tranche_age) - instance.nombre_eleves = validated_data.get('nombre_eleves', instance.nombre_eleves) - instance.langue_enseignement = validated_data.get('langue_enseignement', instance.langue_enseignement) - instance.annee_scolaire = validated_data.get('annee_scolaire', instance.annee_scolaire) - instance.enseignant_principal = validated_data.get('enseignant_principal', instance.enseignant_principal) - instance.save() - instance.specialites.set(specialites_data) - return instance - - def get_dateCreation_formattee(self, obj): - utc_time = timezone.localtime(obj.dateCreation) # Convertir en heure locale - local_tz = pytz.timezone(settings.TZ_APPLI) - local_time = utc_time.astimezone(local_tz) - - return local_time.strftime("%d-%m-%Y %H:%M") - -class EnseignantSerializer(serializers.ModelSerializer): - specialite = SpecialiteSerializer(read_only=True) - specialite_id = serializers.PrimaryKeyRelatedField(queryset=Specialite.objects.all(), source='specialite', write_only=False, read_only=False) - classes_principal = ClasseSerializer(many=True, read_only=True) - - class Meta: - model = Enseignant - fields = ['id', 'nom', 'prenom', 'mail', 'specialite', 'specialite_id', 'classes_principal'] - - def create(self, validated_data): - specialite = validated_data.pop('specialite', None) - enseignant = Enseignant.objects.create(**validated_data) - enseignant.specialite = specialite - enseignant.save() - return enseignant - -class EnseignantDetailSerializer(serializers.ModelSerializer): - specialite = SpecialiteSerializer(read_only=True) - - class Meta: - model = Enseignant - fields = ['id', 'nom', 'prenom', 'mail', 'specialite'] diff --git a/Back-End/GestionEnseignants/urls.py b/Back-End/GestionEnseignants/urls.py deleted file mode 100644 index 41acf66..0000000 --- a/Back-End/GestionEnseignants/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import path, re_path - -from GestionEnseignants.views import EnseignantsView, EnseignantView, SpecialitesView, SpecialiteView, ClassesView, ClasseView - -urlpatterns = [ - re_path(r'^enseignants$', EnseignantsView.as_view(), name="enseignants"), - re_path(r'^enseignant$', EnseignantView.as_view(), name="enseignant"), - re_path(r'^enseignant/([0-9]+)$', EnseignantView.as_view(), name="enseignant"), - - re_path(r'^specialites$', SpecialitesView.as_view(), name="specialites"), - re_path(r'^specialite$', SpecialiteView.as_view(), name="specialite"), - re_path(r'^specialite/([0-9]+)$', SpecialiteView.as_view(), name="specialite"), - - re_path(r'^classes$', ClassesView.as_view(), name="classes"), - re_path(r'^classe$', ClasseView.as_view(), name="classe"), - re_path(r'^classe/([0-9]+)$', ClasseView.as_view(), name="classe"), -] \ No newline at end of file diff --git a/Back-End/GestionEnseignants/views.py b/Back-End/GestionEnseignants/views.py deleted file mode 100644 index 7007523..0000000 --- a/Back-End/GestionEnseignants/views.py +++ /dev/null @@ -1,180 +0,0 @@ -from django.http.response import JsonResponse -from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect -from django.utils.decorators import method_decorator -from rest_framework.parsers import JSONParser -from rest_framework.views import APIView -from django.core.cache import cache -from .models import Enseignant, Specialite, Classe -from .serializers import EnseignantSerializer, SpecialiteSerializer, ClasseSerializer -from N3wtSchool import bdd - -class EnseignantsView(APIView): - def get(self, request): - enseignantsList=bdd.getAllObjects(Enseignant) - enseignants_serializer=EnseignantSerializer(enseignantsList, many=True) - - return JsonResponse(enseignants_serializer.data, safe=False) - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class EnseignantView(APIView): - def get (self, request, _id): - enseignant = bdd.getObject(_objectName=Enseignant, _columnName='id', _value=_id) - enseignant_serializer=EnseignantSerializer(enseignant) - - return JsonResponse(enseignant_serializer.data, safe=False) - - def post(self, request): - enseignant_data=JSONParser().parse(request) - enseignant_serializer = EnseignantSerializer(data=enseignant_data) - - if enseignant_serializer.is_valid(): - enseignant_serializer.save() - - return JsonResponse(enseignant_serializer.data, safe=False) - - return JsonResponse(enseignant_serializer.errors, safe=False) - - def put(self, request, _id): - enseignant_data=JSONParser().parse(request) - enseignant = bdd.getObject(_objectName=Enseignant, _columnName='id', _value=_id) - enseignant_serializer = EnseignantSerializer(enseignant, data=enseignant_data) - if enseignant_serializer.is_valid(): - enseignant_serializer.save() - return JsonResponse(enseignant_serializer.data, safe=False) - - return JsonResponse(enseignant_serializer.errors, safe=False) - - def delete(self, request, _id): - enseignant = bdd.getObject(_objectName=Enseignant, _columnName='id', _value=_id) - if enseignant != None: - enseignant.delete() - - return JsonResponse("La suppression de la spécialité a été effectuée avec succès", safe=False) - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class SpecialitesView(APIView): - def get(self, request): - specialitesList=bdd.getAllObjects(Specialite) - specialites_serializer=SpecialiteSerializer(specialitesList, many=True) - - return JsonResponse(specialites_serializer.data, safe=False) - - def post(self, request): - specialites_data=JSONParser().parse(request) - all_valid = True - for specialite_data in specialites_data: - specialite_serializer = SpecialiteSerializer(data=specialite_data) - - if specialite_serializer.is_valid(): - specialite_serializer.save() - else: - all_valid = False - break - if all_valid: - specialitesList = bdd.getAllObjects(Specialite) - specialites_serializer = SpecialiteSerializer(specialitesList, many=True) - - return JsonResponse(specialite_serializer.errors, safe=False) - - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class SpecialiteView(APIView): - def get (self, request, _id): - specialite = bdd.getObject(_objectName=Specialite, _columnName='id', _value=_id) - specialite_serializer=SpecialiteSerializer(specialite) - - return JsonResponse(specialite_serializer.data, safe=False) - - def post(self, request): - specialite_data=JSONParser().parse(request) - specialite_serializer = SpecialiteSerializer(data=specialite_data) - - if specialite_serializer.is_valid(): - specialite_serializer.save() - return JsonResponse(specialite_serializer.data, safe=False) - - return JsonResponse(specialite_serializer.errors, safe=False) - - def put(self, request, _id): - specialite_data=JSONParser().parse(request) - specialite = bdd.getObject(_objectName=Specialite, _columnName='id', _value=_id) - specialite_serializer = SpecialiteSerializer(specialite, data=specialite_data) - if specialite_serializer.is_valid(): - specialite_serializer.save() - return JsonResponse(specialite_serializer.data, safe=False) - - return JsonResponse(specialite_serializer.errors, safe=False) - - def delete(self, request, _id): - specialite = bdd.getObject(_objectName=Specialite, _columnName='id', _value=_id) - if specialite != None: - specialite.delete() - - return JsonResponse("La suppression de la spécialité a été effectuée avec succès", safe=False) - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class ClassesView(APIView): - def get(self, request): - classesList=bdd.getAllObjects(Classe) - classes_serializer=ClasseSerializer(classesList, many=True) - return JsonResponse(classes_serializer.data, safe=False) - - def post(self, request): - all_valid = True - classes_data=JSONParser().parse(request) - for classe_data in classes_data: - classe_serializer = ClasseSerializer(data=classe_data) - - if classe_serializer.is_valid(): - classe_serializer.save() - else: - all_valid = False - break - - if all_valid: - classesList = bdd.getAllObjects(Classe) - classes_serializer = ClasseSerializer(classesList, many=True) - - return JsonResponse(classes_serializer.data, safe=False) - - return JsonResponse(classe_serializer.errors, safe=False) - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class ClasseView(APIView): - def get (self, request, _id): - classe = bdd.getObject(_objectName=Classe, _columnName='id', _value=_id) - classe_serializer=ClasseSerializer(classe) - - return JsonResponse(classe_serializer.data, safe=False) - - def post(self, request): - classe_data=JSONParser().parse(request) - classe_serializer = ClasseSerializer(data=classe_data) - - if classe_serializer.is_valid(): - classe_serializer.save() - return JsonResponse(classe_serializer.data, safe=False) - - return JsonResponse(classe_serializer.errors, safe=False) - - def put(self, request, _id): - classe_data=JSONParser().parse(request) - classe = bdd.getObject(_objectName=Classe, _columnName='id', _value=_id) - classe_serializer = ClasseSerializer(classe, data=classe_data) - if classe_serializer.is_valid(): - classe_serializer.save() - return JsonResponse(classe_serializer.data, safe=False) - - return JsonResponse(classe_serializer.errors, safe=False) - - def delete(self, request, _id): - classe = bdd.getObject(_objectName=Classe, _columnName='id', _value=_id) - if classe != None: - classe.delete() - - return JsonResponse("La suppression de la classe a été effectuée avec succès", safe=False) \ No newline at end of file diff --git a/Back-End/GestionInscriptions/__init__.py b/Back-End/GestionInscriptions/__init__.py deleted file mode 100644 index b290843..0000000 --- a/Back-End/GestionInscriptions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -default_app_config = 'GestionInscriptions.apps.GestionInscriptionsConfig' \ No newline at end of file diff --git a/Back-End/GestionInscriptions/automate.py b/Back-End/GestionInscriptions/automate.py deleted file mode 100644 index 510876e..0000000 --- a/Back-End/GestionInscriptions/automate.py +++ /dev/null @@ -1,45 +0,0 @@ -# state_machine.py -import json -from GestionInscriptions.models import FicheInscription -from GestionInscriptions.signals import clear_cache - -state_mapping = { - "ABSENT": FicheInscription.EtatDossierInscription.DI_ABSENT, - "CREE": FicheInscription.EtatDossierInscription.DI_CREE, - "ENVOYE": FicheInscription.EtatDossierInscription.DI_ENVOYE, - "EN_VALIDATION": FicheInscription.EtatDossierInscription.DI_EN_VALIDATION, - "A_RELANCER": FicheInscription.EtatDossierInscription.DI_A_RELANCER, - "VALIDE": FicheInscription.EtatDossierInscription.DI_VALIDE, - "ARCHIVE": FicheInscription.EtatDossierInscription.DI_ARCHIVE -} - -def load_config(config_file): - with open(config_file, 'r') as file: - config = json.load(file) - return config - -def getStateMachineObject(etat) : - return Automate_DI_Inscription(etat) - -def getStateMachineObjectState(etat): - return Automate_DI_Inscription(etat).state - -def updateStateMachine(di, transition) : - automateModel = load_config('GestionInscriptions/Configuration/automate.json') - state_machine = getStateMachineObject(di.etat) - print(f'etat DI : {state_machine.state}') - if state_machine.trigger(transition, automateModel): - di.etat = state_machine.state - di.save() - clear_cache() - -class Automate_DI_Inscription: - def __init__(self, initial_state): - self.state = initial_state - - def trigger(self, transition_name, config): - for transition in config["transitions"]: - if transition["name"] == transition_name and self.state == state_mapping[transition["from"]]: - self.state = state_mapping[transition["to"]] - return True - return False \ No newline at end of file diff --git a/Back-End/GestionInscriptions/mailManager.py b/Back-End/GestionInscriptions/mailManager.py deleted file mode 100644 index 653658d..0000000 --- a/Back-End/GestionInscriptions/mailManager.py +++ /dev/null @@ -1,74 +0,0 @@ -from django.core.mail import send_mail -import re -from N3wtSchool import settings - -def envoieReinitMotDePasse(recipients, code): - send_mail( - settings.EMAIL_REINIT_SUBJECT, - settings.EMAIL_REINIT_CORPUS%(str(code)), - settings.EMAIL_HOST_USER, - [recipients], - fail_silently=False, - ) - -def envoieDossierInscription(recipients): - errorMessage = '' - try: - print(f'{settings.EMAIL_HOST_USER}') - send_mail( - settings.EMAIL_INSCRIPTION_SUBJECT, - settings.EMAIL_INSCRIPTION_CORPUS%[recipients], - settings.EMAIL_HOST_USER, - [recipients], - fail_silently=False, - ) - except Exception as e: - errorMessage = str(e) - - return errorMessage - -def envoieRelanceDossierInscription(recipients, code): - errorMessage = '' - try: - send_mail( - settings.EMAIL_RELANCE_SUBJECT, - settings.EMAIL_RELANCE_CORPUS%str(code), - settings.EMAIL_HOST_USER, - [recipients], - fail_silently=False, - ) - except Exception as e: - errorMessage = str(e) - - return errorMessage - - -def envoieSEPA(recipients, ref): - send_mail( - settings.EMAIL_SEPA_SUBJECT%str(ref), - settings.EMAIL_SEPA_CORPUS, - settings.EMAIL_HOST_USER, - [recipients], - fail_silently=False, - ) - -def isValid(message, fiche_inscription): - # Est-ce que la référence du dossier est VALIDE - subject = message.subject - print ("++++ " + subject) - responsableMail = message.from_header - result = re.search('<(.*)>', responsableMail) - - if result: - responsableMail = result.group(1) - - result = re.search(r'.*\[Ref(.*)\].*', subject) - idMail = -1 - if result: - idMail = result.group(1).strip() - - eleve = fiche_inscription.eleve - responsable = eleve.getResponsablePrincipal() - mailReponsableAVerifier = responsable.mail - - return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id) \ No newline at end of file diff --git a/Back-End/GestionInscriptions/models.py b/Back-End/GestionInscriptions/models.py deleted file mode 100644 index 67bd194..0000000 --- a/Back-End/GestionInscriptions/models.py +++ /dev/null @@ -1,123 +0,0 @@ -from django.db import models -from django.utils.timezone import now -from django.conf import settings -from django.utils.translation import gettext_lazy as _ - -from GestionLogin.models import Profil - -class Langue(models.Model): - id = models.AutoField(primary_key=True) - libelle = models.CharField(max_length=200, default="") - - def __str__(self): - return "LANGUE" - -class Responsable(models.Model): - nom = models.CharField(max_length=200, default="") - prenom = models.CharField(max_length=200, default="") - dateNaissance = models.CharField(max_length=200, default="", blank=True) - adresse = models.CharField(max_length=200, default="", blank=True) - mail = models.CharField(max_length=200, default="", blank=True) - telephone = models.CharField(max_length=200, default="", blank=True) - profession = models.CharField(max_length=200, default="", blank=True) - profilAssocie = models.ForeignKey(Profil, on_delete=models.CASCADE) - - def __str__(self): - return self.nom + "_" + self.prenom - -class Frere(models.Model): - id = models.AutoField(primary_key=True) - nom = models.CharField(max_length=200, default="") - prenom = models.CharField(max_length=200, default="") - dateNaissance = models.CharField(max_length=200, default="", blank=True) - - def __str__(self): - return "FRERE" - -class Eleve(models.Model): - - class GenreEleve(models.IntegerChoices): - NONE = 0, _('Sélection du genre') - MALE = 1, _('Garçon') - FEMALE = 2, _('Fille') - - class NiveauEleve(models.IntegerChoices): - NONE = 0, _('Sélection du niveau') - TPS = 1, _('TPS - Très Petite Section') - PS = 2, _('PS - Petite Section') - MS = 3, _('MS - Moyenne Section') - GS = 4, _('GS - Grande Section') - - class ModePaiement(models.IntegerChoices): - NONE = 0, _('Sélection du mode de paiement') - PRELEVEMENT_SEPA = 1, _('Prélèvement SEPA') - CHEQUE = 2, _('Chèque') - - nom = models.CharField(max_length=200, default="") - prenom = models.CharField(max_length=200, default="") - genre = models.IntegerField(choices=GenreEleve, default=GenreEleve.NONE, blank=True) - niveau = models.IntegerField(choices=NiveauEleve, default=NiveauEleve.NONE, blank=True) - nationalite = models.CharField(max_length=200, default="", blank=True) - adresse = models.CharField(max_length=200, default="", blank=True) - dateNaissance = models.CharField(max_length=200, default="", blank=True) - lieuNaissance = models.CharField(max_length=200, default="", blank=True) - codePostalNaissance = models.IntegerField(default=0, blank=True) - medecinTraitant = models.CharField(max_length=200, default="", blank=True) - modePaiement = models.IntegerField(choices=ModePaiement, default=ModePaiement.NONE, blank=True) - - # Relation N-N - profils = models.ManyToManyField(Profil, blank=True) - - # Relation N-N - responsables = models.ManyToManyField(Responsable, blank=True) - - # Relation N-N - freres = models.ManyToManyField(Frere, blank=True) - - # Relation N-N - languesParlees = models.ManyToManyField(Langue, blank=True) - - def __str__(self): - return self.nom + "_" + self.prenom - - def getLanguesParlees(self): - return self.languesParlees.all() - - def getResponsablePrincipal(self): - return self.responsables.all()[0] - - def getResponsables(self): - return self.responsables.all() - - def getProfils(self): - return self.profils.all() - - def getFreres(self): - return self.freres.all() - - def getNbFreres(self): - return self.freres.count() - -class FicheInscription(models.Model): - - class EtatDossierInscription(models.IntegerChoices): - DI_ABSENT = 0, _('Pas de dossier d\'inscription') - DI_CREE = 1, _('Dossier d\'inscription créé') - DI_ENVOYE = 2, _('Dossier d\'inscription envoyé') - DI_EN_VALIDATION = 3, _('Dossier d\'inscription en cours de validation') - DI_A_RELANCER = 4, _('Dossier d\'inscription à relancer') - DI_VALIDE = 5, _('Dossier d\'inscription validé') - DI_ARCHIVE = 6, _('Dossier d\'inscription archivé') - - # Relation 1-1 - eleve = models.OneToOneField(Eleve, on_delete=models.CASCADE, primary_key=True) - etat = models.IntegerField(choices=EtatDossierInscription, default=EtatDossierInscription.DI_ABSENT) - dateMAJ = models.DateTimeField(auto_now=True) - notes = models.CharField(max_length=200, blank=True) - codeLienInscription = models.CharField(max_length=200, default="", blank=True) - fichierInscription = models.FileField(upload_to=settings.DOCUMENT_DIR, default="", blank=True) - di_associe = models.CharField(max_length=200, default="", blank=True) - - def __str__(self): - return "FI_" + self.eleve.nom + "_" + self.eleve.prenom - \ No newline at end of file diff --git a/Back-End/GestionInscriptions/serializers.py b/Back-End/GestionInscriptions/serializers.py deleted file mode 100644 index fbcd6e4..0000000 --- a/Back-End/GestionInscriptions/serializers.py +++ /dev/null @@ -1,176 +0,0 @@ -from rest_framework import serializers -from GestionInscriptions.models import FicheInscription, Eleve, Responsable, Frere, Langue -from GestionLogin.models import Profil -from GestionLogin.serializers import ProfilSerializer -from GestionMessagerie.models import Messagerie -from GestionNotification.models import Notification -from N3wtSchool import settings -from django.utils import timezone -import pytz - -class LanguesSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - class Meta: - model = Langue - fields = '__all__' - -class FrereSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - class Meta: - model = Frere - fields = '__all__' - -class ResponsableSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - profil_associe = serializers.SerializerMethodField() - class Meta: - model = Responsable - fields = '__all__' - - def get_profil_associe(self, obj): - return obj.profilAssocie.email - -class EleveSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - responsables = ResponsableSerializer(many=True, required=False) - freres = FrereSerializer(many=True, required=False) - langues = LanguesSerializer(many=True, required=False) - class Meta: - model = Eleve - fields = '__all__' - - def get_or_create_packages(self, responsables_data): - responsables_ids = [] - for responsable_data in responsables_data: - responsable_instance, created = Responsable.objects.get_or_create( id=responsable_data.get('id'), - defaults=responsable_data) - responsables_ids.append(responsable_instance.id) - return responsables_ids - - def create(self, validated_data): - responsables_data = validated_data.pop('responsables', []) - freres_data = validated_data.pop('freres', []) - langues_data = validated_data.pop('languesParlees', []) - eleve = Eleve.objects.create(**validated_data) - eleve.responsables.set(self.get_or_create_packages(responsables_data)) - eleve.freres.set(self.get_or_create_packages(freres_data)) - eleve.languesParlees.set(self.get_or_create_packages(langues_data)) - - return eleve - - def create_or_update_packages(self, responsables_data): - responsables_ids = [] - - - for responsable_data in responsables_data: - responsable_instance, created = Responsable.objects.update_or_create( id=responsable_data.get('id'), - defaults=responsable_data) - - responsables_ids.append(responsable_instance.id) - return responsables_ids - - def update(self, instance, validated_data): - responsables_data = validated_data.pop('responsables', []) - freres_data = validated_data.pop('freres', []) - langues_data = validated_data.pop('languesParlees', []) - if responsables_data: - instance.responsables.set(self.create_or_update_packages(responsables_data)) - if freres_data: - instance.freres.set(self.create_or_update_packages(freres_data)) - if langues_data: - instance.freres.set(self.create_or_update_packages(langues_data)) - - for field in self.fields: - try: - setattr(instance, field, validated_data[field]) - except KeyError: - pass - instance.save() - - return instance - -class FicheInscriptionSerializer(serializers.ModelSerializer): - eleve = EleveSerializer(many=False, required=True) - fichierInscription = serializers.FileField(required=False) - etat_label = serializers.SerializerMethodField() - dateMAJ_formattee = serializers.SerializerMethodField() - class Meta: - model = FicheInscription - fields = '__all__' - - def create(self, validated_data): - eleve_data = validated_data.pop('eleve') - eleve = EleveSerializer.create(EleveSerializer(), eleve_data) - ficheEleve = FicheInscription.objects.create(eleve=eleve, **validated_data) - return ficheEleve - - def update(self, instance, validated_data): - eleve_data = validated_data.pop('eleve') - eleve = instance.eleve - eleve_serializer = EleveSerializer.update(EleveSerializer(), eleve, eleve_data) - - for field in self.fields: - try: - setattr(instance, field, validated_data[field]) - except KeyError: - pass - instance.save() - - return instance - - def get_etat_label(self, obj): - return obj.get_etat_display() - - def get_dateMAJ_formattee(self, obj): - utc_time = timezone.localtime(obj.dateMAJ) # Convertir en heure locale - local_tz = pytz.timezone(settings.TZ_APPLI) - local_time = utc_time.astimezone(local_tz) - - return local_time.strftime("%d-%m-%Y %H:%M") - -class EleveByParentSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - class Meta: - model = Eleve - fields = ['id', 'nom', 'prenom'] - - def __init__(self, *args, **kwargs): - super(EleveByParentSerializer , self).__init__(*args, **kwargs) - for field in self.fields: - self.fields[field].required = False - -class FicheInscriptionByParentSerializer(serializers.ModelSerializer): - eleve = EleveByParentSerializer(many=False, required=True) - class Meta: - model = FicheInscription - fields = ['eleve', 'etat'] - - def __init__(self, *args, **kwargs): - super(FicheInscriptionByParentSerializer, self).__init__(*args, **kwargs) - for field in self.fields: - self.fields[field].required = False - -class ResponsableByDICreationSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - class Meta: - model = Responsable - fields = ['id', 'nom', 'prenom', 'mail', 'profilAssocie'] - -class EleveByDICreationSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - responsables = ResponsableByDICreationSerializer(many=True, required=False) - class Meta: - model = Eleve - fields = ['id', 'nom', 'prenom', 'responsables'] - - def __init__(self, *args, **kwargs): - super(EleveByDICreationSerializer , self).__init__(*args, **kwargs) - for field in self.fields: - self.fields[field].required = False - -class NotificationSerializer(serializers.ModelSerializer): - typeNotification_label = serializers.ReadOnlyField() - class Meta: - model = Notification - fields = '__all__' - \ No newline at end of file diff --git a/Back-End/GestionInscriptions/signals.py b/Back-End/GestionInscriptions/signals.py deleted file mode 100644 index 909148f..0000000 --- a/Back-End/GestionInscriptions/signals.py +++ /dev/null @@ -1,44 +0,0 @@ -from django.db.models.signals import post_save, post_delete, m2m_changed -from django.dispatch import receiver -from django.core.cache import cache -from GestionInscriptions.models import FicheInscription, Eleve, Responsable -from GestionLogin.models import Profil -from N3wtSchool import settings -from N3wtSchool.redis_client import redis_client -import logging -logger = logging.getLogger(__name__) - -def clear_cache(): - # Préfixes des clés à supprimer - prefixes = ['N3WT_'] - - for prefix in prefixes: - # Utiliser le motif pour obtenir les clés correspondant au préfixe - pattern = f'*{prefix}*' - logger.debug(f'pattern : {pattern}') - for key in redis_client.scan_iter(pattern): - redis_client.delete(key) - logger.debug(f'deleting : {key}') - -@receiver(post_save, sender=FicheInscription) -@receiver(post_delete, sender=FicheInscription) -def clear_cache_after_change(sender, instance, **kwargs): - clear_cache() - -@receiver(m2m_changed, sender=Eleve.responsables.through) -def check_orphan_reponsables(sender, **kwargs): - action = kwargs.pop('action', None) - instance = kwargs.pop('instance', None) - # pre_clear : lors de la suppression d'une FI (on fait un "clear" sur chaque relation) - if action in ('post_remove', 'post_clear'): - if instance.responsables.all(): - Responsable.objects.filter(eleve=None).delete() - -@receiver(m2m_changed, sender=Eleve.profils.through) -def check_orphan_profils(sender, **kwargs): - action = kwargs.pop('action', None) - instance = kwargs.pop('instance', None) - # pre_clear : lors de la suppression d'une FI (on fait un "clear" sur chaque relation) - if action in ('post_remove', 'post_clear'): - if instance.profils.all(): - Profil.objects.filter(eleve=None).delete() diff --git a/Back-End/GestionInscriptions/templates/pdfs/dossier_inscription.html b/Back-End/GestionInscriptions/templates/pdfs/dossier_inscription.html deleted file mode 100644 index b083a38..0000000 --- a/Back-End/GestionInscriptions/templates/pdfs/dossier_inscription.html +++ /dev/null @@ -1,97 +0,0 @@ - - - - - {{ pdf_title }} - - - - {% load myTemplateTag %} -
-
-

{{ pdf_title }}

-
-
-
- Signé le : {{ dateSignature }}
- A : {{ heureSignature }} -
-

ELEVE

- {% with niveau=eleve|recupereNiveauEleve %} - {% with genre=eleve|recupereGenreEleve %} - NOM : {{ eleve.nom }}
- PRENOM : {{ eleve.prenom }}
- ADRESSE : {{ eleve.adresse }}
- GENRE : {{ genre }}
- NE(E) LE : {{ eleve.dateNaissance }}
- A : {{ eleve.lieuNaissance }} ({{ eleve.codePostalNaissance }})
- NATIONALITE : {{ eleve.nationalite }}
- NIVEAU : {{ niveau }}
- MEDECIN TRAITANT : {{ eleve.medecinTraitant }}
- {% endwith %} - {% endwith %} -
-

RESPONSABLES

- {% with responsables_List=eleve.getResponsables %} - {% with freres_List=eleve.getFreres %} - {% for responsable in responsables_List%} -

Responsable {{ forloop.counter }}

- NOM : {{ responsable.nom }}
- PRENOM : {{ responsable.prenom }}
- ADRESSE : {{ responsable.adresse }}
- NE(E) LE : {{ responsable.dateNaissance }}
- MAIL : {{ responsable.mail }}
- TEL : {{ responsable.telephone }}
- PROFESSION : {{ responsable.profession }}
- {% endfor %} -
-

FRATRIE

- {% for frere in freres_List%} -

Frère - Soeur {{ forloop.counter }}

- NOM : {{ frere.nom }}
- PRENOM : {{ frere.prenom }}
- NE(E) LE : {{ frere.dateNaissance }}
- {% endfor %} -
-

MODALITES DE PAIEMENT

- {% with modePaiement=eleve|recupereModePaiement %} - {{ modePaiement }}
- {% endwith %} - {% endwith %} - {% endwith %} -
-
- - \ No newline at end of file diff --git a/Back-End/GestionInscriptions/templatetags/myTemplateTag.py b/Back-End/GestionInscriptions/templatetags/myTemplateTag.py deleted file mode 100644 index 094cf28..0000000 --- a/Back-End/GestionInscriptions/templatetags/myTemplateTag.py +++ /dev/null @@ -1,23 +0,0 @@ -from GestionInscriptions.models import FicheInscription, Eleve -from django import template -register = template.Library() - -# @register.filter -# def recupereFichiersDossierInscription(pk): - # fichiers_list = FicheInscription.objects.filter(fiche_inscription=pk) - # return fichiers_list - -@register.filter -def recupereModePaiement(pk): - ficheInscription = FicheInscription.objects.get(eleve=pk) - return Eleve.ModePaiement(int(ficheInscription.eleve.modePaiement)).label - -@register.filter -def recupereNiveauEleve(pk): - ficheInscription = FicheInscription.objects.get(eleve=pk) - return Eleve.NiveauEleve(int(ficheInscription.eleve.niveau)).label - -@register.filter -def recupereGenreEleve(pk): - ficheInscription = FicheInscription.objects.get(eleve=pk) - return Eleve.GenreEleve(int(ficheInscription.eleve.genre)).label \ No newline at end of file diff --git a/Back-End/GestionInscriptions/urls.py b/Back-End/GestionInscriptions/urls.py deleted file mode 100644 index 4fd699e..0000000 --- a/Back-End/GestionInscriptions/urls.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.urls import path, re_path - -from . import views -from GestionInscriptions.views import ListFichesInscriptionView, FicheInscriptionView, EleveView, ResponsableView, ListeEnfantsView, ListeElevesView - -urlpatterns = [ - re_path(r'^fichesInscription/([a-zA-z]+)$', ListFichesInscriptionView.as_view(), name="listefichesInscriptions"), - re_path(r'^ficheInscription$', FicheInscriptionView.as_view(), name="fichesInscriptions"), - re_path(r'^ficheInscription/([0-9]+)$', FicheInscriptionView.as_view(), name="fichesInscriptions"), - - # Page de formulaire d'inscription - ELEVE - re_path(r'^eleve/([0-9]+)$', EleveView.as_view(), name="eleves"), - - # Page de formulaire d'inscription - RESPONSABLE - re_path(r'^recupereDernierResponsable$', ResponsableView.as_view(), name="recupereDernierResponsable"), - - # Envoi d'un dossier d'inscription - re_path(r'^send/([0-9]+)$', views.send, name="send"), - - # Archivage d'un dossier d'inscription - re_path(r'^archive/([0-9]+)$', views.archive, name="archive"), - - # Envoi d'une relance de dossier d'inscription - re_path(r'^sendRelance/([0-9]+)$', views.relance, name="relance"), - - # Page PARENT - Liste des enfants - re_path(r'^enfants/([0-9]+)$', ListeEnfantsView.as_view(), name="enfants"), - - # Page INSCRIPTION - Liste des élèves - re_path(r'^eleves$', ListeElevesView.as_view(), name="enfants"), -] \ No newline at end of file diff --git a/Back-End/GestionInscriptions/util.py b/Back-End/GestionInscriptions/util.py deleted file mode 100644 index 45be230..0000000 --- a/Back-End/GestionInscriptions/util.py +++ /dev/null @@ -1,181 +0,0 @@ -from django.shortcuts import render,get_object_or_404,get_list_or_404 -from .models import FicheInscription, Eleve, Responsable, Frere -import time -from datetime import date, datetime, timedelta -from zoneinfo import ZoneInfo -from django.conf import settings -from N3wtSchool import renderers -from N3wtSchool import bdd - -from io import BytesIO -from django.core.files import File -from pathlib import Path -import os -from enum import Enum - -import random -import string -from rest_framework.parsers import JSONParser - -def recupereListeFichesInscription(): - context = { - "ficheInscriptions_list": bdd.getAllObjects(FicheInscription), - } - return context - -def recupereListeFichesInscriptionEnAttenteSEPA(): - - ficheInscriptionsSEPA_list = FicheInscription.objects.filter(modePaiement="Prélèvement SEPA").filter(etat=FicheInscription.EtatDossierInscription['SEPA_ENVOYE']) - return ficheInscriptionsSEPA_list - -def updateEleve(eleve, inputs, erase=False): - eleve.nom = inputs["nomEleve"] - eleve.prenom = inputs["prenomEleve"] - eleve.ambiance = inputs["ambiance"] - eleve.genre = inputs["genre"] - eleve.adresse = inputs["adresseEleve"] - eleve.dateNaissance = inputs["dateNaissanceEleve"] - eleve.lieuNaissance = inputs["lieuNaissanceEleve"] - eleve.codePostalNaissance = inputs["codePostalNaissanceEleve"] - eleve.nationalite = inputs["nationaliteEleve"] - eleve.medecinTraitant = inputs["medecinTraitantEleve"] - - - responsable=eleve.getResponsablePrincipal() - responsable.adresse = inputs["adresseResponsable1"] - responsable.dateNaissance = inputs["dateNaissanceResponsable1"] - responsable.profession = inputs["professionResponsable1"] - responsable.save() - - # Création du 2ème responsable - if inputs["nomResponsable2"] != "" and inputs["prenomResponsable2"] != "": - responsable2 = Responsable.objects.create(nom=inputs["nomResponsable2"], - prenom=inputs["prenomResponsable2"], - dateNaissance=inputs["dateNaissanceResponsable2"], - adresse=inputs["adresseResponsable2"], - mail=inputs["mailResponsable2"], - telephone=inputs["telephoneResponsable2"], - profession=inputs["professionResponsable2"]) - responsable2.save() - eleve.responsables.add(responsable2) - - # Création du 1er frère - if inputs["nomFrere1"] != "" and inputs["prenomFrere1"] != "": - frere1 = Frere.objects.create(nom=inputs["nomFrere1"], - prenom=inputs["prenomFrere1"], - dateNaissance=inputs["dateNaissanceFrere1"]) - frere1.save() - eleve.freres.add(frere1) - - # Création du 2ème frère - if inputs["nomFrere2"] != "" and inputs["prenomFrere2"] != "": - frere2 = Frere.objects.create(nom=inputs["nomFrere2"], - prenom=inputs["prenomFrere2"], - dateNaissance=inputs["dateNaissanceFrere2"]) - frere2.save() - eleve.freres.add(frere2) - - eleve.save() - -def _now(): - return datetime.now(ZoneInfo(settings.TZ_APPLI)) - -def convertToStr(dateValue, dateFormat): - return dateValue.strftime(dateFormat) - -def convertToDate(date_time): - format = '%d-%m-%Y %H:%M' - datetime_str = datetime.strptime(date_time, format) - - return datetime_str - -def convertTelephone(telephoneValue, separator='-'): - return f"{telephoneValue[:2]}{separator}{telephoneValue[2:4]}{separator}{telephoneValue[4:6]}{separator}{telephoneValue[6:8]}{separator}{telephoneValue[8:10]}" - -def generePDF(ficheEleve): - data = { - 'pdf_title': "Dossier d'inscription de %s"%ficheEleve.eleve.prenom, - 'dateSignature': convertToStr(_now(), '%d-%m-%Y'), - 'heureSignature': convertToStr(_now(), '%H:%M'), - 'eleve':ficheEleve.eleve, - } - - pdf = renderers.render_to_pdf('pdfs/dossier_inscription.html', data) - - nomFichierPDF = "Dossier_Inscription_%s_%s.pdf"%(ficheEleve.eleve.nom, ficheEleve.eleve.prenom) - pathFichier = Path(settings.DOCUMENT_DIR + "/" + nomFichierPDF) - if os.path.exists(str(pathFichier)): - os.remove(str(pathFichier)) - - receipt_file = BytesIO(pdf.content) - # fichier = Fichier.objects.create(fiche_inscription=ficheEleve) - # fichier.document = File(receipt_file, nomFichierPDF) - # fichier.save() - -def genereRandomCode(length): - return ''.join(random.choice(string.ascii_letters) for i in range(length)) - -def calculeDatePeremption(_start, nbDays): - return convertToStr(_start + timedelta(days=nbDays), settings.DATE_FORMAT) - -# Fonction permettant de retourner la valeur du QueryDict -# QueryDict [ index ] -> Dernière valeur d'une liste -# dict (QueryDict [ index ]) -> Toutes les valeurs de la liste -def _(liste): - return liste[0] - -def toNewEleveJSONRequest(jsonOrigin): - etat=FicheInscription.EtatDossierInscription.DI_CREE - telephone = convertTelephone(_(jsonOrigin['telephoneResponsable'])) - finalJSON = { - "eleve": - { - "nom" : _(jsonOrigin['nomEleve']), - "prenom" : _(jsonOrigin['prenomEleve']), - "responsables" : [ - { - "nom" : _(jsonOrigin['nomResponsable']), - "prenom" : _(jsonOrigin['prenomResponsable']), - "mail" : _(jsonOrigin['mailResponsable']), - "telephone" : telephone - } - ], - "profils" : [ - ], - }, - "etat": str(etat), - "dateMAJ": str(convertToStr(_now(), '%d-%m-%Y %H:%M')), - } - print(finalJSON) - return finalJSON - -def toEditEleveJSONRequest(jsonOrigin): - telephone = convertTelephone(_(jsonOrigin['telephoneResponsable']), '.') - finalJSON = { - "eleve": - { - "id" : _(jsonOrigin['fiche_id']), - "nom" : _(jsonOrigin['nomEleve']), - "prenom" : _(jsonOrigin['prenomEleve']), - "responsables" : [ - { - "id" : _(jsonOrigin['responsable_id']), - "nom" : _(jsonOrigin['nomResponsable']), - "prenom" : _(jsonOrigin['prenomResponsable']), - "mail" : _(jsonOrigin['mailResponsable']), - "telephone" : telephone - } - ], - "profils" : [ - ], - }, - "dateMAJ": str(convertToStr(_now(), '%d-%m-%Y %H:%M')), - } - print(finalJSON) - return finalJSON - -def getArgFromRequest(_argument, _request): - resultat = None - data=JSONParser().parse(_request) - resultat = data[_argument] - return resultat diff --git a/Back-End/GestionInscriptions/views.py b/Back-End/GestionInscriptions/views.py deleted file mode 100644 index 410edac..0000000 --- a/Back-End/GestionInscriptions/views.py +++ /dev/null @@ -1,289 +0,0 @@ -from django.http.response import JsonResponse -from django.contrib.auth import login, authenticate, get_user_model -from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect -from django.utils.decorators import method_decorator -from django.core.cache import cache -from django.core.paginator import Paginator -from django.core.files import File -from django.db.models import Q # Ajout de cet import -from rest_framework.parsers import JSONParser -from rest_framework.views import APIView -from rest_framework import status - -import json -from pathlib import Path -import os -from io import BytesIO - -import GestionInscriptions.mailManager as mailer -import GestionInscriptions.util as util -from GestionInscriptions.serializers import FicheInscriptionSerializer, EleveSerializer, FicheInscriptionByParentSerializer, EleveByDICreationSerializer -from GestionInscriptions.pagination import CustomPagination -from GestionInscriptions.signals import clear_cache -from .models import Eleve, Responsable, FicheInscription -from GestionInscriptions.automate import Automate_DI_Inscription, load_config, getStateMachineObjectState, updateStateMachine - -from GestionLogin.models import Profil - -from N3wtSchool import settings, renderers, bdd - -class ListFichesInscriptionView(APIView): - pagination_class = CustomPagination - - def get(self, request, _filter): - if _filter == 'all': - # Récupération des paramètres - search = request.GET.get('search', '').strip() - page_size = request.GET.get('page_size', None) - - # Gestion du page_size - if page_size is not None: - try: - page_size = int(page_size) - except ValueError: - page_size = settings.NB_RESULT_PER_PAGE - - cached_page_size = cache.get('N3WT_page_size') - if cached_page_size != page_size: - clear_cache() - cache.set('N3WT_page_size', page_size) - - # Gestion du cache - page_number = request.GET.get('page', 1) - cache_key = f'N3WT_ficheInscriptions_page_{page_number}_search_{search}' - cached_page = cache.get(cache_key) - if cached_page: - return JsonResponse(cached_page, safe=False) - - # Filtrage des résultats - if search: - # Utiliser la nouvelle fonction de recherche - ficheInscriptions_List = bdd.searchObjects( - FicheInscription, - search, - _excludeState=6 # Exclure les fiches archivées - ) - else: - # Récupère toutes les fiches non archivées - ficheInscriptions_List = bdd.getObjects(FicheInscription, 'etat', 6, _reverseCondition=True) - - # Pagination - paginator = self.pagination_class() - page = paginator.paginate_queryset(ficheInscriptions_List, request) - if page is not None: - ficheInscriptions_serializer = FicheInscriptionSerializer(page, many=True) - response_data = paginator.get_paginated_response(ficheInscriptions_serializer.data) - cache.set(cache_key, response_data, timeout=60*15) - return JsonResponse(response_data, safe=False) - - elif _filter == 'archived' : - page_size = request.GET.get('page_size', None) - if page_size is not None: - try: - page_size = int(page_size) - except ValueError: - page_size = settings.NB_RESULT_PER_PAGE - - cached_page_size = cache.get('N3WT_archived_page_size') - - # Comparer avec le nouveau page_size - if cached_page_size != page_size: - # Appeler cached_page() et mettre à jour le cache - clear_cache() - cache.set('N3WT_archived_page_size',page_size) - - page_number = request.GET.get('page', 1) - cache_key_page = f'N3WT_ficheInscriptions_archives_page_{page_number}' - cached_page = cache.get(cache_key_page) - if cached_page: - return JsonResponse(cached_page, safe=False) - - ficheInscriptions_List=bdd.getObjects(FicheInscription, 'etat', 6) - paginator = self.pagination_class() - page = paginator.paginate_queryset(ficheInscriptions_List, request) - if page is not None: - ficheInscriptions_serializer = FicheInscriptionSerializer(page, many=True) - response_data = paginator.get_paginated_response(ficheInscriptions_serializer.data) - cache.set(cache_key_page, response_data, timeout=60*15) - - return JsonResponse(response_data, safe=False) - - return JsonResponse(status=status.HTTP_404_NOT_FOUND) - - def post(self, request): - fichesEleve_data=JSONParser().parse(request) - for ficheEleve_data in fichesEleve_data: - # Ajout de la date de mise à jour - ficheEleve_data["dateMAJ"] = util.convertToStr(util._now(), '%d-%m-%Y %H:%M') - json.dumps(ficheEleve_data) - # Ajout du code d'inscription - code = util.genereRandomCode(12) - ficheEleve_data["codeLienInscription"] = code - ficheEleve_serializer = FicheInscriptionSerializer(data=ficheEleve_data) - - if ficheEleve_serializer.is_valid(): - ficheEleve_serializer.save() - - return JsonResponse(ficheEleve_serializer.errors, safe=False) - - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class FicheInscriptionView(APIView): - pagination_class = CustomPagination - - def get(self, request, _id): - ficheInscription=bdd.getObject(FicheInscription, "eleve__id", _id) - fiche_serializer=FicheInscriptionSerializer(ficheInscription) - return JsonResponse(fiche_serializer.data, safe=False) - - def post(self, request): - ficheEleve_data=JSONParser().parse(request) - # Ajout de la date de mise à jour - ficheEleve_data["dateMAJ"] = util.convertToStr(util._now(), '%d-%m-%Y %H:%M') - json.dumps(ficheEleve_data) - # Ajout du code d'inscription - code = util.genereRandomCode(12) - ficheEleve_data["codeLienInscription"] = code - - responsablesId = ficheEleve_data.pop('idResponsables', []) - ficheEleve_serializer = FicheInscriptionSerializer(data=ficheEleve_data) - - if ficheEleve_serializer.is_valid(): - di = ficheEleve_serializer.save() - - # Mise à jour de l'automate - updateStateMachine(di, 'creationDI') - - # Récupération du reponsable associé - for responsableId in responsablesId: - responsable = Responsable.objects.get(id=responsableId) - di.eleve.responsables.add(responsable) - di.save() - - ficheInscriptions_List=bdd.getAllObjects(FicheInscription) - return JsonResponse({'totalInscrits':len(ficheInscriptions_List)}, safe=False) - - return JsonResponse(ficheEleve_serializer.errors, safe=False) - - def put(self, request, id): - ficheEleve_data=JSONParser().parse(request) - admin = ficheEleve_data.pop('admin', 1) - ficheEleve_data["dateMAJ"] = str(util.convertToStr(util._now(), '%d-%m-%Y %H:%M')) - ficheEleve = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id) - currentState = getStateMachineObjectState(ficheEleve.etat) - if admin == 0 and currentState == FicheInscription.EtatDossierInscription.DI_ENVOYE: - json.dumps(ficheEleve_data) - - # Ajout du fichier d'inscriptions - data = { - 'pdf_title': "Dossier d'inscription de %s"%ficheEleve.eleve.prenom, - 'dateSignature': util.convertToStr(util._now(), '%d-%m-%Y'), - 'heureSignature': util.convertToStr(util._now(), '%H:%M'), - 'eleve':ficheEleve.eleve, - } - - pdf = renderers.render_to_pdf('pdfs/dossier_inscription.html', data) - - nomFichierPDF = "Dossier_Inscription_%s_%s.pdf"%(ficheEleve.eleve.nom, ficheEleve.eleve.prenom) - pathFichier = Path(settings.DOCUMENT_DIR + "/" + nomFichierPDF) - if os.path.exists(str(pathFichier)): - print(f'File exists : {str(pathFichier)}') - os.remove(str(pathFichier)) - - receipt_file = BytesIO(pdf.content) - ficheEleve.fichierInscription = File(receipt_file, nomFichierPDF) - - # Mise à jour de l'automate - updateStateMachine(di, 'saisiDI') - - ficheEleve_serializer = FicheInscriptionSerializer(ficheEleve, data=ficheEleve_data) - if ficheEleve_serializer.is_valid(): - di = ficheEleve_serializer.save() - return JsonResponse("Updated Successfully", safe=False) - - return JsonResponse(ficheEleve_serializer.errors, safe=False) - - def delete(self, request, id): - fiche_inscription = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id) - if fiche_inscription != None: - eleve = fiche_inscription.eleve - eleve.responsables.clear() - eleve.profils.clear() - eleve.delete() - clear_cache() - - return JsonResponse("La suppression du dossier a été effectuée avec succès", safe=False) - - return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False) - -class EleveView(APIView): - def get(self, request, _id): - eleve = bdd.getObject(_objectName=Eleve, _columnName='id', _value=_id) - eleve_serializer = EleveSerializer(eleve) - return JsonResponse(eleve_serializer.data, safe=False) - -class ResponsableView(APIView): - def get(self, request): - lastResponsable = bdd.getLastId(Responsable) - return JsonResponse({"lastid":lastResponsable}, safe=False) - -def send(request, id): - fiche_inscription = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id) - if fiche_inscription != None: - eleve = fiche_inscription.eleve - responsable = eleve.getResponsablePrincipal() - mail = responsable.mail - errorMessage = mailer.envoieDossierInscription(mail) - if errorMessage == '': - fiche_inscription.dateMAJ=util.convertToStr(util._now(), '%d-%m-%Y %H:%M') - # Mise à jour de l'automate - updateStateMachine(fiche_inscription, 'envoiDI') - - return JsonResponse({"errorMessage":errorMessage}, safe=False) - - return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False) - -def archive(request, id): - fiche_inscription = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id) - if fiche_inscription != None: - fiche_inscription.dateMAJ=util.convertToStr(util._now(), '%d-%m-%Y %H:%M') - # Mise à jour de l'automate - updateStateMachine(fiche_inscription, 'archiveDI') - - return JsonResponse({"errorMessage":''}, safe=False) - - return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False) - -def relance(request, id): - fiche_inscription = bdd.getObject(_objectName=FicheInscription, _columnName='eleve__id', _value=id) - if fiche_inscription != None: - eleve = fiche_inscription.eleve - responsable = eleve.getResponsablePrincipal() - mail = responsable.mail - errorMessage = mailer.envoieRelanceDossierInscription(mail, fiche_inscription.codeLienInscription) - if errorMessage == '': - fiche_inscription.etat=FicheInscription.EtatDossierInscription.DI_ENVOYE - fiche_inscription.dateMAJ=util.convertToStr(util._now(), '%d-%m-%Y %H:%M') - fiche_inscription.save() - - return JsonResponse({"errorMessage":errorMessage}, safe=False) - - return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False) - -# API utilisée pour la vue parent -class ListeEnfantsView(APIView): - # Récupération des élèves d'un parent - # idProfile : identifiant du profil connecté rattaché aux fiches d'élèves - def get(self, request, _idProfile): - students = bdd.getObjects(_objectName=FicheInscription, _columnName='eleve__responsables__profilAssocie__id', _value=_idProfile) - students_serializer = FicheInscriptionByParentSerializer(students, many=True) - return JsonResponse(students_serializer.data, safe=False) - -# API utilisée pour la vue de création d'un DI -class ListeElevesView(APIView): - # Récupération de la liste des élèves inscrits ou en cours d'inscriptions - def get(self, request): - students = bdd.getAllObjects(_objectName=Eleve) - students_serializer = EleveByDICreationSerializer(students, many=True) - return JsonResponse(students_serializer.data, safe=False) diff --git a/Back-End/GestionLogin/models.py b/Back-End/GestionLogin/models.py deleted file mode 100644 index f16dcbb..0000000 --- a/Back-End/GestionLogin/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.contrib.auth.models import AbstractUser -from django.db import models -from django.utils.translation import gettext_lazy as _ -from django.core.validators import EmailValidator - -class Profil(AbstractUser): - class Droits(models.IntegerChoices): - PROFIL_UNDEFINED = -1, _('Profil non défini') - PROFIL_ECOLE = 0, _('Profil école') - PROFIL_PARENT = 1, _('Profil parent') - PROFIL_ADMIN = 2, _('Profil administrateur') - - email = models.EmailField(max_length=255, unique=True, default="", validators=[EmailValidator()]) - - USERNAME_FIELD = 'email' - - REQUIRED_FIELDS = ('password', ) - - code = models.CharField(max_length=200, default="", blank=True) - datePeremption = models.CharField(max_length=200, default="", blank=True) - droit = models.IntegerField(choices=Droits, default=Droits.PROFIL_UNDEFINED) - estConnecte = models.BooleanField(default=False, blank=True) - - def __str__(self): - return self.email + " - " + str(self.droit) diff --git a/Back-End/GestionLogin/serializers.py b/Back-End/GestionLogin/serializers.py deleted file mode 100644 index e5653de..0000000 --- a/Back-End/GestionLogin/serializers.py +++ /dev/null @@ -1,28 +0,0 @@ -from rest_framework import serializers -from GestionLogin.models import Profil -from django.core.exceptions import ValidationError - -class ProfilSerializer(serializers.ModelSerializer): - id = serializers.IntegerField(required=False) - password = serializers.CharField(write_only=True) - - class Meta: - model = Profil - fields = ['id', 'password', 'email', 'code', 'datePeremption', 'estConnecte', 'droit', 'username', 'is_active'] - extra_kwargs = {'password': {'write_only': True}} - - def create(self, validated_data): - user = Profil( - username=validated_data['username'], - email=validated_data['email'], - is_active=validated_data['is_active'], - droit=validated_data['droit'] - ) - user.set_password(validated_data['password']) - user.save() - return user - - def to_representation(self, instance): - ret = super().to_representation(instance) - ret['password'] = '********' - return ret diff --git a/Back-End/GestionLogin/urls.py b/Back-End/GestionLogin/urls.py deleted file mode 100644 index d0f29b7..0000000 --- a/Back-End/GestionLogin/urls.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.urls import path, re_path - -from . import views -import GestionLogin.views -from GestionLogin.views import ProfilView, ListProfilView, SessionView, LoginView, SubscribeView, NewPasswordView, ResetPasswordView - -urlpatterns = [ - re_path(r'^csrf$', GestionLogin.views.csrf, name='csrf'), - - re_path(r'^login$', LoginView.as_view(), name="login"), - re_path(r'^subscribe$', SubscribeView.as_view(), name='subscribe'), - re_path(r'^newPassword$', NewPasswordView.as_view(), name='newPassword'), - re_path(r'^resetPassword/([a-zA-Z]+)$', ResetPasswordView.as_view(), name='resetPassword'), - re_path(r'^infoSession$', GestionLogin.views.infoSession, name='infoSession'), - - re_path(r'^profils$', ListProfilView.as_view(), name="profil"), - re_path(r'^profil$', ProfilView.as_view(), name="profil"), - re_path(r'^profil/([0-9]+)$', ProfilView.as_view(), name="profil"), - - # Test SESSION VIEW - re_path(r'^session$', SessionView.as_view(), name="session"), -] \ No newline at end of file diff --git a/Back-End/GestionLogin/views.py b/Back-End/GestionLogin/views.py deleted file mode 100644 index 76590cd..0000000 --- a/Back-End/GestionLogin/views.py +++ /dev/null @@ -1,264 +0,0 @@ -from django.conf import settings -from django.contrib.auth import login, authenticate, get_user_model -from django.http.response import JsonResponse -from django.views.decorators.csrf import ensure_csrf_cookie, csrf_exempt, csrf_protect -from django.utils.decorators import method_decorator -from django.core.exceptions import ValidationError -from django.core.cache import cache -from django.middleware.csrf import get_token -from rest_framework.views import APIView -from rest_framework.parsers import JSONParser -from rest_framework import status - -from datetime import datetime -import jwt -import json - -from . import validator -from .models import Profil - -from GestionInscriptions.models import FicheInscription -from GestionInscriptions.serializers import ProfilSerializer -from GestionInscriptions.signals import clear_cache -import GestionInscriptions.mailManager as mailer -import GestionInscriptions.util as util - -from N3wtSchool import bdd, error - -def csrf(request): - token = get_token(request) - return JsonResponse({'csrfToken': token}) - -class SessionView(APIView): - - def post(self, request): - token = request.META.get('HTTP_AUTHORIZATION', '').split('Bearer ')[-1] - - try: - decoded_token = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256']) - print(f'decode : {decoded_token}') - user_id = decoded_token.get('id') - user = Profil.objects.get(id=user_id) - - response_data = { - 'user': { - 'id': user.id, - 'email': user.email, - 'role': user.droit, # Assure-toi que le champ 'droit' existe et contient le rôle - } - } - return JsonResponse(response_data, status=status.HTTP_200_OK) - except jwt.ExpiredSignatureError: - return JsonResponse({"error": "Token has expired"}, status=status.HTTP_401_UNAUTHORIZED) - except jwt.InvalidTokenError: - return JsonResponse({"error": "Invalid token"}, status=status.HTTP_401_UNAUTHORIZED) - -class ListProfilView(APIView): - def get(self, request): - profilsList = bdd.getAllObjects(_objectName=Profil) - profils_serializer = ProfilSerializer(profilsList, many=True) - return JsonResponse(profils_serializer.data, safe=False) - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class ProfilView(APIView): - def get(self, request, _id): - profil=bdd.getObject(Profil, "id", _id) - profil_serializer=ProfilSerializer(profil) - return JsonResponse(profil_serializer.data, safe=False) - - def post(self, request): - profil_data=JSONParser().parse(request) - print(f'{profil_data}') - profil_serializer = ProfilSerializer(data=profil_data) - - if profil_serializer.is_valid(): - profil_serializer.save() - - return JsonResponse(profil_serializer.data, safe=False) - - - return JsonResponse(profil_serializer.errors, safe=False) - - def put(self, request, _id): - data=JSONParser().parse(request) - profil = Profil.objects.get(id=_id) - profil_serializer = ProfilSerializer(profil, data=data) - if profil_serializer.is_valid(): - profil_serializer.save() - return JsonResponse("Updated Successfully", safe=False) - - return JsonResponse(profil_serializer.errors, safe=False) - -def infoSession(request): - profilCache = cache.get('session_cache') - if profilCache: - return JsonResponse({"cacheSession":True,"typeProfil":profilCache.droit, "username":profilCache.email}, safe=False) - else: - return JsonResponse({"cacheSession":False,"typeProfil":Profil.Droits.PROFIL_UNDEFINED, "username":""}, safe=False) - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class LoginView(APIView): - - def get(self, request): - return JsonResponse({ - 'errorFields':'', - 'errorMessage':'', - 'profil':0, - }, safe=False) - - def post(self, request): - data=JSONParser().parse(request) - validatorAuthentication = validator.ValidatorAuthentication(data=data) - retour = error.returnMessage[error.WRONG_ID] - validationOk, errorFields = validatorAuthentication.validate() - user = None - if validationOk: - user = authenticate( - email=data.get('email'), - password=data.get('password'), - ) - if user is not None: - if user.is_active: - login(request, user) - user.estConnecte = True - user.save() - clear_cache() - retour = '' - else: - retour = error.returnMessage[error.PROFIL_INACTIVE] - - # Génération du token JWT - # jwt_token = jwt.encode({ - # 'id': user.id, - # 'email': user.email, - # 'role': "admin" - # }, settings.SECRET_KEY, algorithm='HS256') - else: - retour = error.returnMessage[error.WRONG_ID] - - - return JsonResponse({ - 'errorFields':errorFields, - 'errorMessage':retour, - 'profil':user.id if user else -1, - #'jwtToken':jwt_token if profil != -1 else '' - }, safe=False) - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class SubscribeView(APIView): - - def get(self, request): - return JsonResponse({ - 'message':'', - 'errorFields':'', - 'errorMessage':'' - }, safe=False) - - def post(self, request): - retourErreur = error.returnMessage[error.BAD_URL] - retour = '' - newProfilConnection=JSONParser().parse(request) - - validatorSubscription = validator.ValidatorSubscription(data=newProfilConnection) - validationOk, errorFields = validatorSubscription.validate() - - - if validationOk: - - # On vérifie que l'email existe : si ce n'est pas le cas, on retourne une erreur - profil = bdd.getProfile(Profil.objects.all(), newProfilConnection.get('email')) - if profil == None: - retourErreur = error.returnMessage[error.PROFIL_NOT_EXISTS] - else: - if profil.is_active: - retourErreur=error.returnMessage[error.PROFIL_ACTIVE] - return JsonResponse({'message':retour,'errorMessage':retourErreur, "errorFields":errorFields, "id":profil.id}, safe=False) - else: - try: - profil.set_password(newProfilConnection.get('password1')) - profil.is_active = True - profil.full_clean() - profil.save() - clear_cache() - retour = error.returnMessage[error.MESSAGE_ACTIVATION_PROFILE] - retourErreur='' - return JsonResponse({'message':retour,'errorMessage':retourErreur, "errorFields":errorFields, "id":profil.id}, safe=False) - except ValidationError as e: - retourErreur = error.returnMessage[error.WRONG_MAIL_FORMAT] - return JsonResponse({'message':retour,'errorMessage':retourErreur, "errorFields":errorFields}, safe=False) - - return JsonResponse({'message':retour, 'errorMessage':retourErreur, "errorFields":errorFields, "id":-1}, safe=False) - - - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class NewPasswordView(APIView): - def get(self, request): - return JsonResponse({ - 'message':'', - 'errorFields':'', - 'errorMessage':'' - }, safe=False) - - def post(self, request): - retourErreur = error.returnMessage[error.BAD_URL] - retour = '' - newProfilConnection=JSONParser().parse(request) - - validatorNewPassword = validator.ValidatorNewPassword(data=newProfilConnection) - validationOk, errorFields = validatorNewPassword.validate() - if validationOk: - - profil = bdd.getProfile(Profil.objects.all(), newProfilConnection.get('email')) - if profil == None: - retourErreur = error.returnMessage[error.PROFIL_NOT_EXISTS] - else: - # Génération d'une URL provisoire pour modifier le mot de passe - profil.code = util.genereRandomCode(12) - profil.datePeremption = util.calculeDatePeremption(util._now(), settings.EXPIRATION_URL_NB_DAYS) - profil.save() - clear_cache() - retourErreur = '' - retour = error.returnMessage[error.MESSAGE_REINIT_PASSWORD]%(newProfilConnection.get('email')) - mailer.envoieReinitMotDePasse(newProfilConnection.get('email'), profil.code) - - return JsonResponse({'message':retour, 'errorMessage':retourErreur, "errorFields":errorFields}, safe=False) - -@method_decorator(csrf_protect, name='dispatch') -@method_decorator(ensure_csrf_cookie, name='dispatch') -class ResetPasswordView(APIView): - def get(self, request, _uuid): - return JsonResponse({ - 'message':'', - 'errorFields':'', - 'errorMessage':'' - }, safe=False) - - def post(self, request, _uuid): - retourErreur = error.returnMessage[error.BAD_URL] - retour = '' - newProfilConnection=JSONParser().parse(request) - - validatorResetPassword = validator.ValidatorResetPassword(data=newProfilConnection) - validationOk, errorFields = validatorResetPassword.validate() - - profil = bdd.getObject(Profil, "code", _uuid) - if profil: - - if datetime.strptime(util.convertToStr(util._now(), '%d-%m-%Y %H:%M'), '%d-%m-%Y %H:%M') > datetime.strptime(profil.datePeremption, '%d-%m-%Y %H:%M'): - retourErreur = error.returnMessage[error.EXPIRED_URL]%(_uuid) - elif validationOk: - retour = error.returnMessage[error.PASSWORD_CHANGED] - - profil.set_password(newProfilConnection.get('password1')) - profil.code = '' - profil.datePeremption = '' - profil.save() - clear_cache() - retourErreur='' - - return JsonResponse({'message':retour, "errorMessage":retourErreur, "errorFields":errorFields}, safe=False) diff --git a/Back-End/GestionMessagerie/models.py b/Back-End/GestionMessagerie/models.py index 654f6ef..a17f91f 100644 --- a/Back-End/GestionMessagerie/models.py +++ b/Back-End/GestionMessagerie/models.py @@ -2,13 +2,13 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.utils.translation import gettext_lazy as _ from django.conf import settings -from GestionLogin.models import Profil +from Auth.models import Profile class Messagerie(models.Model): id = models.AutoField(primary_key=True) objet = models.CharField(max_length=200, default="", blank=True) - emetteur = models.ForeignKey(Profil, on_delete=models.PROTECT, related_name='messages_envoyes') - destinataire = models.ForeignKey(Profil, on_delete=models.PROTECT, related_name='messages_recus') + emetteur = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_envoyes') + destinataire = models.ForeignKey(Profile, on_delete=models.PROTECT, related_name='messages_recus') corpus = models.CharField(max_length=200, default="", blank=True) def __str__(self): diff --git a/Back-End/GestionMessagerie/serializers.py b/Back-End/GestionMessagerie/serializers.py index 9dbebe3..ceea37a 100644 --- a/Back-End/GestionMessagerie/serializers.py +++ b/Back-End/GestionMessagerie/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from GestionLogin.models import Profil +from Auth.models import Profile from GestionMessagerie.models import Messagerie class MessageSerializer(serializers.ModelSerializer): diff --git a/Back-End/GestionMessagerie/urls.py b/Back-End/GestionMessagerie/urls.py index 9636936..cdbd50b 100644 --- a/Back-End/GestionMessagerie/urls.py +++ b/Back-End/GestionMessagerie/urls.py @@ -1,9 +1,9 @@ from django.urls import path, re_path -from GestionMessagerie.views import MessagerieView, MessageView +from GestionMessagerie.views import MessagerieView, MessageView, MessageSimpleView urlpatterns = [ - re_path(r'^messagerie/([0-9]+)$', MessagerieView.as_view(), name="messagerie"), - re_path(r'^message$', MessageView.as_view(), name="message"), - re_path(r'^message/([0-9]+)$', MessageView.as_view(), name="message"), + re_path(r'^messagerie/(?P[0-9]+)$', MessagerieView.as_view(), name="messagerie"), + re_path(r'^messages$', MessageView.as_view(), name="messages"), + re_path(r'^messages/(?P[0-9]+)$', MessageSimpleView.as_view(), name="messages"), ] \ No newline at end of file diff --git a/Back-End/GestionMessagerie/views.py b/Back-End/GestionMessagerie/views.py index 40c7297..9b51042 100644 --- a/Back-End/GestionMessagerie/views.py +++ b/Back-End/GestionMessagerie/views.py @@ -9,24 +9,26 @@ from GestionMessagerie.serializers import MessageSerializer from N3wtSchool import bdd class MessagerieView(APIView): - def get(self, request, _idProfile): - messagesList = bdd.getObjects(_objectName=Messagerie, _columnName='destinataire__id', _value=_idProfile) + def get(self, request, profile_id): + messagesList = bdd.getObjects(_objectName=Messagerie, _columnName='destinataire__id', _value=profile_id) messages_serializer = MessageSerializer(messagesList, many=True) - return JsonResponse(messages_serializer.data, safe=False) + return JsonResponse(messages_serializer.data, safe=False) class MessageView(APIView): - def get(self, request, _id): - message=bdd.getObject(Messagerie, "id", _id) - message_serializer=MessageSerializer(message) - return JsonResponse(message_serializer.data, safe=False) - def post(self, request): message_data=JSONParser().parse(request) message_serializer = MessageSerializer(data=message_data) if message_serializer.is_valid(): - message_serializer.save() - + message_serializer.save() + return JsonResponse('Nouveau Message ajouté', safe=False) - return JsonResponse(message_serializer.errors, safe=False) \ No newline at end of file + return JsonResponse(message_serializer.errors, safe=False) + +class MessageSimpleView(APIView): + def get(self, request, id): + message=bdd.getObject(Messagerie, "id", id) + message_serializer=MessageSerializer(message) + return JsonResponse(message_serializer.data, safe=False) + diff --git a/Back-End/GestionNotification/models.py b/Back-End/GestionNotification/models.py index 361c034..a1aa830 100644 --- a/Back-End/GestionNotification/models.py +++ b/Back-End/GestionNotification/models.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.utils.translation import gettext_lazy as _ from django.conf import settings -from GestionLogin.models import Profil +from Auth.models import Profile class TypeNotif(models.IntegerChoices): NOTIF_NONE = 0, _('Aucune notification') @@ -10,7 +10,7 @@ class TypeNotif(models.IntegerChoices): NOTIF_DI = 2, _('Le dossier d\'inscription a été mis à jour') class Notification(models.Model): - user = models.ForeignKey(Profil, on_delete=models.PROTECT) + user = models.ForeignKey(Profile, on_delete=models.PROTECT) message = models.CharField(max_length=255) is_read = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) diff --git a/Back-End/GestionNotification/signals.py b/Back-End/GestionNotification/signals.py index 1f1fc37..4e3fb0a 100644 --- a/Back-End/GestionNotification/signals.py +++ b/Back-End/GestionNotification/signals.py @@ -2,22 +2,22 @@ from django.db.models.signals import post_save from django.dispatch import receiver from .models import Notification, TypeNotif from GestionMessagerie.models import Messagerie -from GestionInscriptions.models import FicheInscription +from Subscriptions.models import RegistrationForm -@receiver(post_save, sender=Messagerie) -def notification_MESSAGE(sender, instance, created, **kwargs): - if created: - Notification.objects.create( - user=instance.destinataire, - message=(TypeNotif.NOTIF_MESSAGE).label, - typeNotification=TypeNotif.NOTIF_MESSAGE - ) +# @receiver(post_save, sender=Messagerie) +# def notification_MESSAGE(sender, instance, created, **kwargs): +# if created: +# Notification.objects.create( +# user=instance.destinataire, +# message=(TypeNotif.NOTIF_MESSAGE).label, +# typeNotification=TypeNotif.NOTIF_MESSAGE +# ) -@receiver(post_save, sender=FicheInscription) -def notification_DI(sender, instance, created, **kwargs): - for responsable in instance.eleve.responsables.all(): - Notification.objects.create( - user=responsable.profilAssocie, - message=(TypeNotif.NOTIF_DI).label, - typeNotification=TypeNotif.NOTIF_DI - ) +# @receiver(post_save, sender=RegistrationForm) +# def notification_DI(sender, instance, created, **kwargs): +# for responsable in instance.student.guardians.all(): +# Notification.objects.create( +# user=responsable.associated_profile, +# message=(TypeNotif.NOTIF_DI).label, +# typeNotification=TypeNotif.NOTIF_DI +# ) diff --git a/Back-End/GestionNotification/urls.py b/Back-End/GestionNotification/urls.py index 7619357..e966cc6 100644 --- a/Back-End/GestionNotification/urls.py +++ b/Back-End/GestionNotification/urls.py @@ -3,5 +3,5 @@ from django.urls import path, re_path from GestionNotification.views import NotificationView urlpatterns = [ - re_path(r'^notification$', NotificationView.as_view(), name="notification"), + re_path(r'^notifications$', NotificationView.as_view(), name="notifications"), ] \ No newline at end of file diff --git a/Back-End/GestionNotification/views.py b/Back-End/GestionNotification/views.py index 8ffbeb6..b43dee8 100644 --- a/Back-End/GestionNotification/views.py +++ b/Back-End/GestionNotification/views.py @@ -3,7 +3,7 @@ from rest_framework.views import APIView from .models import * -from GestionInscriptions.serializers import NotificationSerializer +from Subscriptions.serializers import NotificationSerializer from N3wtSchool import bdd diff --git a/Back-End/N3wtSchool/bdd.py b/Back-End/N3wtSchool/bdd.py index 50ffbe8..12eafff 100644 --- a/Back-End/N3wtSchool/bdd.py +++ b/Back-End/N3wtSchool/bdd.py @@ -1,6 +1,9 @@ import logging from django.db.models import Q -from GestionInscriptions.models import FicheInscription, Profil, Eleve +from django.http import JsonResponse +from django.core.exceptions import ObjectDoesNotExist +from Subscriptions.models import RegistrationForm, Student +from Auth.models import Profile logger = logging.getLogger('N3wtSchool') @@ -43,12 +46,12 @@ def getProfile(objectList, valueToCheck): return result def getEleveByCodeFI(_codeFI): - eleve = None - ficheInscriptions_List=getAllObjects(FicheInscription) - for fi in ficheInscriptions_List: - if fi.codeLienInscription == _codeFI: - eleve = fi.eleve - return eleve + student = None + ficheInscriptions_List=getAllObjects(RegistrationForm) + for rf in ficheInscriptions_List: + if rf.codeLienInscription == _codeFI: + student = rf.student + return student def getLastId(_object): result = 1 @@ -58,31 +61,47 @@ def getLastId(_object): logger.warning("Aucun résultat n'a été trouvé - ") return result -def searchObjects(_objectName, _searchTerm, _excludeState=None): +def searchObjects(_objectName, _searchTerm=None, _excludeStates=None): """ Recherche générique sur les objets avec possibilité d'exclure certains états - _objectName: Classe du modèle + _objectName: SchoolClass du modèle _searchTerm: Terme de recherche - _excludeState: État à exclure de la recherche (optionnel) + _excludeStates: Liste d'état à exclure de la recherche (optionnel) """ try: query = _objectName.objects.all() # Si on a un état à exclure - if _excludeState is not None: - query = query.filter(etat__lt=_excludeState) + if _excludeStates is not None: + query = query.exclude(status__in=_excludeStates) # Si on a un terme de recherche if _searchTerm and _searchTerm.strip(): terms = _searchTerm.lower().strip().split() for term in terms: query = query.filter( - Q(eleve__nom__icontains=term) | - Q(eleve__prenom__icontains=term) + Q(student__last_name__icontains=term) | + Q(student__first_name__icontains=term) ) - return query.order_by('eleve__nom', 'eleve__prenom') + return query.order_by('student__last_name', 'student__first_name') except _objectName.DoesNotExist: - logger.error(f"Aucun résultat n'a été trouvé - {_objectName.__name__} (recherche: {_searchTerm})") - return None \ No newline at end of file + logging.error(f"Aucun résultat n'a été trouvé - {_objectName.__name__} (recherche: {_searchTerm})") + return None + +def delete_object(model_class, object_id, related_field=None): + try: + obj = model_class.objects.get(id=object_id) + + if related_field and hasattr(obj, related_field): + related_obj = getattr(obj, related_field) + if related_obj: + related_obj.delete() + obj_name = str(obj) # Utiliser la méthode __str__ + obj.delete() + return JsonResponse({'message': f'La suppression de l\'objet {obj_name} a été effectuée avec succès'}, safe=False) + except ObjectDoesNotExist: + return JsonResponse({'error': f'L\'objet {model_class.__name__} n\'existe pas avec cet ID'}, status=404, safe=False) + except Exception as e: + return JsonResponse({'error': f'Une erreur est survenue : {str(e)}'}, status=500, safe=False) diff --git a/Back-End/N3wtSchool/middleware.py b/Back-End/N3wtSchool/middleware.py new file mode 100644 index 0000000..b923652 --- /dev/null +++ b/Back-End/N3wtSchool/middleware.py @@ -0,0 +1,8 @@ +class ContentSecurityPolicyMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response['Content-Security-Policy'] = "frame-ancestors 'self' http://localhost:3000" + return response diff --git a/Back-End/N3wtSchool/settings.py b/Back-End/N3wtSchool/settings.py index 75c2b88..ab74235 100644 --- a/Back-End/N3wtSchool/settings.py +++ b/Back-End/N3wtSchool/settings.py @@ -13,11 +13,16 @@ https://docs.djangoproject.com/en/5.0/ref/settings/ from pathlib import Path import json import os +from datetime import timedelta # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +MEDIA_URL = '/data/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'data') -LOGIN_REDIRECT_URL = '/GestionInscriptions/fichesInscriptions' +BASE_URL = os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000') + +LOGIN_REDIRECT_URL = '/Subscriptions/registerForms' # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ @@ -33,11 +38,13 @@ ALLOWED_HOSTS = ['*'] # Application definition INSTALLED_APPS = [ - 'GestionInscriptions.apps.GestioninscriptionsConfig', - 'GestionLogin.apps.GestionloginConfig', + 'Subscriptions.apps.GestioninscriptionsConfig', + 'Auth.apps.GestionloginConfig', 'GestionMessagerie.apps.GestionMessagerieConfig', 'GestionNotification.apps.GestionNotificationConfig', - 'GestionEnseignants.apps.GestionenseignantsConfig', + 'School.apps.SchoolConfig', + 'Planning.apps.PlanningConfig', + 'Establishment.apps.EstablishmentConfig', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -49,6 +56,7 @@ INSTALLED_APPS = [ 'django_celery_beat', 'N3wtSchool', 'drf_yasg', + 'rest_framework_simplejwt' ] MIDDLEWARE = [ @@ -60,6 +68,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'N3wtSchool.middleware.ContentSecurityPolicyMiddleware' ] @@ -131,12 +140,12 @@ LOGGING = { "level": os.getenv("GESTION_NOTIFICATION_LOG_LEVEL", "INFO"), "propagate": False, }, - "GestionLogin": { + "Auth": { "handlers": ["console"], "level": os.getenv("GESTION_LOGIN_LOG_LEVEL", "INFO"), "propagate": False, }, - "GestionInscriptions": { + "Subscriptions": { "handlers": ["console"], "level": os.getenv("GESTION_INSCRIPTIONS_LOG_LEVEL", "DEBUG"), "propagate": False, @@ -146,7 +155,7 @@ LOGGING = { "level": os.getenv("GESTION_MESSAGERIE_LOG_LEVEL", "INFO"), "propagate": False, }, - "GestionEnseignants": { + "School": { "handlers": ["console"], "level": os.getenv("GESTION_ENSEIGNANTS_LOG_LEVEL", "INFO"), "propagate": False, @@ -209,7 +218,7 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' #################### Application Settings ############################## ######################################################################## -with open('GestionInscriptions/Configuration/application.json', 'r') as f: +with open('Subscriptions/Configuration/application.json', 'r') as f: jsonObject = json.load(f) DJANGO_SUPERUSER_PASSWORD='admin' @@ -223,42 +232,32 @@ EMAIL_HOST_PASSWORD=jsonObject['password'] EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_USE_TLS = True EMAIL_USE_SSL = False -EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Dossier Inscription' -EMAIL_INSCRIPTION_CORPUS = """Bonjour, -Afin de procéder à l'inscription de votre petit bout, vous trouverez ci-joint le lien vers la page d'authentification : http://localhost:3000/users/login -S'il s'agit de votre première connexion, veuillez procéder à l'activation de votre compte : http://localhost:3000/users/subscribe -identifiant = %s -Cordialement, -""" - -EMAIL_RELANCE_SUBJECT = '[N3WT-SCHOOL] Relance - Dossier Inscription' -EMAIL_RELANCE_CORPUS = 'Bonjour,\nN\'ayant pas eu de retour de votre part, nous vous renvoyons le lien vers le formulaire d\'inscription : http://localhost:3000/users/login\nCordialement' -EMAIL_REINIT_SUBJECT = 'Réinitialisation du mot de passe' -EMAIL_REINIT_CORPUS = 'Bonjour,\nVous trouverez ci-joint le lien pour réinitialiser votre mot de passe : http://localhost:3000/users/password/reset?uuid=%s\nCordialement' DOCUMENT_DIR = 'documents' CORS_ORIGIN_ALLOW_ALL = True CORS_ALLOW_ALL_HEADERS = True CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_HEADERS = [ + 'content-type', + 'authorization', + 'X-Auth-Token', + 'x-csrftoken' +] CORS_ALLOWED_ORIGINS = [ - "http://localhost:3000" + os.getenv('CORS_ALLOWED_ORIGINS', 'http://localhost:3000') ] -CSRF_TRUSTED_ORIGINS = [ - "http://localhost:3000", # Front Next.js - "http://localhost:8080" # Insomnia -] +CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', 'http://localhost:3000,http://localhost:8080').split(',') CSRF_COOKIE_HTTPONLY = False CSRF_COOKIE_SECURE = False CSRF_COOKIE_NAME = 'csrftoken' - USE_TZ = True TZ_APPLI = 'Europe/Paris' @@ -273,8 +272,8 @@ DATABASES = { } } -AUTH_USER_MODEL = 'GestionLogin.Profil' -AUTHENTICATION_BACKENDS = ('GestionLogin.backends.EmailBackend', ) +AUTH_USER_MODEL = 'Auth.Profile' +AUTHENTICATION_BACKENDS = ('Auth.backends.EmailBackend', ) SILENCED_SYSTEM_CHECKS = ["auth.W004"] EXPIRATION_URL_NB_DAYS = 7 @@ -287,8 +286,11 @@ NB_RESULT_PER_PAGE = 8 NB_MAX_PAGE = 100 REST_FRAMEWORK = { - 'DEFAULT_PAGINATION_CLASS': 'GestionInscriptions.pagination.CustomPagination', - 'PAGE_SIZE': NB_RESULT_PER_PAGE + 'DEFAULT_PAGINATION_CLASS': 'Subscriptions.pagination.CustomPagination', + 'PAGE_SIZE': NB_RESULT_PER_PAGE, + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), } CELERY_BROKER_URL = 'redis://redis:6379/0' @@ -307,3 +309,25 @@ REDIS_DB = 0 REDIS_PASSWORD = None SECRET_KEY = 'QWQ8bYlCz1NpQ9G0vR5kxMnvWszfH2y3' +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': False, + 'BLACKLIST_AFTER_ROTATION': True, + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUTH_HEADER_TYPES': ('Bearer',), + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', +} + +# Configuration for DocuSeal JWT +DOCUSEAL_JWT = { + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'EXPIRATION_DELTA': timedelta(hours=1), + 'API_KEY': os.getenv('DOCUSEAL_API_KEY') +} \ No newline at end of file diff --git a/Back-End/N3wtSchool/signals.py b/Back-End/N3wtSchool/signals.py index c44aabc..a76f431 100644 --- a/Back-End/N3wtSchool/signals.py +++ b/Back-End/N3wtSchool/signals.py @@ -16,6 +16,6 @@ def setup_periodic_tasks(sender, **kwargs): PeriodicTask.objects.get_or_create( interval=schedule, # Utiliser l'intervalle défini ci-dessus name='Tâche périodique toutes les 5 secondes', - task='GestionInscriptions.tasks.check_for_signature_deadlines', # Remplacer par le nom de ta tâche + task='Subscriptions.tasks.check_for_signature_deadlines', # Remplacer par le nom de ta tâche kwargs=json.dumps({}) # Si nécessaire, ajoute ) \ No newline at end of file diff --git a/Back-End/N3wtSchool/urls.py b/Back-End/N3wtSchool/urls.py index 639494f..048a202 100644 --- a/Back-End/N3wtSchool/urls.py +++ b/Back-End/N3wtSchool/urls.py @@ -7,7 +7,7 @@ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views +SchoolClass-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf @@ -16,6 +16,8 @@ Including another URLconf """ from django.contrib import admin from django.urls import include, path, re_path +from django.conf import settings +from django.conf.urls.static import static from rest_framework import permissions from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -37,13 +39,19 @@ schema_view = get_schema_view( urlpatterns = [ path('admin/', admin.site.urls), - path("GestionInscriptions/", include(("GestionInscriptions.urls", 'GestionInscriptions'), namespace='GestionInscriptions')), - path("GestionLogin/", include(("GestionLogin.urls", 'GestionLogin'), namespace='GestionLogin')), + path("Subscriptions/", include(("Subscriptions.urls", 'Subscriptions'), namespace='Subscriptions')), + path("Auth/", include(("Auth.urls", 'Auth'), namespace='Auth')), path("GestionMessagerie/", include(("GestionMessagerie.urls", 'GestionMessagerie'), namespace='GestionMessagerie')), path("GestionNotification/", include(("GestionNotification.urls", 'GestionNotification'), namespace='GestionNotification')), - path("GestionEnseignants/", include(("GestionEnseignants.urls", 'GestionEnseignants'), namespace='GestionEnseignants')), + path("School/", include(("School.urls", 'School'), namespace='School')), + path("DocuSeal/", include(("DocuSeal.urls", 'DocuSeal'), namespace='DocuSeal')), + path("Planning/", include(("Planning.urls", 'Planning'), namespace='Planning')), + path("Establishment/", include(("Establishment.urls", 'Establishment'), namespace='Establishment')), # Documentation Api re_path(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/Back-End/Planning/__init__.py b/Back-End/Planning/__init__.py new file mode 100644 index 0000000..d3fd592 --- /dev/null +++ b/Back-End/Planning/__init__.py @@ -0,0 +1 @@ +default_app_config = 'Planning.apps.PlanningConfig' diff --git a/Back-End/Planning/admin.py b/Back-End/Planning/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/Back-End/Planning/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/Back-End/Planning/apps.py b/Back-End/Planning/apps.py new file mode 100644 index 0000000..43ec794 --- /dev/null +++ b/Back-End/Planning/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + +class PlanningConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'Planning' + + diff --git a/Back-End/Planning/models.py b/Back-End/Planning/models.py new file mode 100644 index 0000000..b36882c --- /dev/null +++ b/Back-End/Planning/models.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.conf import settings + +from Establishment.models import Establishment + +class RecursionType(models.IntegerChoices): + RECURSION_NONE = 0, _('Aucune') + RECURSION_DAILY = 1, _('Quotidienne') + RECURSION_WEEKLY = 2, _('Hebdomadaire') + RECURSION_MONTHLY = 3, _('Mensuel') + RECURSION_CUSTOM = 4, _('Personnalisé') + +class Planning(models.Model): + establishment = models.ForeignKey(Establishment, on_delete=models.PROTECT) + name = models.CharField(max_length=255) + description = models.TextField(default="", blank=True, null=True) + color= models.CharField(max_length=255, default="#000000") + + def __str__(self): + return f'Planning for {self.user.username}' + + +class Events(models.Model): + planning = models.ForeignKey(Planning, on_delete=models.PROTECT) + title = models.CharField(max_length=255) + description = models.TextField() + start = models.DateTimeField() + end = models.DateTimeField() + recursionType = models.IntegerField(choices=RecursionType, default=0) + color= models.CharField(max_length=255) + location = models.CharField(max_length=255, default="", blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + + def __str__(self): + return f'Event for {self.user.username}' \ No newline at end of file diff --git a/Back-End/Planning/serializers.py b/Back-End/Planning/serializers.py new file mode 100644 index 0000000..b3467f1 --- /dev/null +++ b/Back-End/Planning/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from .models import Planning, Events + + +class PlanningSerializer(serializers.ModelSerializer): + class Meta: + model = Planning + fields = '__all__' +class EventsSerializer(serializers.ModelSerializer): + class Meta: + model = Events + fields = '__all__' \ No newline at end of file diff --git a/Back-End/Planning/urls.py b/Back-End/Planning/urls.py new file mode 100644 index 0000000..a2ef0c2 --- /dev/null +++ b/Back-End/Planning/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, re_path + +from Planning.views import PlanningView,PlanningWithIdView,EventsView,EventsWithIdView,UpcomingEventsView + +urlpatterns = [ + re_path(r'^plannings$', PlanningView.as_view(), name="planning"), + re_path(r'^plannings/(?P[0-9]+)$', PlanningWithIdView.as_view(), name="planning"), + re_path(r'^events$', EventsView.as_view(), name="events"), + re_path(r'^events/(?P[0-9]+)$', EventsWithIdView.as_view(), name="events"), + re_path(r'^events/upcoming', UpcomingEventsView.as_view(), name="events"), +] \ No newline at end of file diff --git a/Back-End/Planning/views.py b/Back-End/Planning/views.py new file mode 100644 index 0000000..e3fb852 --- /dev/null +++ b/Back-End/Planning/views.py @@ -0,0 +1,97 @@ +from django.http.response import JsonResponse +from rest_framework.views import APIView +from django.utils import timezone + +from .models import Planning, Events + +from .serializers import PlanningSerializer, EventsSerializer + +from N3wtSchool import bdd + +class PlanningView(APIView): + def get(self, request): + plannings=bdd.getAllObjects(Planning) + planning_serializer=PlanningSerializer(plannings, many=True) + + return JsonResponse(planning_serializer.data, safe=False) + + def post(self, request): + planning_serializer = PlanningSerializer(data=request.data) + if planning_serializer.is_valid(): + planning_serializer.save() + return JsonResponse(planning_serializer.data, status=201) + return JsonResponse(planning_serializer.errors, status=400) + + + +class PlanningWithIdView(APIView): + def get(self, request,id): + planning = Planning.objects.get(pk=id) + if planning is None: + return JsonResponse({"errorMessage":'Le dossier d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + planning_serializer=PlanningSerializer(planning) + + return JsonResponse(planning_serializer.data, safe=False) + + def put(self, request, id): + try: + planning = Planning.objects.get(pk=id) + except Planning.DoesNotExist: + return JsonResponse({'error': 'Planning not found'}, status=404) + + planning_serializer = PlanningSerializer(planning, data=request.data) + if planning_serializer.is_valid(): + planning_serializer.save() + return JsonResponse(planning_serializer.data) + return JsonResponse(planning_serializer.errors, status=400) + + def delete(self, request, id): + try: + planning = Planning.objects.get(pk=id) + except Planning.DoesNotExist: + return JsonResponse({'error': 'Planning not found'}, status=404) + + planning.delete() + return JsonResponse({'message': 'Planning deleted'}, status=204) + +class EventsView(APIView): + def get(self, request): + events = bdd.getAllObjects(Events) + events_serializer = EventsSerializer(events, many=True) + return JsonResponse(events_serializer.data, safe=False) + + def post(self, request): + events_serializer = EventsSerializer(data=request.data) + if events_serializer.is_valid(): + events_serializer.save() + return JsonResponse(events_serializer.data, status=201) + return JsonResponse(events_serializer.errors, status=400) + +class EventsWithIdView(APIView): + def put(self, request, id): + try: + event = Events.objects.get(pk=id) + except Events.DoesNotExist: + return JsonResponse({'error': 'Event not found'}, status=404) + + events_serializer = EventsSerializer(event, data=request.data) + if events_serializer.is_valid(): + events_serializer.save() + return JsonResponse(events_serializer.data) + return JsonResponse(events_serializer.errors, status=400) + + def delete(self, request, id): + try: + event = Events.objects.get(pk=id) + except Events.DoesNotExist: + return JsonResponse({'error': 'Event not found'}, status=404) + + event.delete() + return JsonResponse({'message': 'Event deleted'}, status=204) + +class UpcomingEventsView(APIView): + def get(self, request): + current_date = timezone.now() + upcoming_events = Events.objects.filter(start__gte=current_date) + events_serializer = EventsSerializer(upcoming_events, many=True) + return JsonResponse(events_serializer.data, safe=False) \ No newline at end of file diff --git a/Back-End/School/__init__.py b/Back-End/School/__init__.py new file mode 100644 index 0000000..c646cf8 --- /dev/null +++ b/Back-End/School/__init__.py @@ -0,0 +1 @@ +default_app_config = 'School.apps.SchoolConfig' diff --git a/Back-End/School/admin.py b/Back-End/School/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/Back-End/School/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/Back-End/School/apps.py b/Back-End/School/apps.py new file mode 100644 index 0000000..fe33f19 --- /dev/null +++ b/Back-End/School/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class SchoolConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'School' diff --git a/Back-End/School/management/commands/References/CapSchool/Autorisations Parentales 2024-2025.pdf b/Back-End/School/management/commands/References/CapSchool/Autorisations Parentales 2024-2025.pdf new file mode 100644 index 0000000..5c940c7 Binary files /dev/null and b/Back-End/School/management/commands/References/CapSchool/Autorisations Parentales 2024-2025.pdf differ diff --git a/Back-End/School/management/commands/References/CapSchool/Dossier Inscription 24-25.pdf b/Back-End/School/management/commands/References/CapSchool/Dossier Inscription 24-25.pdf new file mode 100644 index 0000000..abdc77e Binary files /dev/null and b/Back-End/School/management/commands/References/CapSchool/Dossier Inscription 24-25.pdf differ diff --git a/Back-End/School/management/commands/References/CapSchool/Règlement Cap School 24-25.pdf b/Back-End/School/management/commands/References/CapSchool/Règlement Cap School 24-25.pdf new file mode 100644 index 0000000..8cab532 Binary files /dev/null and b/Back-End/School/management/commands/References/CapSchool/Règlement Cap School 24-25.pdf differ diff --git a/Back-End/School/management/commands/References/LMDE/Bulletin d'adhésion familiale scolaire.pdf b/Back-End/School/management/commands/References/LMDE/Bulletin d'adhésion familiale scolaire.pdf new file mode 100644 index 0000000..a3b0caa Binary files /dev/null and b/Back-End/School/management/commands/References/LMDE/Bulletin d'adhésion familiale scolaire.pdf differ diff --git a/Back-End/School/management/commands/References/LMDE/Contrat d'engagement 2024 2025.pdf b/Back-End/School/management/commands/References/LMDE/Contrat d'engagement 2024 2025.pdf new file mode 100644 index 0000000..a967e18 Binary files /dev/null and b/Back-End/School/management/commands/References/LMDE/Contrat d'engagement 2024 2025.pdf differ diff --git a/Back-End/School/management/commands/References/LMDE/Fiche d'inscription 2024 2025.pdf b/Back-End/School/management/commands/References/LMDE/Fiche d'inscription 2024 2025.pdf new file mode 100644 index 0000000..54c2ed0 Binary files /dev/null and b/Back-End/School/management/commands/References/LMDE/Fiche d'inscription 2024 2025.pdf differ diff --git a/Back-End/School/management/commands/References/LMDE/Fiche sanitaire de liaison.pdf b/Back-End/School/management/commands/References/LMDE/Fiche sanitaire de liaison.pdf new file mode 100644 index 0000000..d4de1d7 Binary files /dev/null and b/Back-End/School/management/commands/References/LMDE/Fiche sanitaire de liaison.pdf differ diff --git a/Back-End/School/management/commands/References/LMDE/RIB LA MAISON DES ENFANTS.pdf b/Back-End/School/management/commands/References/LMDE/RIB LA MAISON DES ENFANTS.pdf new file mode 100644 index 0000000..528c95b Binary files /dev/null and b/Back-End/School/management/commands/References/LMDE/RIB LA MAISON DES ENFANTS.pdf differ diff --git a/Back-End/School/management/commands/init_mock_datas.py b/Back-End/School/management/commands/init_mock_datas.py new file mode 100644 index 0000000..b570cc2 --- /dev/null +++ b/Back-End/School/management/commands/init_mock_datas.py @@ -0,0 +1,427 @@ +from django.core.management.base import BaseCommand +from Subscriptions.models import ( + RegistrationForm, + Student, + Guardian, + Fee, + Discount, + RegistrationFileGroup, + RegistrationTemplateMaster, + RegistrationTemplate +) +from Auth.models import Profile, ProfileRole +from School.models import ( + FeeType, + Speciality, + Teacher, + SchoolClass, + PaymentMode, + PaymentModeType, + PaymentPlan, + PaymentPlanType, + DiscountType +) +from django.utils import timezone +from dateutil.relativedelta import relativedelta +from django.core.files import File +from django.core.exceptions import SuspiciousFileOperation +import os +from django.conf import settings +from faker import Faker +import random +import json + +from School.serializers import ( + FeeSerializer, + DiscountSerializer, + PaymentModeSerializer, + PaymentPlanSerializer, + SpecialitySerializer, + TeacherSerializer, + SchoolClassSerializer +) +from Auth.serializers import ProfileSerializer, ProfileRoleSerializer +from Establishment.serializers import EstablishmentSerializer +from Subscriptions.serializers import RegistrationFormSerializer, StudentSerializer + +# Définir le chemin vers le dossier mock_datas +MOCK_DATAS_PATH = os.path.join(settings.BASE_DIR, 'School', 'management', 'mock_datas') + +class Command(BaseCommand): + help = 'Initialise toutes les données mock' + + def handle(self, *args, **kwargs): + self.init_establishments() + self.init_profiles() + self.init_fees() + self.init_discounts() + self.init_payment_modes() + self.init_payment_plans() + self.init_specialities() + self.init_teachers() + self.init_school_classes() + self.init_file_group() + self.init_register_form() + + def load_data(self, filename): + with open(os.path.join(MOCK_DATAS_PATH, filename), 'r') as file: + return json.load(file) + + def init_establishments(self): + establishments_data = self.load_data('establishments.json') + + self.establishments = [] + for establishment_data in establishments_data: + serializer = EstablishmentSerializer(data=establishment_data) + if serializer.is_valid(): + establishment = serializer.save() + self.establishments.append(establishment) + self.stdout.write(self.style.SUCCESS(f'Establishment {establishment.name} created or updated successfully')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for establishment: {serializer.errors}')) + + def init_profiles(self): + fake = Faker() + + for _ in range(50): + # Générer des données fictives pour le profil + profile_data = { + "username": fake.user_name(), + "email": fake.email(), + "password": "Provisoire01!", + "code": "", + "datePeremption": "" + } + + # Créer le profil + profile_serializer = ProfileSerializer(data=profile_data) + if profile_serializer.is_valid(): + profile = profile_serializer.save() + profile.set_password(profile_data["password"]) + profile.save() + self.stdout.write(self.style.SUCCESS(f'Profile {profile.email} created successfully')) + + # Créer entre 1 et 3 ProfileRole pour chaque profil + num_roles = random.randint(1, 3) + created_roles = set() + for _ in range(num_roles): + establishment = random.choice(self.establishments) + role_type = random.choice([ProfileRole.RoleType.PROFIL_ECOLE, ProfileRole.RoleType.PROFIL_ADMIN, ProfileRole.RoleType.PROFIL_PARENT]) + + # Vérifier si le rôle existe déjà pour cet établissement + if (establishment.id, role_type) in created_roles: + continue + + profile_role_data = { + "profile": profile.id, + "establishment": establishment.id, + "role_type": role_type, + "is_active": random.choice([True, False]) + } + + profile_role_serializer = ProfileRoleSerializer(data=profile_role_data) + if profile_role_serializer.is_valid(): + profile_role_serializer.save() + created_roles.add((establishment.id, role_type)) + self.stdout.write(self.style.SUCCESS(f'ProfileRole for {profile.email} created successfully with role type {role_type}')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for profile role: {profile_role_serializer.errors}')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for profile: {profile_serializer.errors}')) + + def init_fees(self): + fees_data = self.load_data('fees.json') + + for fee_data in fees_data: + establishment = random.choice(self.establishments) + print(f'establishment : {establishment}') + fee_data["name"] = fee_data['name'] + fee_data["establishment"] = establishment.id + fee_data["type"] = random.choice([FeeType.REGISTRATION_FEE, FeeType.TUITION_FEE]) + + serializer = FeeSerializer(data=fee_data) + if serializer.is_valid(): + fee = serializer.save() + self.stdout.write(self.style.SUCCESS(f'Fee {fee.name} created successfully')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for fee: {serializer.errors}')) + + def init_discounts(self): + discounts_data = self.load_data('discounts.json') + + for discount_data in discounts_data: + establishment = random.choice(self.establishments) + discount_data["name"] = discount_data['name'] + discount_data["establishment"] = establishment.id + discount_data["type"] = random.choice([FeeType.REGISTRATION_FEE, FeeType.TUITION_FEE]) + discount_data["discount_type"] = random.choice([DiscountType.CURRENCY, DiscountType.PERCENT]) + + serializer = DiscountSerializer(data=discount_data) + if serializer.is_valid(): + discount = serializer.save() + self.stdout.write(self.style.SUCCESS(f'Discount {discount.name} created successfully')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for discount: {serializer.errors}')) + + def init_payment_modes(self): + modes = [PaymentModeType.SEPA, PaymentModeType.TRANSFER, PaymentModeType.CHECK, PaymentModeType.CASH] + types = [FeeType.REGISTRATION_FEE, FeeType.TUITION_FEE] + + for establishment in self.establishments: + for mode in modes: + for type in types: + payment_mode_data = { + "mode": mode, + "type": type, + "is_active": random.choice([True, False]), + "establishment": establishment.id + } + + serializer = PaymentModeSerializer(data=payment_mode_data) + if serializer.is_valid(): + payment_mode = serializer.save() + self.stdout.write(self.style.SUCCESS(f'Payment Mode {payment_mode} created successfully')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for payment mode: {serializer.errors}')) + + def init_payment_plans(self): + frequencies = [PaymentPlanType.ONE_TIME, PaymentPlanType.THREE_TIMES, PaymentPlanType.TEN_TIMES, PaymentPlanType.TWELVE_TIMES] + types = [FeeType.REGISTRATION_FEE, FeeType.TUITION_FEE] + current_date = timezone.now().date() + + for establishment in self.establishments: + for frequency in frequencies: + for type in types: + payment_plan_data = { + "frequency": frequency, + "type": type, + "is_active": random.choice([True, False]), + "establishment": establishment.id, + "due_dates": self.generate_due_dates(frequency, current_date) + } + + serializer = PaymentPlanSerializer(data=payment_plan_data) + if serializer.is_valid(): + payment_plan = serializer.save() + self.stdout.write(self.style.SUCCESS(f'Payment Plan {payment_plan} created successfully')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for payment plan: {serializer.errors}')) + + def generate_due_dates(self, frequency, start_date): + if frequency == PaymentPlanType.ONE_TIME: + return [start_date + relativedelta(months=1)] + elif frequency == PaymentPlanType.THREE_TIMES: + return [start_date + relativedelta(months=1+4*i) for i in range(3)] + elif frequency == PaymentPlanType.TEN_TIMES: + return [start_date + relativedelta(months=1+i) for i in range(10)] + elif frequency == PaymentPlanType.TWELVE_TIMES: + return [start_date + relativedelta(months=1+i) for i in range(12)] + + def init_specialities(self): + specialities_data = self.load_data('specialities.json') + + for speciality_data in specialities_data: + establishment = random.choice(self.establishments) + speciality_data["name"] = speciality_data['name'] + speciality_data["establishment"] = establishment.id + + serializer = SpecialitySerializer(data=speciality_data) + if serializer.is_valid(): + speciality = serializer.save() + self.stdout.write(self.style.SUCCESS(f'Speciality {speciality.name} created successfully')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for speciality: {serializer.errors}')) + + def init_teachers(self): + fake = Faker() + + # Récupérer tous les profils existants avec un rôle ECOLE ou ADMIN + profiles_with_roles = Profile.objects.filter(roles__role_type__in=[ProfileRole.RoleType.PROFIL_ECOLE, ProfileRole.RoleType.PROFIL_ADMIN]).distinct() + + if not profiles_with_roles.exists(): + self.stdout.write(self.style.ERROR('No profiles with role_type ECOLE or ADMIN found')) + return + + used_profiles = set() + + for _ in range(15): + # Récupérer un profil aléatoire qui n'a pas encore été utilisé + available_profiles = profiles_with_roles.exclude(id__in=used_profiles) + if not available_profiles.exists(): + self.stdout.write(self.style.ERROR('Not enough profiles with role_type ECOLE or ADMIN available')) + break + + profile = random.choice(available_profiles) + used_profiles.add(profile.id) + + # Récupérer les ProfileRole associés au profil avec les rôles ECOLE ou ADMIN + profile_roles = ProfileRole.objects.filter(profile=profile, role_type__in=[ProfileRole.RoleType.PROFIL_ECOLE, ProfileRole.RoleType.PROFIL_ADMIN]) + + if not profile_roles.exists(): + self.stdout.write(self.style.ERROR(f'No ProfileRole with role_type ECOLE or ADMIN found for profile {profile.email}')) + continue + + profile_role = random.choice(profile_roles) + + # Générer des données fictives pour l'enseignant + teacher_data = { + "last_name": fake.last_name(), + "first_name": fake.first_name(), + "profile_role": profile_role.id + } + + establishment_specialities = list(Speciality.objects.filter(establishment=profile_role.establishment)) + num_specialities = min(random.randint(1, 3), len(establishment_specialities)) + selected_specialities = random.sample(establishment_specialities, num_specialities) + + # Créer l'enseignant si il n'existe pas + teacher_serializer = TeacherSerializer(data=teacher_data) + if teacher_serializer.is_valid(): + teacher = teacher_serializer.save() + # Associer les spécialités + teacher.specialities.set(selected_specialities) + teacher.save() + self.stdout.write(self.style.SUCCESS(f'Teacher {teacher.last_name} created successfully for establishment {profile_role.establishment.name}')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for teacher: {teacher_serializer.errors}')) + + self.stdout.write(self.style.SUCCESS('Teachers initialized or updated successfully')) + + def init_school_classes(self): + school_classes_data = self.load_data('school_classes.json') + + for index, class_data in enumerate(school_classes_data, start=1): + # Randomize establishment + establishment = random.choice(self.establishments) + class_data["atmosphere_name"] = f"Classe {index}" + class_data["establishment"] = establishment.id + + # Randomize levels + class_data["levels"] = random.sample(range(1, 10), random.randint(1, 5)) + + # Randomize teachers + establishment_teachers = list(Teacher.objects.filter(profile_role__establishment=establishment)) + if len(establishment_teachers) > 0: + num_teachers = min(2, len(establishment_teachers)) + selected_teachers = random.sample(establishment_teachers, num_teachers) + teachers_ids = [teacher.id for teacher in selected_teachers] + else: + teachers_ids = [] + + # Use the serializer to create or update the school class + class_data["teachers"] = teachers_ids + serializer = SchoolClassSerializer(data=class_data) + if serializer.is_valid(): + school_class = serializer.save() + self.stdout.write(self.style.SUCCESS(f'SchoolClass {school_class.atmosphere_name} created or updated successfully')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for school class: {serializer.errors}')) + + self.stdout.write(self.style.SUCCESS('SchoolClasses initialized or updated successfully')) + + def init_file_group(self): + fake = Faker() + + for establishment in self.establishments: + for i in range(1, 4): # Créer 3 groupes de fichiers par établissement + name = f"Fichiers d'inscription - {fake.word()}" + description = fake.sentence() + group_data = { + "name": name, + "description": description, + "establishment": establishment + } + RegistrationFileGroup.objects.update_or_create(name=name, defaults=group_data) + self.stdout.write(self.style.SUCCESS(f'RegistrationFileGroup {name} initialized or updated successfully')) + + self.stdout.write(self.style.SUCCESS('All RegistrationFileGroups initialized or updated successfully')) + + def init_register_form(self): + fake = Faker('fr_FR') # Utiliser le locale français pour Faker + + file_group_count = RegistrationFileGroup.objects.count() + + # Récupérer tous les profils existants avec un ProfileRole Parent + profiles_with_parent_role = Profile.objects.filter(roles__role_type=ProfileRole.RoleType.PROFIL_PARENT).distinct() + + if not profiles_with_parent_role.exists(): + self.stdout.write(self.style.ERROR('No profiles with ProfileRole Parent found')) + return + + used_profiles = set() + + for _ in range(50): + # Récupérer un profil aléatoire qui n'a pas encore été utilisé + available_profiles = profiles_with_parent_role.exclude(id__in=used_profiles) + if not available_profiles.exists(): + self.stdout.write(self.style.ERROR('Not enough profiles with ProfileRole Parent available')) + break + + profile = random.choice(available_profiles) + used_profiles.add(profile.id) + + # Récupérer le ProfileRole Parent associé au profil + profile_roles = ProfileRole.objects.filter(profile=profile, role_type=ProfileRole.RoleType.PROFIL_PARENT) + profile_role = random.choice(profile_roles) + + # Générer des données fictives pour le guardian + guardian_data = { + "profile_role": profile_role.id, + "last_name": fake.last_name(), + "first_name": fake.first_name(), + "birth_date": fake.date_of_birth().strftime('%Y-%m-%d'), + "address": fake.address(), + "phone": fake.phone_number(), + "profession": fake.job() + } + + # Générer des données fictives pour l'étudiant + student_data = { + "last_name": fake.last_name(), + "first_name": fake.first_name(), + "address": fake.address(), + "birth_date": fake.date_of_birth(), + "birth_place": fake.city(), + "birth_postal_code": fake.postcode(), + "nationality": fake.country(), + "attending_physician": fake.name(), + "level": fake.random_int(min=1, max=6), + "guardians": [guardian_data], + "sibling": [] + } + + # Créer ou mettre à jour l'étudiant + student_serializer = StudentSerializer(data=student_data) + if student_serializer.is_valid(): + student = student_serializer.save() + self.stdout.write(self.style.SUCCESS(f'Student {student.last_name} created successfully')) + else: + self.stdout.write(self.style.ERROR(f'Error in data for student: {student_serializer.errors}')) + continue + + # Récupérer les frais et les réductions + fees = Fee.objects.filter(id__in=[1, 2, 3, 4]) + discounts = Discount.objects.filter(id__in=[1]) + + # Créer les données du formulaire d'inscription + register_form_data = { + "fileGroup": RegistrationFileGroup.objects.get(id=fake.random_int(min=1, max=file_group_count)), + "establishment": profile_role.establishment, + "status": fake.random_int(min=1, max=3) + } + + # Créer ou mettre à jour le formulaire d'inscription + register_form, created = RegistrationForm.objects.get_or_create( + student=student, + establishment=profile_role.establishment, + defaults=register_form_data + ) + + if created: + register_form.fees.set(fees) + register_form.discounts.set(discounts) + self.stdout.write(self.style.SUCCESS(f'RegistrationForm for student {student.last_name} created successfully')) + else: + self.stdout.write(self.style.SUCCESS(f'RegistrationForm for student {student.last_name} already exists')) + + self.stdout.write(self.style.SUCCESS('50 RegistrationForms initialized or updated successfully')) \ No newline at end of file diff --git a/Back-End/School/management/mock_datas/discounts.json b/Back-End/School/management/mock_datas/discounts.json new file mode 100644 index 0000000..f498b92 --- /dev/null +++ b/Back-End/School/management/mock_datas/discounts.json @@ -0,0 +1,42 @@ +[ + { + "name": "Parrainage", + "amount": "10.00", + "description": "Réduction pour parrainage" + }, + { + "name": "Réinscription", + "amount": "100.00", + "description": "Réduction pour Réinscription" + }, + { + "name": "Famille nombreuse", + "amount": "50.00", + "description": "Réduction pour les familles nombreuses" + }, + { + "name": "Excellence académique", + "amount": "200.00", + "description": "Réduction pour les élèves ayant des résultats académiques exceptionnels" + }, + { + "name": "Sportif de haut niveau", + "amount": "150.00", + "description": "Réduction pour les élèves pratiquant un sport de haut niveau" + }, + { + "name": "Artiste talentueux", + "amount": "100.00", + "description": "Réduction pour les élèves ayant des talents artistiques" + }, + { + "name": "Bourse d'études", + "amount": "300.00", + "description": "Réduction pour les élèves bénéficiant d'une bourse d'études" + }, + { + "name": "Réduction spéciale", + "amount": "75.00", + "description": "Réduction spéciale pour des occasions particulières" + } +] \ No newline at end of file diff --git a/Back-End/School/management/mock_datas/establishments.json b/Back-End/School/management/mock_datas/establishments.json new file mode 100644 index 0000000..99a8db5 --- /dev/null +++ b/Back-End/School/management/mock_datas/establishments.json @@ -0,0 +1,23 @@ +[ + { + "name": "Ecole A", + "address": "Adresse de l'Ecole A", + "total_capacity": 69, + "establishment_type": [1, 2], + "licence_code": "" + }, + { + "name": "Ecole B", + "address": "Adresse de l'Ecole B", + "total_capacity": 100, + "establishment_type": [2, 3], + "licence_code": "" + }, + { + "name": "Ecole C", + "address": "Adresse de l'Ecole C", + "total_capacity": 50, + "establishment_type": [1], + "licence_code": "" + } +] \ No newline at end of file diff --git a/Back-End/School/management/mock_datas/fees.json b/Back-End/School/management/mock_datas/fees.json new file mode 100644 index 0000000..043afe2 --- /dev/null +++ b/Back-End/School/management/mock_datas/fees.json @@ -0,0 +1,32 @@ +[ + { + "name": "Frais d'inscription", + "base_amount": "150.00", + "description": "Montant de base", + "is_active": true + }, + { + "name": "Matériel", + "base_amount": "85.00", + "description": "Livres / jouets", + "is_active": true + }, + { + "name": "Sorties périscolaires", + "base_amount": "120.00", + "description": "Sorties", + "is_active": true + }, + { + "name": "Les colibris", + "base_amount": "4500.00", + "description": "TPS / PS / MS / GS", + "is_active": true + }, + { + "name": "Les butterflies", + "base_amount": "5000.00", + "description": "CP / CE1 / CE2 / CM1 / CM2", + "is_active": true + } +] \ No newline at end of file diff --git a/Back-End/School/management/mock_datas/school_classes.json b/Back-End/School/management/mock_datas/school_classes.json new file mode 100644 index 0000000..fe8b388 --- /dev/null +++ b/Back-End/School/management/mock_datas/school_classes.json @@ -0,0 +1,52 @@ +[ + { + "age_range": "3-6", + "number_of_students": 14, + "teaching_language": "", + "school_year": "2024-2025", + "levels": [2, 3, 4], + "type": 1, + "time_range": ["08:30", "17:30"], + "opening_days": [1, 2, 4, 5] + }, + { + "age_range": "2-3", + "number_of_students": 5, + "teaching_language": "", + "school_year": "2024-2025", + "levels": [1], + "type": 1, + "time_range": ["08:30", "17:30"], + "opening_days": [1, 2, 4, 5] + }, + { + "age_range": "6-12", + "number_of_students": 21, + "teaching_language": "", + "school_year": "2024-2025", + "levels": [5, 6, 7, 8, 9], + "type": 1, + "time_range": ["08:30", "17:30"], + "opening_days": [1, 2, 4, 5] + }, + { + "age_range": "4-6", + "number_of_students": 18, + "teaching_language": "", + "school_year": "2024-2025", + "levels": [4, 5], + "type": 1, + "time_range": ["08:30", "17:30"], + "opening_days": [1, 2, 4, 5] + }, + { + "age_range": "7-9", + "number_of_students": 20, + "teaching_language": "", + "school_year": "2024-2025", + "levels": [6, 7], + "type": 1, + "time_range": ["08:30", "17:30"], + "opening_days": [1, 2, 4, 5] + } +] \ No newline at end of file diff --git a/Back-End/School/management/mock_datas/specialities.json b/Back-End/School/management/mock_datas/specialities.json new file mode 100644 index 0000000..1806e7c --- /dev/null +++ b/Back-End/School/management/mock_datas/specialities.json @@ -0,0 +1,42 @@ +[ + { + "name": "GROUPE", + "color_code": "#FF0000" + }, + { + "name": "MATHS", + "color_code": "#0a98f0" + }, + { + "name": "ANGLAIS", + "color_code": "#f708d7" + }, + { + "name": "FRANCAIS", + "color_code": "#04f108" + }, + { + "name": "HISTOIRE", + "color_code": "#ffb005" + }, + { + "name": "SPORT", + "color_code": "#bbb9b9" + }, + { + "name": "SCIENCES", + "color_code": "#00FF00" + }, + { + "name": "MUSIQUE", + "color_code": "#0000FF" + }, + { + "name": "ART", + "color_code": "#FF00FF" + }, + { + "name": "INFORMATIQUE", + "color_code": "#00FFFF" + } +] \ No newline at end of file diff --git a/Back-End/School/models.py b/Back-End/School/models.py new file mode 100644 index 0000000..71d6078 --- /dev/null +++ b/Back-End/School/models.py @@ -0,0 +1,134 @@ +from django.db import models +from Auth.models import ProfileRole +from Establishment.models import Establishment +from django.db.models import JSONField +from django.dispatch import receiver +from django.contrib.postgres.fields import ArrayField +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError + +LEVEL_CHOICES = [ + (1, 'Très Petite Section (TPS)'), + (2, 'Petite Section (PS)'), + (3, 'Moyenne Section (MS)'), + (4, 'Grande Section (GS)'), + (5, 'Cours Préparatoire (CP)'), + (6, 'Cours Élémentaire 1 (CE1)'), + (7, 'Cours Élémentaire 2 (CE2)'), + (8, 'Cours Moyen 1 (CM1)'), + (9, 'Cours Moyen 2 (CM2)') +] + +class Speciality(models.Model): + name = models.CharField(max_length=100) + updated_date = models.DateTimeField(auto_now=True) + color_code = models.CharField(max_length=7, default='#FFFFFF') + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='specialities') + + def __str__(self): + return self.name + +class Teacher(models.Model): + last_name = models.CharField(max_length=100) + first_name = models.CharField(max_length=100) + specialities = models.ManyToManyField(Speciality, blank=True) + profile_role = models.OneToOneField(ProfileRole, on_delete=models.CASCADE, related_name='teacher_profile', null=True, blank=True) + updated_date = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.last_name} {self.first_name}" + +class SchoolClass(models.Model): + PLANNING_TYPE_CHOICES = [ + (1, 'Annuel'), + (2, 'Semestriel'), + (3, 'Trimestriel') + ] + + atmosphere_name = models.CharField(max_length=255, null=True, blank=True) + age_range = models.JSONField(blank=True) + number_of_students = models.PositiveIntegerField(blank=True) + teaching_language = models.CharField(max_length=255, blank=True) + school_year = models.CharField(max_length=9, blank=True) + updated_date = models.DateTimeField(auto_now=True) + teachers = models.ManyToManyField(Teacher, blank=True) + levels = ArrayField(models.IntegerField(choices=LEVEL_CHOICES), default=list) + type = models.IntegerField(choices=PLANNING_TYPE_CHOICES, default=1) + time_range = models.JSONField(default=list) + opening_days = ArrayField(models.IntegerField(), default=list) + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='school_classes') + + def __str__(self): + return self.atmosphere_name + +class Planning(models.Model): + level = models.IntegerField(choices=LEVEL_CHOICES, null=True, blank=True) + school_class = models.ForeignKey(SchoolClass, null=True, blank=True, related_name='plannings', on_delete=models.CASCADE) + schedule = JSONField(default=dict) + + def __str__(self): + return f'Planning for {self.level} of {self.school_class.atmosphere_name}' + +class PaymentPlanType(models.IntegerChoices): + ONE_TIME = 1, '1 fois' + THREE_TIMES = 3, '3 fois' + TEN_TIMES = 10, '10 fois' + TWELVE_TIMES = 12, '12 fois' + +class DiscountType(models.IntegerChoices): + CURRENCY = 0, 'Currency' + PERCENT = 1, 'Percent' + +class FeeType(models.IntegerChoices): + REGISTRATION_FEE = 0, 'Registration Fee' + TUITION_FEE = 1, 'Tuition Fee' + +class PaymentModeType(models.IntegerChoices): + SEPA = 1, 'Prélèvement SEPA' + TRANSFER = 2, 'Virement' + CHECK = 3, 'Chèque' + CASH = 4, 'Espèce' + +class Discount(models.Model): + name = models.CharField(max_length=255, unique=True) + amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + description = models.TextField(blank=True) + discount_type = models.IntegerField(choices=DiscountType.choices, default=DiscountType.CURRENCY) + type = models.IntegerField(choices=FeeType.choices, default=FeeType.REGISTRATION_FEE) + updated_at = models.DateTimeField(auto_now=True) + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='discounts') + + def __str__(self): + return self.name + +class Fee(models.Model): + name = models.CharField(max_length=255, unique=True) + base_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + updated_at = models.DateTimeField(auto_now=True) + type = models.IntegerField(choices=FeeType.choices, default=FeeType.REGISTRATION_FEE) + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='fees') + + def __str__(self): + return self.name + +class PaymentPlan(models.Model): + frequency = models.IntegerField(choices=PaymentPlanType.choices, default=PaymentPlanType.ONE_TIME) + due_dates = ArrayField(models.DateField(), blank=True) + type = models.IntegerField(choices=FeeType.choices, default=FeeType.REGISTRATION_FEE) + is_active = models.BooleanField(default=False) + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='payment_plans') + + def __str__(self): + return f"{self.get_frequency_display()} - {self.get_type_display()}" + +class PaymentMode(models.Model): + mode = models.IntegerField(choices=PaymentModeType.choices, default=PaymentModeType.SEPA) + type = models.IntegerField(choices=FeeType.choices, default=FeeType.REGISTRATION_FEE) + is_active = models.BooleanField(default=False) + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='payment_modes') + + def __str__(self): + return f"{self.get_mode_display()} - {self.get_type_display()}" + diff --git a/Back-End/School/serializers.py b/Back-End/School/serializers.py new file mode 100644 index 0000000..42bb6bb --- /dev/null +++ b/Back-End/School/serializers.py @@ -0,0 +1,257 @@ +from rest_framework import serializers +from .models import Teacher, Speciality, SchoolClass, Planning, LEVEL_CHOICES, Discount, Fee, PaymentPlan, PaymentMode +from Auth.models import Profile, ProfileRole +from Subscriptions.models import Student +from Establishment.models import Establishment +from Auth.serializers import ProfileRoleSerializer +from N3wtSchool import settings, bdd +from django.utils import timezone +import pytz + +class SpecialitySerializer(serializers.ModelSerializer): + updated_date_formatted = serializers.SerializerMethodField() + + class Meta: + model = Speciality + fields = '__all__' + + def get_updated_date_formatted(self, obj): + utc_time = timezone.localtime(obj.updated_date) # Convert to local time + local_tz = pytz.timezone(settings.TZ_APPLI) + local_time = utc_time.astimezone(local_tz) + + return local_time.strftime("%d-%m-%Y %H:%M") + +class TeacherDetailSerializer(serializers.ModelSerializer): + specialities = SpecialitySerializer(many=True, read_only=True) + + class Meta: + model = Teacher + fields = ['id', 'last_name', 'first_name', 'email', 'specialities'] + +class TeacherSerializer(serializers.ModelSerializer): + specialities = serializers.PrimaryKeyRelatedField(queryset=Speciality.objects.all(), many=True, required=False) + specialities_details = serializers.SerializerMethodField() + updated_date_formatted = serializers.SerializerMethodField() + role_type_display = serializers.SerializerMethodField() + role_type = serializers.IntegerField(write_only=True) + associated_profile_email = serializers.EmailField(write_only=True) + profile_role = serializers.PrimaryKeyRelatedField(queryset=ProfileRole.objects.all(), required=False) + associated_profile_email_display = serializers.SerializerMethodField() + + class Meta: + model = Teacher + fields = '__all__' + + def create_or_update_profile_role(self, profile, associated_profile_email, establishment_id, role_type): + # Mettre à jour l'email du profil si nécessaire + if profile.email != associated_profile_email: + profile.email = associated_profile_email + profile.username = associated_profile_email + profile.save() + + profile_role, created = ProfileRole.objects.update_or_create( + profile=profile, + establishment_id=establishment_id, + defaults={'role_type': role_type, 'is_active': True} + ) + + if not created: + profile_role.role_type = role_type + profile_role.establishment_id = establishment_id + profile_role.save() + + return profile_role + + def create(self, validated_data): + specialities_data = validated_data.pop('specialities', None) + associated_profile_email = validated_data.pop('associated_profile_email') + establishment_id = validated_data.pop('establishment') + role_type = validated_data.pop('role_type') + + profile, created = Profile.objects.get_or_create( + email=associated_profile_email, + defaults={'username': associated_profile_email} + ) + + profile_role = self.create_or_update_profile_role(profile, associated_profile_email, establishment_id, role_type) + + teacher = Teacher.objects.create(profile_role=profile_role, **validated_data) + if specialities_data: + teacher.specialities.set(specialities_data) + teacher.save() + return teacher + + def update(self, instance, validated_data): + specialities_data = validated_data.pop('specialities', []) + associated_profile_email = validated_data.pop('associated_profile_email', instance.profile_role.profile.email) + establishment_id = validated_data.get('establishment', instance.profile_role.establishment.id) + role_type = validated_data.get('role_type', instance.profile_role.role_type) + + profile = instance.profile_role.profile + + profile_role = self.create_or_update_profile_role(profile, associated_profile_email, establishment_id, role_type) + instance.profile_role = profile_role + + instance.last_name = validated_data.get('last_name', instance.last_name) + instance.first_name = validated_data.get('first_name', instance.first_name) + instance.save() + if specialities_data: + instance.specialities.set(specialities_data) + return instance + + def get_updated_date_formatted(self, obj): + utc_time = timezone.localtime(obj.updated_date) # Convert to local time + local_tz = pytz.timezone(settings.TZ_APPLI) + local_time = utc_time.astimezone(local_tz) + return local_time.strftime("%d-%m-%Y %H:%M") + + def get_specialities_details(self, obj): + return [{'id': speciality.id, 'name': speciality.name, 'color_code': speciality.color_code} for speciality in obj.specialities.all()] + + def get_associated_profile_email(self, obj): + if obj.profile_role and obj.profile_role.profile: + return obj.profile_role.profile.email + return None + + def get_role_type_display(self, obj): + if obj.profile_role: + return obj.profile_role.role_type + return None + + def get_associated_profile_email_display(self, obj): + return self.get_associated_profile_email(obj) + +class PlanningSerializer(serializers.ModelSerializer): + class Meta: + model = Planning + fields = ['id', 'level', 'schedule'] + + def to_internal_value(self, data): + internal_value = super().to_internal_value(data) + internal_value['schedule'] = data.get('schedule', {}) + return internal_value + +class SchoolClassSerializer(serializers.ModelSerializer): + updated_date_formatted = serializers.SerializerMethodField() + teachers = serializers.PrimaryKeyRelatedField(queryset=Teacher.objects.all(), many=True, required=False) + establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False) + teachers_details = serializers.SerializerMethodField() + students = serializers.PrimaryKeyRelatedField(queryset=Student.objects.all(), many=True, required=False) + + class Meta: + model = SchoolClass + fields = '__all__' + + def create(self, validated_data): + teachers_data = validated_data.pop('teachers', []) + levels_data = validated_data.pop('levels', []) + plannings_data = validated_data.pop('plannings', []) + + school_class = SchoolClass.objects.create( + atmosphere_name=validated_data.get('atmosphere_name', ''), + age_range=validated_data.get('age_range', []), + number_of_students=validated_data.get('number_of_students', 0), + teaching_language=validated_data.get('teaching_language', ''), + school_year=validated_data.get('school_year', ''), + levels=levels_data, + type=validated_data.get('type', 1), + time_range=validated_data.get('time_range', ['08:30', '17:30']), + opening_days=validated_data.get('opening_days', [1, 2, 4, 5]), + establishment=validated_data.get('establishment', None) + ) + + school_class.teachers.set(teachers_data) + + for planning_data in plannings_data: + Planning.objects.create( + school_class=school_class, + level=planning_data['level'], + schedule=planning_data.get('schedule', {}) + ) + + return school_class + + def update(self, instance, validated_data): + teachers_data = validated_data.pop('teachers', []) + levels_data = validated_data.pop('levels', []) + plannings_data = validated_data.pop('plannings', []) + + instance.atmosphere_name = validated_data.get('atmosphere_name', instance.atmosphere_name) + instance.age_range = validated_data.get('age_range', instance.age_range) + instance.number_of_students = validated_data.get('number_of_students', instance.number_of_students) + instance.teaching_language = validated_data.get('teaching_language', instance.teaching_language) + instance.school_year = validated_data.get('school_year', instance.school_year) + instance.levels = levels_data + instance.type = validated_data.get('type', instance.type) + instance.time_range = validated_data.get('time_range', instance.time_range) + instance.opening_days = validated_data.get('opening_days', instance.opening_days) + instance.establishment = validated_data.get('establishment', instance.establishment) + + instance.save() + instance.teachers.set(teachers_data) + + existing_plannings = {planning.level: planning for planning in instance.plannings.all()} + + for planning_data in plannings_data: + level = planning_data['level'] + if level in existing_plannings: + # Update existing planning + planning = existing_plannings[level] + planning.schedule = planning_data.get('schedule', planning.schedule) + planning.save() + else: + # Create new planning if level not existing + Planning.objects.create( + school_class=instance, + level=level, + schedule=planning_data.get('schedule', {}) + ) + + return instance + + def get_teachers_details(self, obj): + return [{'id': teacher.id, 'last_name': teacher.last_name, 'first_name': teacher.first_name} for teacher in obj.teachers.all()] + + def get_updated_date_formatted(self, obj): + utc_time = timezone.localtime(obj.updated_date) + local_tz = pytz.timezone(settings.TZ_APPLI) + local_time = utc_time.astimezone(local_tz) + return local_time.strftime("%d-%m-%Y %H:%M") + +class DiscountSerializer(serializers.ModelSerializer): + updated_at_formatted = serializers.SerializerMethodField() + establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False) + class Meta: + model = Discount + fields = '__all__' + + def get_updated_at_formatted(self, obj): + utc_time = timezone.localtime(obj.updated_at) + local_tz = pytz.timezone(settings.TZ_APPLI) + local_time = utc_time.astimezone(local_tz) + return local_time.strftime("%d-%m-%Y %H:%M") + +class FeeSerializer(serializers.ModelSerializer): + updated_at_formatted = serializers.SerializerMethodField() + establishment = serializers.PrimaryKeyRelatedField(queryset=Establishment.objects.all(), required=False) + + class Meta: + model = Fee + fields = '__all__' + + def get_updated_at_formatted(self, obj): + utc_time = timezone.localtime(obj.updated_at) + local_tz = pytz.timezone(settings.TZ_APPLI) + local_time = utc_time.astimezone(local_tz) + return local_time.strftime("%d-%m-%Y %H:%M") + +class PaymentPlanSerializer(serializers.ModelSerializer): + class Meta: + model = PaymentPlan + fields = '__all__' + +class PaymentModeSerializer(serializers.ModelSerializer): + class Meta: + model = PaymentMode + fields = '__all__' \ No newline at end of file diff --git a/Back-End/GestionEnseignants/tests.py b/Back-End/School/tests.py similarity index 100% rename from Back-End/GestionEnseignants/tests.py rename to Back-End/School/tests.py diff --git a/Back-End/School/urls.py b/Back-End/School/urls.py new file mode 100644 index 0000000..8505bfc --- /dev/null +++ b/Back-End/School/urls.py @@ -0,0 +1,46 @@ +from django.urls import path, re_path + +from .views import ( + TeacherListCreateView, + TeacherDetailView, + SpecialityListCreateView, + SpecialityDetailView, + SchoolClassListCreateView, + SchoolClassDetailView, + PlanningListCreateView, + PlanningDetailView, + FeeListCreateView, + FeeDetailView, + DiscountListCreateView, + DiscountDetailView, + PaymentPlanListCreateView, + PaymentPlanDetailView, + PaymentModeListCreateView, + PaymentModeDetailView +) + +urlpatterns = [ + re_path(r'^specialities$', SpecialityListCreateView.as_view(), name="speciality_list_create"), + re_path(r'^specialities/(?P[0-9]+)$', SpecialityDetailView.as_view(), name="speciality_detail"), + + re_path(r'^teachers$', TeacherListCreateView.as_view(), name="teacher_list_create"), + re_path(r'^teachers/(?P[0-9]+)', TeacherDetailView.as_view(), name="teacher_detail"), + + re_path(r'^schoolClasses$', SchoolClassListCreateView.as_view(), name="school_class_list_create"), + re_path(r'^schoolClasses/(?P[0-9]+)', SchoolClassDetailView.as_view(), name="school_class_detail"), + + re_path(r'^plannings$', PlanningListCreateView.as_view(), name="planninglist_create"), + re_path(r'^plannings/(?P[0-9]+)$', PlanningDetailView.as_view(), name="planning_detail"), + + re_path(r'^fees$', FeeListCreateView.as_view(), name="fee_list_create"), + re_path(r'^fees/(?P[0-9]+)$', FeeDetailView.as_view(), name="fee_detail"), + + re_path(r'^discounts$', DiscountListCreateView.as_view(), name="discount_list_create"), + re_path(r'^discounts/(?P[0-9]+)$', DiscountDetailView.as_view(), name="discount_detail"), + + re_path(r'^paymentPlans$', PaymentPlanListCreateView.as_view(), name="payment_plan_list_create"), + re_path(r'^paymentPlans/(?P[0-9]+)$', PaymentPlanDetailView.as_view(), name="payment_plan_detail"), + + re_path(r'^paymentModes$', PaymentModeListCreateView.as_view(), name="payment_mode_list_create"), + re_path(r'^paymentModes/(?P[0-9]+)$', PaymentModeDetailView.as_view(), name="payment_mode_detail") +] \ No newline at end of file diff --git a/Back-End/School/views.py b/Back-End/School/views.py new file mode 100644 index 0000000..f211640 --- /dev/null +++ b/Back-End/School/views.py @@ -0,0 +1,413 @@ +from django.http.response import JsonResponse +from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect +from django.utils.decorators import method_decorator +from rest_framework.parsers import JSONParser +from rest_framework.views import APIView +from rest_framework import status +from .models import ( + Teacher, + Speciality, + SchoolClass, + Planning, + Discount, + Fee, + PaymentPlan, + PaymentMode +) +from .serializers import ( + TeacherSerializer, + SpecialitySerializer, + SchoolClassSerializer, + PlanningSerializer, + DiscountSerializer, + FeeSerializer, + PaymentPlanSerializer, + PaymentModeSerializer +) +from N3wtSchool.bdd import delete_object, getAllObjects, getObject + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class SpecialityListCreateView(APIView): + def get(self, request): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + specialities_list = getAllObjects(Speciality) + if establishment_id: + specialities_list = specialities_list.filter(establishment__id=establishment_id).distinct() + specialities_serializer = SpecialitySerializer(specialities_list, many=True) + return JsonResponse(specialities_serializer.data, safe=False) + + def post(self, request): + speciality_data=JSONParser().parse(request) + speciality_serializer = SpecialitySerializer(data=speciality_data) + + if speciality_serializer.is_valid(): + speciality_serializer.save() + return JsonResponse(speciality_serializer.data, safe=False, status=status.HTTP_201_CREATED) + + return JsonResponse(speciality_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class SpecialityDetailView(APIView): + def get(self, request, id): + speciality = getObject(_objectName=Speciality, _columnName='id', _value=id) + speciality_serializer=SpecialitySerializer(speciality) + return JsonResponse(speciality_serializer.data, safe=False) + + def put(self, request, id): + speciality_data=JSONParser().parse(request) + speciality = getObject(_objectName=Speciality, _columnName='id', _value=id) + speciality_serializer = SpecialitySerializer(speciality, data=speciality_data) + if speciality_serializer.is_valid(): + speciality_serializer.save() + return JsonResponse(speciality_serializer.data, safe=False) + + return JsonResponse(speciality_serializer.errors, safe=False) + + def delete(self, request, id): + return delete_object(Speciality, id) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class TeacherListCreateView(APIView): + def get(self, request): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + teachers_list = getAllObjects(Teacher) + if teachers_list: + teachers_list = teachers_list.filter(profile_role__establishment_id=establishment_id) + teachers_serializer = TeacherSerializer(teachers_list, many=True) + return JsonResponse(teachers_serializer.data, safe=False) + + def post(self, request): + teacher_data=JSONParser().parse(request) + teacher_serializer = TeacherSerializer(data=teacher_data) + + if teacher_serializer.is_valid(): + teacher_serializer.save() + + return JsonResponse(teacher_serializer.data, safe=False) + + return JsonResponse(teacher_serializer.errors, safe=False) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class TeacherDetailView(APIView): + def get (self, request, id): + teacher = getObject(_objectName=Teacher, _columnName='id', _value=id) + teacher_serializer=TeacherSerializer(teacher) + + return JsonResponse(teacher_serializer.data, safe=False) + + def put(self, request, id): + teacher_data=JSONParser().parse(request) + teacher = getObject(_objectName=Teacher, _columnName='id', _value=id) + teacher_serializer = TeacherSerializer(teacher, data=teacher_data) + if teacher_serializer.is_valid(): + teacher_serializer.save() + return JsonResponse(teacher_serializer.data, safe=False) + + return JsonResponse(teacher_serializer.errors, safe=False) + + def delete(self, request, id): + return delete_object(Teacher, id, related_field='profile') + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class SchoolClassListCreateView(APIView): + def get(self, request): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + school_classes_list = getAllObjects(SchoolClass) + if school_classes_list: + school_classes_list = school_classes_list.filter(establishment=establishment_id).distinct() + classes_serializer = SchoolClassSerializer(school_classes_list, many=True) + return JsonResponse(classes_serializer.data, safe=False) + + def post(self, request): + classe_data=JSONParser().parse(request) + classe_serializer = SchoolClassSerializer(data=classe_data) + + if classe_serializer.is_valid(): + classe_serializer.save() + return JsonResponse(classe_serializer.data, safe=False) + + return JsonResponse(classe_serializer.errors, safe=False) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class SchoolClassDetailView(APIView): + def get (self, request, id): + schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id) + classe_serializer=SchoolClassSerializer(schoolClass) + + return JsonResponse(classe_serializer.data, safe=False) + + def put(self, request, id): + classe_data=JSONParser().parse(request) + schoolClass = getObject(_objectName=SchoolClass, _columnName='id', _value=id) + classe_serializer = SchoolClassSerializer(schoolClass, data=classe_data) + if classe_serializer.is_valid(): + classe_serializer.save() + return JsonResponse(classe_serializer.data, safe=False) + + return JsonResponse(classe_serializer.errors, safe=False) + + def delete(self, request, id): + return delete_object(SchoolClass, id) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class PlanningListCreateView(APIView): + def get(self, request): + schedulesList=getAllObjects(Planning) + schedules_serializer=PlanningSerializer(schedulesList, many=True) + return JsonResponse(schedules_serializer.data, safe=False) + + def post(self, request): + planning_data=JSONParser().parse(request) + planning_serializer = PlanningSerializer(data=planning_data) + + if planning_serializer.is_valid(): + planning_serializer.save() + return JsonResponse(planning_serializer.data, safe=False) + + return JsonResponse(planning_serializer.errors, safe=False) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class PlanningDetailView(APIView): + def get (self, request, id): + planning = getObject(_objectName=Planning, _columnName='classe_id', _value=id) + planning_serializer=PlanningSerializer(planning) + + return JsonResponse(planning_serializer.data, safe=False) + + def put(self, request, id): + planning_data = JSONParser().parse(request) + + try: + planning = Planning.objects.get(id=id) + except Planning.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + except Planning.MultipleObjectsReturned: + return JsonResponse({'error': 'Multiple objects found'}, status=status.HTTP_400_BAD_REQUEST) + + planning_serializer = PlanningSerializer(planning, data=planning_data) + + if planning_serializer.is_valid(): + planning_serializer.save() + return JsonResponse(planning_serializer.data, safe=False) + + return JsonResponse(planning_serializer.errors, safe=False) + + def delete(self, request, id): + return delete_object(Planning, id) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class FeeListCreateView(APIView): + def get(self, request, *args, **kwargs): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + filter = request.GET.get('filter', '').strip() + fee_type_value = 0 if filter == 'registration' else 1 + + fees = Fee.objects.filter(type=fee_type_value, establishment_id=establishment_id).distinct() + fee_serializer = FeeSerializer(fees, many=True) + + return JsonResponse(fee_serializer.data, safe=False, status=status.HTTP_200_OK) + + def post(self, request): + fee_data = JSONParser().parse(request) + fee_serializer = FeeSerializer(data=fee_data) + if fee_serializer.is_valid(): + fee_serializer.save() + return JsonResponse(fee_serializer.data, safe=False, status=status.HTTP_201_CREATED) + return JsonResponse(fee_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class FeeDetailView(APIView): + def get(self, request, id): + try: + fee = Fee.objects.get(id=id) + fee_serializer = FeeSerializer(fee) + return JsonResponse(fee_serializer.data, safe=False) + except Fee.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + def put(self, request, id): + fee_data = JSONParser().parse(request) + try: + fee = Fee.objects.get(id=id) + except Fee.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + fee_serializer = FeeSerializer(fee, data=fee_data, partial=True) + if fee_serializer.is_valid(): + fee_serializer.save() + return JsonResponse(fee_serializer.data, safe=False) + return JsonResponse(fee_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, id): + return delete_object(Fee, id) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class DiscountListCreateView(APIView): + def get(self, request, *args, **kwargs): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + filter = request.GET.get('filter', '').strip() + discount_type_value = 0 if filter == 'registration' else 1 + + discounts = Discount.objects.filter(type=discount_type_value, establishment_id=establishment_id).distinct() + discounts_serializer = DiscountSerializer(discounts, many=True) + + return JsonResponse(discounts_serializer.data, safe=False, status=status.HTTP_200_OK) + + def post(self, request): + discount_data = JSONParser().parse(request) + discount_serializer = DiscountSerializer(data=discount_data) + if discount_serializer.is_valid(): + discount_serializer.save() + return JsonResponse(discount_serializer.data, safe=False, status=status.HTTP_201_CREATED) + return JsonResponse(discount_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class DiscountDetailView(APIView): + def get(self, request, id): + try: + discount = Discount.objects.get(id=id) + discount_serializer = DiscountSerializer(discount) + return JsonResponse(discount_serializer.data, safe=False) + except Discount.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + def put(self, request, id): + discount_data = JSONParser().parse(request) + try: + discount = Discount.objects.get(id=id) + except Discount.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + discount_serializer = DiscountSerializer(discount, data=discount_data, partial=True) # Utilisation de partial=True + if discount_serializer.is_valid(): + discount_serializer.save() + return JsonResponse(discount_serializer.data, safe=False) + return JsonResponse(discount_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, id): + return delete_object(Discount, id) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class PaymentPlanListCreateView(APIView): + def get(self, request, *args, **kwargs): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + filter = request.GET.get('filter', '').strip() + type_value = 0 if filter == 'registration' else 1 + + payment_plans = PaymentPlan.objects.filter(type=type_value, establishment_id=establishment_id).distinct() + payment_plans_serializer = PaymentPlanSerializer(payment_plans, many=True) + + return JsonResponse(payment_plans_serializer.data, safe=False, status=status.HTTP_200_OK) + + def post(self, request): + payment_plan_data = JSONParser().parse(request) + payment_plan_serializer = PaymentPlanSerializer(data=payment_plan_data) + if payment_plan_serializer.is_valid(): + payment_plan_serializer.save() + return JsonResponse(payment_plan_serializer.data, safe=False, status=status.HTTP_201_CREATED) + return JsonResponse(payment_plan_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class PaymentPlanDetailView(APIView): + def get(self, request, id): + try: + payment_plan = PaymentPlan.objects.get(id=id) + payment_plan_serializer = PaymentPlanSerializer(payment_plan) + return JsonResponse(payment_plan_serializer.data, safe=False) + except PaymentPlan.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + def put(self, request, id): + payment_plan_data = JSONParser().parse(request) + try: + payment_plan = PaymentPlan.objects.get(id=id) + except PaymentPlan.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + payment_plan_serializer = PaymentPlanSerializer(payment_plan, data=payment_plan_data, partial=True) + if payment_plan_serializer.is_valid(): + payment_plan_serializer.save() + return JsonResponse(payment_plan_serializer.data, safe=False) + return JsonResponse(payment_plan_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, id): + return delete_object(PaymentPlan, id) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class PaymentModeListCreateView(APIView): + def get(self, request): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + filter = request.GET.get('filter', '').strip() + type_value = 0 if filter == 'registration' else 1 + + payment_modes = PaymentMode.objects.filter(type=type_value, establishment_id=establishment_id).distinct() + payment_modes_serializer = PaymentModeSerializer(payment_modes, many=True) + + return JsonResponse(payment_modes_serializer.data, safe=False, status=status.HTTP_200_OK) + + def post(self, request): + payment_mode_data = JSONParser().parse(request) + payment_mode_serializer = PaymentModeSerializer(data=payment_mode_data) + if payment_mode_serializer.is_valid(): + payment_mode_serializer.save() + return JsonResponse(payment_mode_serializer.data, safe=False, status=status.HTTP_201_CREATED) + return JsonResponse(payment_mode_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +@method_decorator(csrf_protect, name='dispatch') +@method_decorator(ensure_csrf_cookie, name='dispatch') +class PaymentModeDetailView(APIView): + def get(self, request, id): + try: + payment_mode = PaymentMode.objects.get(id=id) + payment_mode_serializer = PaymentModeSerializer(payment_mode) + return JsonResponse(payment_mode_serializer.data, safe=False) + except PaymentMode.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + def put(self, request, id): + payment_mode_data = JSONParser().parse(request) + try: + payment_mode = PaymentMode.objects.get(id=id) + except PaymentMode.DoesNotExist: + return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + payment_mode_serializer = PaymentModeSerializer(payment_mode, data=payment_mode_data, partial=True) + if payment_mode_serializer.is_valid(): + payment_mode_serializer.save() + return JsonResponse(payment_mode_serializer.data, safe=False) + return JsonResponse(payment_mode_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, id): + return delete_object(PaymentMode, id) diff --git a/Back-End/GestionInscriptions/Configuration/application.default.json b/Back-End/Subscriptions/Configuration/application.default.json similarity index 100% rename from Back-End/GestionInscriptions/Configuration/application.default.json rename to Back-End/Subscriptions/Configuration/application.default.json diff --git a/Back-End/GestionInscriptions/Configuration/automate.json b/Back-End/Subscriptions/Configuration/automate.json similarity index 100% rename from Back-End/GestionInscriptions/Configuration/automate.json rename to Back-End/Subscriptions/Configuration/automate.json diff --git a/Back-End/GestionInscriptions/Configuration/inscriptions.json b/Back-End/Subscriptions/Configuration/inscriptions.json similarity index 100% rename from Back-End/GestionInscriptions/Configuration/inscriptions.json rename to Back-End/Subscriptions/Configuration/inscriptions.json diff --git a/Back-End/Subscriptions/__init__.py b/Back-End/Subscriptions/__init__.py new file mode 100644 index 0000000..91049e1 --- /dev/null +++ b/Back-End/Subscriptions/__init__.py @@ -0,0 +1 @@ +default_app_config = 'Subscriptions.apps.GestionInscriptionsConfig' \ No newline at end of file diff --git a/Back-End/GestionInscriptions/admin.py b/Back-End/Subscriptions/admin.py similarity index 79% rename from Back-End/GestionInscriptions/admin.py rename to Back-End/Subscriptions/admin.py index 2d81adb..f0bf1ee 100644 --- a/Back-End/GestionInscriptions/admin.py +++ b/Back-End/Subscriptions/admin.py @@ -2,8 +2,8 @@ from django.contrib import admin from .models import * -admin.site.register(Eleve) -admin.site.register(Responsable) +admin.site.register(Student) +admin.site.register(Guardian) class EleveAdmin(admin.ModelAdmin): def save_model(self, request, obj, form, change): diff --git a/Back-End/GestionInscriptions/apps.py b/Back-End/Subscriptions/apps.py similarity index 55% rename from Back-End/GestionInscriptions/apps.py rename to Back-End/Subscriptions/apps.py index f88ea7f..3ab723f 100644 --- a/Back-End/GestionInscriptions/apps.py +++ b/Back-End/Subscriptions/apps.py @@ -3,8 +3,5 @@ from django.conf import settings class GestioninscriptionsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'GestionInscriptions' + name = 'Subscriptions' - def ready(self): - from GestionInscriptions.signals import clear_cache - clear_cache() diff --git a/Back-End/Subscriptions/automate.py b/Back-End/Subscriptions/automate.py new file mode 100644 index 0000000..6f98e79 --- /dev/null +++ b/Back-End/Subscriptions/automate.py @@ -0,0 +1,43 @@ +# state_machine.py +import json +from Subscriptions.models import RegistrationForm + +state_mapping = { + "ABSENT": RegistrationForm.RegistrationFormStatus.RF_ABSENT, + "CREE": RegistrationForm.RegistrationFormStatus.RF_CREATED, + "ENVOYE": RegistrationForm.RegistrationFormStatus.RF_SENT, + "EN_VALIDATION": RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW, + "A_RELANCER": RegistrationForm.RegistrationFormStatus.RF_TO_BE_FOLLOWED_UP, + "VALIDE": RegistrationForm.RegistrationFormStatus.RF_VALIDATED, + "ARCHIVE": RegistrationForm.RegistrationFormStatus.RF_ARCHIVED +} + +def load_config(config_file): + with open(config_file, 'r') as file: + config = json.load(file) + return config + +def getStateMachineObject(etat) : + return Automate_RF_Register(etat) + +def getStateMachineObjectState(etat): + return Automate_RF_Register(etat).state + +def updateStateMachine(rf, transition) : + automateModel = load_config('Subscriptions/Configuration/automate.json') + state_machine = getStateMachineObject(rf.status) + print(f'etat DI : {state_machine.state}') + if state_machine.trigger(transition, automateModel): + rf.status = state_machine.state + rf.save() + +class Automate_RF_Register: + def __init__(self, initial_state): + self.state = initial_state + + def trigger(self, transition_name, config): + for transition in config["transitions"]: + if transition["name"] == transition_name and self.state == state_mapping[transition["from"]]: + self.state = state_mapping[transition["to"]] + return True + return False \ No newline at end of file diff --git a/Back-End/Subscriptions/mailManager.py b/Back-End/Subscriptions/mailManager.py new file mode 100644 index 0000000..26342e3 --- /dev/null +++ b/Back-End/Subscriptions/mailManager.py @@ -0,0 +1,86 @@ +from django.core.mail import send_mail, EmailMultiAlternatives, EmailMessage +from django.template.loader import render_to_string +from django.utils.html import strip_tags +import re +from N3wtSchool import settings + +def envoieReinitMotDePasse(recipients, code): + errorMessage = '' + try: + EMAIL_REINIT_SUBJECT = 'Réinitialisation du mot de passe' + context = { + 'BASE_URL': settings.BASE_URL, + 'code': str(code) + } + subject = EMAIL_REINIT_SUBJECT + html_message = render_to_string('emails/resetPassword.html', context) + plain_message = strip_tags(html_message) + from_email = settings.EMAIL_HOST_USER + send_mail(subject, plain_message, from_email, [recipients], html_message=html_message) + except Exception as e: + errorMessage = str(e) + + return errorMessage + + +def sendRegisterForm(recipients, establishment_id): + errorMessage = '' + try: + print(f'{settings.EMAIL_HOST_USER}') + # Préparation du contexte pour le template + EMAIL_INSCRIPTION_SUBJECT = '[N3WT-SCHOOL] Dossier Inscription' + context = { + 'BASE_URL': settings.BASE_URL, + 'email': recipients, + 'establishment': establishment_id + } + + subject = EMAIL_INSCRIPTION_SUBJECT + html_message = render_to_string('emails/inscription.html', context) + plain_message = strip_tags(html_message) + from_email = settings.EMAIL_HOST_USER + + send_mail(subject, plain_message, from_email, [recipients], html_message=html_message) + + except Exception as e: + errorMessage = str(e) + + return errorMessage + +def envoieRelanceDossierInscription(recipients, code): + EMAIL_RELANCE_SUBJECT = '[N3WT-SCHOOL] Relance - Dossier Inscription' + EMAIL_RELANCE_CORPUS = 'Bonjour,\nN\'ayant pas eu de retour de votre part, nous vous renvoyons le lien vers le formulaire d\'inscription : ' + BASE_URL + '/users/login\nCordialement' + errorMessage = '' + try: + send_mail( + EMAIL_RELANCE_SUBJECT, + EMAIL_RELANCE_CORPUS%str(code), + settings.EMAIL_HOST_USER, + [recipients], + fail_silently=False, + ) + except Exception as e: + errorMessage = str(e) + + return errorMessage + +def isValid(message, fiche_inscription): + # Est-ce que la référence du dossier est VALIDE + subject = message.subject + print ("++++ " + subject) + responsableMail = message.from_header + result = re.search('<(.*)>', responsableMail) + + if result: + responsableMail = result.group(1) + + result = re.search(r'.*\[Ref(.*)\].*', subject) + idMail = -1 + if result: + idMail = result.group(1).strip() + + eleve = fiche_inscription.eleve + responsable = eleve.getMainGuardian() + mailReponsableAVerifier = responsable.mail + + return responsableMail == mailReponsableAVerifier and str(idMail) == str(fiche_inscription.eleve.id) \ No newline at end of file diff --git a/Back-End/Subscriptions/models.py b/Back-End/Subscriptions/models.py new file mode 100644 index 0000000..4f70800 --- /dev/null +++ b/Back-End/Subscriptions/models.py @@ -0,0 +1,247 @@ +from django.db import models +from django.utils.timezone import now +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from School.models import SchoolClass, Fee, Discount +from Auth.models import ProfileRole +from Establishment.models import Establishment + +from datetime import datetime + +class Language(models.Model): + """ + Représente une langue parlée par l’élève. + """ + id = models.AutoField(primary_key=True) + label = models.CharField(max_length=200, default="") + + def __str__(self): + return "LANGUAGE" + +class Guardian(models.Model): + """ + Représente un responsable légal (parent/tuteur) d’un élève. + """ + last_name = models.CharField(max_length=200, default="") + first_name = models.CharField(max_length=200, default="") + birth_date = models.CharField(max_length=200, default="", blank=True) + address = models.CharField(max_length=200, default="", blank=True) + phone = models.CharField(max_length=200, default="", blank=True) + profession = models.CharField(max_length=200, default="", blank=True) + profile_role = models.OneToOneField(ProfileRole, on_delete=models.CASCADE, related_name='guardian_profile', null=True, blank=True) + + def __str__(self): + return self.last_name + "_" + self.first_name + +class Sibling(models.Model): + """ + Représente un frère ou une sœur d’un élève. + """ + id = models.AutoField(primary_key=True) + last_name = models.CharField(max_length=200, default="") + first_name = models.CharField(max_length=200, default="") + birth_date = models.CharField(max_length=200, default="", blank=True) + + def __str__(self): + return "SIBLING" + +class Student(models.Model): + """ + Représente l’élève inscrit ou en cours d’inscription. + """ + class StudentGender(models.IntegerChoices): + NONE = 0, _('Sélection du genre') + MALE = 1, _('Garçon') + FEMALE = 2, _('Fille') + + class StudentLevel(models.IntegerChoices): + NONE = 0, _('Sélection du niveau') + TPS = 1, _('TPS - Très Petite Section') + PS = 2, _('PS - Petite Section') + MS = 3, _('MS - Moyenne Section') + GS = 4, _('GS - Grande Section') + + class PaymentMethod(models.IntegerChoices): + NONE = 0, _('Sélection du mode de paiement') + SEPA_DIRECT_DEBIT = 1, _('Prélèvement SEPA') + CHECK = 2, _('Chèques') + + last_name = models.CharField(max_length=200, default="") + first_name = models.CharField(max_length=200, default="") + gender = models.IntegerField(choices=StudentGender, default=StudentGender.NONE, blank=True) + level = models.IntegerField(choices=StudentLevel, default=StudentLevel.NONE, blank=True) + nationality = models.CharField(max_length=200, default="", blank=True) + address = models.CharField(max_length=200, default="", blank=True) + birth_date = models.DateField(null=True, blank=True) + birth_place = models.CharField(max_length=200, default="", blank=True) + birth_postal_code = models.IntegerField(default=0, blank=True) + attending_physician = models.CharField(max_length=200, default="", blank=True) + payment_method = models.IntegerField(choices=PaymentMethod, default=PaymentMethod.NONE, blank=True) + + # Many-to-Many Relationship + profiles = models.ManyToManyField('Auth.Profile', blank=True) + + # Many-to-Many Relationship + guardians = models.ManyToManyField(Guardian, blank=True) + + # Many-to-Many Relationship + siblings = models.ManyToManyField(Sibling, blank=True) + + # Many-to-Many Relationship + registration_files = models.ManyToManyField('RegistrationTemplate', blank=True, related_name='students') + + # Many-to-Many Relationship + spoken_languages = models.ManyToManyField(Language, blank=True) + + # One-to-Many Relationship + associated_class = models.ForeignKey(SchoolClass, on_delete=models.SET_NULL, null=True, blank=True, related_name='students') + + def __str__(self): + return self.last_name + "_" + self.first_name + + def getSpokenLanguages(self): + """ + Retourne la liste des langues parlées par l’élève. + """ + return self.spoken_languages.all() + + def getMainGuardian(self): + """ + Retourne le responsable légal principal de l’élève. + """ + return self.guardians.all()[0] + + def getGuardians(self): + """ + Retourne tous les responsables légaux de l’élève. + """ + return self.guardians.all() + + def getProfiles(self): + """ + Retourne les profils utilisateurs liés à l’élève. + """ + return self.profiles.all() + + def getSiblings(self): + """ + Retourne les frères et sœurs de l’élève. + """ + return self.siblings.all() + + def getNumberOfSiblings(self): + """ + Retourne le nombre de frères et sœurs. + """ + return self.siblings.count() + + @property + def age(self): + if self.birth_date: + today = datetime.today() + years = today.year - self.birth_date.year + months = today.month - self.birth_date.month + if today.day < self.birth_date.day: + months -= 1 + if months < 0: + years -= 1 + months += 12 + + # Determine the age format + if 6 <= months <= 12: + return f"{years} years 1/2" + else: + return f"{years} years" + return None + + @property + def formatted_birth_date(self): + if self.birth_date: + return self.birth_date.strftime('%d-%m-%Y') + return None + +class RegistrationFileGroup(models.Model): + name = models.CharField(max_length=255, default="") + description = models.TextField(blank=True, null=True) + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='file_group', null=True, blank=True) + + def __str__(self): + return self.name + +def registration_file_path(instance, filename): + # Génère le chemin : registration_files/dossier_rf_{student_id}/filename + return f'registration_files/dossier_rf_{instance.student_id}/{filename}' + +class RegistrationTemplateMaster(models.Model): + groups = models.ManyToManyField(RegistrationFileGroup, related_name='template_masters', blank=True) + id = models.IntegerField(primary_key=True) + name = models.CharField(max_length=255, default="") + is_required = models.BooleanField(default=False) + + def __str__(self): + return f'{self.group.name} - {self.id}' + +class RegistrationForm(models.Model): + class RegistrationFormStatus(models.IntegerChoices): + RF_ABSENT = 0, _('Pas de dossier d\'inscription') + RF_CREATED = 1, _('Dossier d\'inscription créé') + RF_SENT = 2, _('Dossier d\'inscription envoyé') + RF_UNDER_REVIEW = 3, _('Dossier d\'inscription en cours de validation') + RF_TO_BE_FOLLOWED_UP = 4, _('Dossier d\'inscription à relancer') + RF_VALIDATED = 5, _('Dossier d\'inscription validé') + RF_ARCHIVED = 6, _('Dossier d\'inscription archivé') + + # One-to-One Relationship + student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True) + status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_ABSENT) + last_update = models.DateTimeField(auto_now=True) + notes = models.CharField(max_length=200, blank=True) + registration_link_code = models.CharField(max_length=200, default="", blank=True) + registration_file = models.FileField( + upload_to=registration_file_path, + null=True, + blank=True + ) + associated_rf = models.CharField(max_length=200, default="", blank=True) + + # Many-to-Many Relationship + fees = models.ManyToManyField(Fee, blank=True, related_name='register_forms') + + # Many-to-Many Relationship + discounts = models.ManyToManyField(Discount, blank=True, related_name='register_forms') + fileGroup = models.ForeignKey(RegistrationFileGroup, + on_delete=models.CASCADE, + related_name='register_forms', + null=True, + blank=True) + + establishment = models.ForeignKey(Establishment, on_delete=models.CASCADE, related_name='register_forms') + + def __str__(self): + return "RF_" + self.student.last_name + "_" + self.student.first_name + +def registration_file_upload_to(instance, filename): + return f"registration_files/dossier_rf_{instance.registration_form.pk}/{filename}" + +class RegistrationTemplate(models.Model): + master = models.ForeignKey(RegistrationTemplateMaster, on_delete=models.CASCADE, related_name='templates', blank=True) + id = models.IntegerField(primary_key=True) + slug = models.CharField(max_length=255, default="") + name = models.CharField(max_length=255, default="") + registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='templates', blank=True) + file = models.FileField(null=True,blank=True, upload_to=registration_file_upload_to) + + def __str__(self): + return self.name + + @staticmethod + def get_files_from_rf(register_form_id): + """ + Récupère tous les fichiers liés à un dossier d’inscription donné. + """ + registration_files = RegistrationTemplate.objects.filter(registration_form=register_form_id) + filenames = [] + for reg_file in registration_files: + filenames.append(reg_file.file.path) + return filenames \ No newline at end of file diff --git a/Back-End/GestionInscriptions/pagination.py b/Back-End/Subscriptions/pagination.py similarity index 93% rename from Back-End/GestionInscriptions/pagination.py rename to Back-End/Subscriptions/pagination.py index db1809c..f2a97dc 100644 --- a/Back-End/GestionInscriptions/pagination.py +++ b/Back-End/Subscriptions/pagination.py @@ -16,5 +16,5 @@ class CustomPagination(PageNumberPagination): 'count': self.page.paginator.count, 'page_size': self.page_size, 'max_page_size' : self.max_page_size, - 'fichesInscriptions': data } + 'registerForms': data } ) \ No newline at end of file diff --git a/Back-End/Subscriptions/serializers.py b/Back-End/Subscriptions/serializers.py new file mode 100644 index 0000000..1ed09df --- /dev/null +++ b/Back-End/Subscriptions/serializers.py @@ -0,0 +1,314 @@ +from rest_framework import serializers +from .models import RegistrationFileGroup, RegistrationForm, Student, Guardian, Sibling, Language, RegistrationTemplateMaster, RegistrationTemplate +from School.models import SchoolClass, Fee, Discount, FeeType +from School.serializers import FeeSerializer, DiscountSerializer +from Auth.models import ProfileRole, Profile +from Auth.serializers import ProfileSerializer, ProfileRoleSerializer +from GestionMessagerie.models import Messagerie +from GestionNotification.models import Notification +from N3wtSchool import settings +from django.utils import timezone +import pytz +from datetime import datetime + +class RegistrationTemplateMasterSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + class Meta: + model = RegistrationTemplateMaster + fields = '__all__' + +class RegistrationTemplateSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + class Meta: + model = RegistrationTemplate + fields = '__all__' + +class GuardianSimpleSerializer(serializers.ModelSerializer): + associated_profile_email = serializers.SerializerMethodField() + + class Meta: + model = Guardian + fields = ['id', 'associated_profile_email'] + + def get_associated_profile_email(self, obj): + if obj.profile_role and obj.profile_role.profile: + return obj.profile_role.profile.email + return None + +class RegistrationFormSimpleSerializer(serializers.ModelSerializer): + guardians = GuardianSimpleSerializer(many=True, source='student.guardians') + last_name = serializers.SerializerMethodField() + first_name = serializers.SerializerMethodField() + + class Meta: + model = RegistrationForm + fields = ['student_id', 'last_name', 'first_name', 'guardians'] + + def get_last_name(self, obj): + return obj.student.last_name + + def get_first_name(self, obj): + return obj.student.first_name + +class RegistrationFileGroupSerializer(serializers.ModelSerializer): + registration_forms = serializers.SerializerMethodField() + + class Meta: + model = RegistrationFileGroup + fields = '__all__' + + def get_registration_forms(self, obj): + forms = RegistrationForm.objects.filter(fileGroup=obj) + return RegistrationFormSimpleSerializer(forms, many=True).data + +class LanguageSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + class Meta: + model = Language + fields = '__all__' + +class SiblingSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + class Meta: + model = Sibling + fields = '__all__' + +class GuardianSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + profile_role = serializers.PrimaryKeyRelatedField(queryset=ProfileRole.objects.all(), required=False) + profile_role_data = ProfileRoleSerializer(write_only=True, required=False) + associated_profile_email = serializers.SerializerMethodField() + + class Meta: + model = Guardian + fields = '__all__' + + def get_associated_profile_email(self, obj): + if obj.profile_role and obj.profile_role.profile: + return obj.profile_role.profile.email + return None + +class StudentSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + guardians = GuardianSerializer(many=True, required=False) + siblings = SiblingSerializer(many=True, required=False) + spoken_languages = LanguageSerializer(many=True, required=False) + associated_class_id = serializers.PrimaryKeyRelatedField(queryset=SchoolClass.objects.all(), source='associated_class', required=False, write_only=False, read_only=False) + age = serializers.SerializerMethodField() + formatted_birth_date = serializers.SerializerMethodField() + birth_date = serializers.DateField(input_formats=['%d-%m-%Y', '%Y-%m-%d'], required=False, allow_null=True) + associated_class_name = serializers.SerializerMethodField() + + class Meta: + model = Student + fields = '__all__' + + def create_or_update_guardians(self, guardians_data): + guardians_ids = [] + for guardian_data in guardians_data: + profile_role_data = guardian_data.pop('profile_role_data', None) + profile_role = guardian_data.pop('profile_role', None) + + if profile_role_data: + # Vérifiez si 'profile' est un objet ou une clé primaire + if isinstance(profile_role_data.get('profile'), Profile): + profile_role_data['profile'] = profile_role_data['profile'].id + establishment_id = profile_role_data.pop('establishment').id + profile_role_data['establishment'] = establishment_id + + profile_role_serializer = ProfileRoleSerializer(data=profile_role_data) + profile_role_serializer.is_valid(raise_exception=True) + profile_role = profile_role_serializer.save() + elif profile_role: + profile_role = ProfileRole.objects.get(id=profile_role.id) + + if profile_role: + guardian_data['profile_role'] = profile_role + + guardian_instance, created = Guardian.objects.update_or_create( + id=guardian_data.get('id'), + defaults=guardian_data + ) + guardians_ids.append(guardian_instance.id) + return guardians_ids + + def create_or_update_siblings(self, siblings_data): + siblings_ids = [] + for sibling_data in siblings_data: + sibling_instance, created = Sibling.objects.update_or_create( + id=sibling_data.get('id'), + defaults=sibling_data + ) + siblings_ids.append(sibling_instance.id) + return siblings_ids + + def create_or_update_languages(self, languages_data): + languages_ids = [] + for language_data in languages_data: + language_instance, created = Language.objects.update_or_create( + id=language_data.get('id'), + defaults=language_data + ) + languages_ids.append(language_instance.id) + return languages_ids + + def create(self, validated_data): + guardians_data = validated_data.pop('guardians', []) + siblings_data = validated_data.pop('siblings', []) + languages_data = validated_data.pop('spoken_languages', []) + student = Student.objects.create(**validated_data) + student.guardians.set(self.create_or_update_guardians(guardians_data)) + student.siblings.set(self.create_or_update_siblings(siblings_data)) + student.spoken_languages.set(self.create_or_update_languages(languages_data)) + + return student + + def update(self, instance, validated_data): + guardians_data = validated_data.pop('guardians', []) + siblings_data = validated_data.pop('siblings', []) + languages_data = validated_data.pop('spoken_languages', []) + if guardians_data: + instance.guardians.set(self.create_or_update_guardians(guardians_data)) + if siblings_data: + instance.siblings.set(self.create_or_update_siblings(siblings_data)) + if languages_data: + instance.spoken_languages.set(self.create_or_update_languages(languages_data)) + + for field in self.fields: + try: + setattr(instance, field, validated_data[field]) + except KeyError: + pass + instance.save() + + return instance + + def get_age(self, obj): + return obj.age + + def get_formatted_birth_date(self, obj): + return obj.formatted_birth_date + + def get_associated_class_name(self, obj): + return obj.associated_class.atmosphereName if obj.associated_class else None + +class RegistrationFormSerializer(serializers.ModelSerializer): + student = StudentSerializer(many=False, required=False) + registration_file = serializers.FileField(required=False) + status_label = serializers.SerializerMethodField() + formatted_last_update = serializers.SerializerMethodField() + registration_files = RegistrationTemplateSerializer(many=True, required=False) + fees = serializers.PrimaryKeyRelatedField(queryset=Fee.objects.all(), many=True, required=False) + discounts = serializers.PrimaryKeyRelatedField(queryset=Discount.objects.all(), many=True, required=False) + totalRegistrationFees = serializers.SerializerMethodField() + totalTuitionFees = serializers.SerializerMethodField() + + class Meta: + model = RegistrationForm + fields = '__all__' + + def create(self, validated_data): + student_data = validated_data.pop('student') + student = StudentSerializer.create(StudentSerializer(), student_data) + fees_data = validated_data.pop('fees', []) + discounts_data = validated_data.pop('discounts', []) + registrationForm = RegistrationForm.objects.create(student=student, **validated_data) + + # Associer les IDs des objets Fee et Discount au RegistrationForm + registrationForm.fees.set([fee.id for fee in fees_data]) + registrationForm.discounts.set([discount.id for discount in discounts_data]) + return registrationForm + + def update(self, instance, validated_data): + student_data = validated_data.pop('student', None) + fees_data = validated_data.pop('fees', []) + discounts_data = validated_data.pop('discounts', []) + if student_data: + student = instance.student + StudentSerializer.update(StudentSerializer(), student, student_data) + + for field in self.fields: + try: + setattr(instance, field, validated_data[field]) + except KeyError: + pass + instance.save() + + # Associer les IDs des objets Fee et Discount au RegistrationForm + instance.fees.set([fee.id for fee in fees_data]) + instance.discounts.set([discount.id for discount in discounts_data]) + + return instance + + def get_status_label(self, obj): + return obj.get_status_display() + + def get_formatted_last_update(self, obj): + utc_time = timezone.localtime(obj.last_update) # Convert to local time + local_tz = pytz.timezone(settings.TZ_APPLI) + local_time = utc_time.astimezone(local_tz) + + return local_time.strftime("%d-%m-%Y %H:%M") + + def get_totalRegistrationFees(self, obj): + return sum(fee.base_amount for fee in obj.fees.filter(type=FeeType.REGISTRATION_FEE)) + + def get_totalTuitionFees(self, obj): + return sum(fee.base_amount for fee in obj.fees.filter(type=FeeType.TUITION_FEE)) + +class StudentByParentSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + + class Meta: + model = Student + fields = ['id', 'last_name', 'first_name'] + + def __init__(self, *args, **kwargs): + super(StudentByParentSerializer, self).__init__(*args, **kwargs) + for field in self.fields: + self.fields[field].required = False + +class RegistrationFormByParentSerializer(serializers.ModelSerializer): + student = StudentByParentSerializer(many=False, required=True) + + class Meta: + model = RegistrationForm + fields = ['student', 'status'] + + def __init__(self, *args, **kwargs): + super(RegistrationFormByParentSerializer, self).__init__(*args, **kwargs) + for field in self.fields: + self.fields[field].required = False + +class GuardianByDICreationSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + associated_profile_email = serializers.SerializerMethodField() + + class Meta: + model = Guardian + fields = ['id', 'last_name', 'first_name', 'associated_profile_email'] + + def get_associated_profile_email(self, obj): + if obj.profile_role and obj.profile_role.profile: + return obj.profile_role.profile.email + return None + +class StudentByRFCreationSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(required=False) + guardians = GuardianByDICreationSerializer(many=True, required=False) + + class Meta: + model = Student + fields = ['id', 'last_name', 'first_name', 'guardians'] + + def __init__(self, *args, **kwargs): + super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs) + for field in self.fields: + self.fields[field].required = False + +class NotificationSerializer(serializers.ModelSerializer): + notification_type_label = serializers.ReadOnlyField() + + class Meta: + model = Notification + fields = '__all__' diff --git a/Back-End/Subscriptions/signals.py b/Back-End/Subscriptions/signals.py new file mode 100644 index 0000000..1da56dc --- /dev/null +++ b/Back-End/Subscriptions/signals.py @@ -0,0 +1,26 @@ +from django.db.models.signals import post_save, post_delete, m2m_changed +from django.dispatch import receiver +from .models import RegistrationForm, Student, Guardian +from Auth.models import Profile +from N3wtSchool import settings +from N3wtSchool.redis_client import redis_client +import logging +logger = logging.getLogger(__name__) + +@receiver(m2m_changed, sender=Student.guardians.through) +def check_orphan_reponsables(sender, **kwargs): + action = kwargs.pop('action', None) + instance = kwargs.pop('instance', None) + # pre_clear : lors de la suppression d'une FI (on fait un "clear" sur chaque relation) + if action in ('post_remove', 'post_clear'): + if instance.guardians.all(): + Guardian.objects.filter(eleve=None).delete() + +@receiver(m2m_changed, sender=Student.profiles.through) +def check_orphan_profils(sender, **kwargs): + action = kwargs.pop('action', None) + instance = kwargs.pop('instance', None) + # pre_clear : lors de la suppression d'une FI (on fait un "clear" sur chaque relation) + if action in ('post_remove', 'post_clear'): + if instance.profiles.all(): + Profile.objects.filter(eleve=None).delete() diff --git a/Back-End/GestionInscriptions/tasks.py b/Back-End/Subscriptions/tasks.py similarity index 82% rename from Back-End/GestionInscriptions/tasks.py rename to Back-End/Subscriptions/tasks.py index b43ef68..e89d2b7 100644 --- a/Back-End/GestionInscriptions/tasks.py +++ b/Back-End/Subscriptions/tasks.py @@ -1,8 +1,8 @@ # tasks.py from celery import shared_task from django.utils import timezone -from GestionInscriptions.automate import Automate_DI_Inscription, updateStateMachine -from .models import FicheInscription +from Subscriptions.automate import Automate_RF_Register, updateStateMachine +from .models import RegistrationForm from GestionMessagerie.models import Messagerie from N3wtSchool import settings, bdd import requests @@ -15,7 +15,7 @@ def check_for_signature_deadlines(): deadline = now - timezone.timedelta(days=settings.EXPIRATION_DI_NB_DAYS) # deadline = now - timezone.timedelta(seconds=settings.EXPIRATION_DI_NB_DAYS) - dossiers_en_attente = FicheInscription.objects.filter(etat=FicheInscription.EtatDossierInscription.DI_ENVOYE, dateMAJ__lt=deadline) + dossiers_en_attente = RegistrationForm.objects.filter(etat=RegistrationForm.RegistrationFormStatus.DI_ENVOYE, dateMAJ__lt=deadline) for dossier in dossiers_en_attente: send_notification(dossier) @@ -28,7 +28,7 @@ def send_notification(dossier): url = settings.URL_DJANGO + 'GestionMessagerie/message' - destinataires = dossier.eleve.profils.all() + destinataires = dossier.eleve.profiles.all() for destinataire in destinataires: message = { "objet": "[RELANCE]", diff --git a/Back-End/GestionInscriptions/templates/GestionInscriptions/ajouterFicheEleve.html b/Back-End/Subscriptions/templates/GestionInscriptions/ajouterFicheEleve.html similarity index 96% rename from Back-End/GestionInscriptions/templates/GestionInscriptions/ajouterFicheEleve.html rename to Back-End/Subscriptions/templates/GestionInscriptions/ajouterFicheEleve.html index b2b5a97..1d12537 100644 --- a/Back-End/GestionInscriptions/templates/GestionInscriptions/ajouterFicheEleve.html +++ b/Back-End/Subscriptions/templates/GestionInscriptions/ajouterFicheEleve.html @@ -3,7 +3,7 @@ {% block content %}

Création d'une nouvelle fiche d'inscription


-
+ {% csrf_token %}
  • ELEVE
  • diff --git a/Back-End/GestionInscriptions/templates/GestionInscriptions/configure.html b/Back-End/Subscriptions/templates/GestionInscriptions/configure.html similarity index 87% rename from Back-End/GestionInscriptions/templates/GestionInscriptions/configure.html rename to Back-End/Subscriptions/templates/GestionInscriptions/configure.html index c719143..00b59c2 100644 --- a/Back-End/GestionInscriptions/templates/GestionInscriptions/configure.html +++ b/Back-End/Subscriptions/templates/GestionInscriptions/configure.html @@ -2,7 +2,7 @@ {% block content %}

    Configuration des dossiers d'inscriptions


    - + {% csrf_token %}
      diff --git a/Back-End/GestionInscriptions/templates/GestionInscriptions/creationDossier.html b/Back-End/Subscriptions/templates/GestionInscriptions/creationDossier.html similarity index 97% rename from Back-End/GestionInscriptions/templates/GestionInscriptions/creationDossier.html rename to Back-End/Subscriptions/templates/GestionInscriptions/creationDossier.html index 50f3656..860e379 100644 --- a/Back-End/GestionInscriptions/templates/GestionInscriptions/creationDossier.html +++ b/Back-End/Subscriptions/templates/GestionInscriptions/creationDossier.html @@ -2,9 +2,9 @@ {% block content %}

      Création du dossier d'inscription


      - + {% csrf_token %} - {% with responsable=eleve.getResponsablePrincipal %} + {% with responsable=eleve.getMainGuardian %}
      • ELEVE
      • diff --git a/Back-End/GestionInscriptions/templates/GestionInscriptions/editStudent.html b/Back-End/Subscriptions/templates/GestionInscriptions/editStudent.html similarity index 92% rename from Back-End/GestionInscriptions/templates/GestionInscriptions/editStudent.html rename to Back-End/Subscriptions/templates/GestionInscriptions/editStudent.html index f33652e..4196cc5 100644 --- a/Back-End/GestionInscriptions/templates/GestionInscriptions/editStudent.html +++ b/Back-End/Subscriptions/templates/GestionInscriptions/editStudent.html @@ -2,9 +2,9 @@ {% block content %}

        Edition d'une fiche d'inscription


        - + {% csrf_token %} - {% with responsable=eleve.getResponsablePrincipal %} + {% with responsable=eleve.getMainGuardian %}
          diff --git a/Back-End/GestionInscriptions/templates/GestionInscriptions/index.html b/Back-End/Subscriptions/templates/GestionInscriptions/index.html similarity index 88% rename from Back-End/GestionInscriptions/templates/GestionInscriptions/index.html rename to Back-End/Subscriptions/templates/GestionInscriptions/index.html index c428315..fcd5c8e 100644 --- a/Back-End/GestionInscriptions/templates/GestionInscriptions/index.html +++ b/Back-End/Subscriptions/templates/GestionInscriptions/index.html @@ -47,22 +47,22 @@ - {% for ficheInscription in ficheInscriptions_list %} - {% with eleve=ficheInscription.eleve %} - {% with responsable=eleve.getResponsablePrincipal %} - {% with fichiers=ficheInscription|recupereFichiersDossierInscription %} + {% for registerForm in ficheInscriptions_list %} + {% with eleve=registerForm.eleve %} + {% with responsable=eleve.getMainGuardian %} + {% with fichiers=registerForm|recupereFichiersDossierInscription %} {{ eleve.nom }} {{ eleve.prenom }} {{ responsable.mail }} {{ responsable.telephone }} - {{ ficheInscription.dateMAJ }} + {{ registerForm.dateMAJ }} - {% if ficheInscription.etat == 0 %} + {% if registerForm.etat == 0 %} Créé - {% elif ficheInscription.etat == 1 %} + {% elif registerForm.etat == 1 %} Envoyé - {% elif ficheInscription.etat == 2 %} + {% elif registerForm.etat == 2 %} En Validation {% else %} Validé @@ -74,7 +74,7 @@ {% endfor %} - + diff --git a/Back-End/GestionInscriptions/templates/base.html b/Back-End/Subscriptions/templates/base.html similarity index 100% rename from Back-End/GestionInscriptions/templates/base.html rename to Back-End/Subscriptions/templates/base.html diff --git a/Back-End/Subscriptions/templates/emails/inscription.html b/Back-End/Subscriptions/templates/emails/inscription.html new file mode 100644 index 0000000..fdd4cdd --- /dev/null +++ b/Back-End/Subscriptions/templates/emails/inscription.html @@ -0,0 +1,52 @@ + + + + + Confirmation d'inscription + + + +
          +
          +

          Confirmation d'inscription

          +
          +
          +

          Bonjour,

          +

          Nous vous confirmons la réception de votre demande d'inscription, vous trouverez ci-joint le lien vers la page d'authentification : {{BASE_URL}}/users/login

          +

          S'il s'agit de votre première connexion, veuillez procéder à l'activation de votre compte à cette url : {{BASE_URL}}/users/subscribe

          +

          votre identifiant est : {{ email }}

          +

          Merci de compléter votre dossier d'inscription en suivant les instructions fournies.

          +

          Cordialement,

          +

          L'équipe N3wt School

          +
          + +
          + + \ No newline at end of file diff --git a/Back-End/Subscriptions/templates/emails/resetPassword.html b/Back-End/Subscriptions/templates/emails/resetPassword.html new file mode 100644 index 0000000..6646ddf --- /dev/null +++ b/Back-End/Subscriptions/templates/emails/resetPassword.html @@ -0,0 +1,49 @@ + + + + + Réinitialisation du Mot de passe + + + +
          +
          +

          Réinitialisation du Mot de passe

          +
          +
          +

          Bonjour,

          +

          Vous trouverez ci-joint le lien pour réinitialiser votre mot de passe : {{BASE_URL}}/users/password/reset?uuid={{code}}

          +

          Cordialement,

          +

          L'équipe N3wt School

          +
          + +
          + + \ No newline at end of file diff --git a/Back-End/Subscriptions/templates/pdfs/dossier_inscription.html b/Back-End/Subscriptions/templates/pdfs/dossier_inscription.html new file mode 100644 index 0000000..cb01e82 --- /dev/null +++ b/Back-End/Subscriptions/templates/pdfs/dossier_inscription.html @@ -0,0 +1,207 @@ + + + + + {{ pdf_title }} + + + + {% load myTemplateTag %} +
          +
          +

          {{ pdf_title }}

          +
          + +
          +

          ÉLÈVE

          + {% with level=student|getStudentLevel %} + {% with gender=student|getStudentGender %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          NOM :{{ student.last_name }}PRÉNOM :{{ student.first_name }}
          ADRESSE :{{ student.address }}
          GENRE :{{ gender }}NÉ(E) LE :{{ student.birth_date }}
          À :{{ student.birth_place }} ({{ student.birth_postal_code }})
          NATIONALITÉ :{{ student.nationality }}NIVEAU :{{ level }}
          MÉDECIN TRAITANT :{{ student.attending_physician }}
          + {% endwith %} + {% endwith %} +
          + +
          +

          RESPONSABLES

          + {% with guardians=student.getGuardians %} + {% for guardian in guardians%} +
          +

          Responsable {{ forloop.counter }}

          + + + + + + + + + + + + + + + + + + + + + + + +
          NOM :{{ guardian.last_name }}PRÉNOM :{{ guardian.first_name }}
          ADRESSE :{{ guardian.address }}
          NÉ(E) LE :{{ guardian.birth_date }}EMAIL :{{ guardian.email }}
          TÉLÉPHONE :{{ guardian.phone }}PROFESSION :{{ guardian.profession }}
          +
          + {% endfor %} + {% endwith %} +
          + +
          +

          FRATRIE

          + {% with siblings=student.getGuardians %} + {% for sibling in siblings%} +
          +

          Frère/Sœur {{ forloop.counter }}

          + + + + + + + + + + + +
          NOM :{{ sibling.last_name }}PRÉNOM :{{ sibling.first_name }}
          NÉ(E) LE :{{ sibling.birth_date }}
          +
          + {% endfor %} + {% endwith %} +
          + +
          +

          MODALITÉS DE PAIEMENT

          + {% with paymentMethod=student|getStudentPaymentMethod %} +

          {{ paymentMethod }}

          + {% endwith %} +
          + +
          + Fait le {{ signatureDate }} à {{ signatureTime }} +
          +
          + + \ No newline at end of file diff --git a/Back-End/Subscriptions/templatetags/myTemplateTag.py b/Back-End/Subscriptions/templatetags/myTemplateTag.py new file mode 100644 index 0000000..67db926 --- /dev/null +++ b/Back-End/Subscriptions/templatetags/myTemplateTag.py @@ -0,0 +1,18 @@ +from Subscriptions.models import RegistrationForm, Student +from django import template +register = template.Library() + +@register.filter +def getStudentPaymentMethod(pk): + registerForm = RegistrationForm.objects.get(student=pk) + return Student.PaymentMethod(int(registerForm.student.payment_method)).label + +@register.filter +def getStudentLevel(pk): + registerForm = RegistrationForm.objects.get(student=pk) + return Student.StudentLevel(int(registerForm.student.level)).label + +@register.filter +def getStudentGender(pk): + registerForm = RegistrationForm.objects.get(student=pk) + return Student.StudentGender(int(registerForm.student.gender)).label \ No newline at end of file diff --git a/Back-End/Subscriptions/urls.py b/Back-End/Subscriptions/urls.py new file mode 100644 index 0000000..e8e705b --- /dev/null +++ b/Back-End/Subscriptions/urls.py @@ -0,0 +1,41 @@ +from django.urls import path, re_path + +from . import views + +# RF +from .views import RegisterFormView, RegisterFormWithIdView, send, resend, archive +# SubClasses +from .views import StudentView, GuardianView, ChildrenListView, StudentListView +# Files +from .views import RegistrationTemplateMasterView, RegistrationTemplateMasterSimpleView, RegistrationTemplateView, RegistrationTemplateSimpleView +from .views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group +from .views import registration_file_views, get_templates_by_rf + +urlpatterns = [ + re_path(r'^registerForms/(?P[0-9]+)/archive$', archive, name="archive"), + re_path(r'^registerForms/(?P[0-9]+)/resend$', resend, name="resend"), + re_path(r'^registerForms/(?P[0-9]+)/send$', send, name="send"), + re_path(r'^registerForms/(?P[0-9]+)$', RegisterFormWithIdView.as_view(), name="registerForm"), + re_path(r'^registerForms/(?P[0-9]+)/templates$', get_templates_by_rf, name="get_templates_by_rf"), + re_path(r'^registerForms$', RegisterFormView.as_view(), name="registerForms"), + + # Page INSCRIPTION - Liste des élèves + re_path(r'^students$', StudentListView.as_view(), name="students"), + # Page de formulaire d'inscription - ELEVE + re_path(r'^students/(?P[0-9]+)$', StudentView.as_view(), name="students"), + # Page PARENT - Liste des children + re_path(r'^children/(?P[0-9]+)$', ChildrenListView.as_view(), name="children"), + + # Page de formulaire d'inscription - RESPONSABLE + re_path(r'^lastGuardianId$', GuardianView.as_view(), name="lastGuardianId"), + + re_path(r'^registrationFileGroups/(?P[0-9]+)$', RegistrationFileGroupSimpleView.as_view(), name='registrationFileGroupDetail'), + re_path(r'^registrationFileGroups/(?P[0-9]+)/templates$', get_registration_files_by_group, name="get_registration_files_by_group"), + re_path(r'^registrationFileGroups$', RegistrationFileGroupView.as_view(), name='registrationFileGroups'), + + re_path(r'^registrationTemplateMasters/(?P[0-9]+)$', RegistrationTemplateMasterSimpleView.as_view(), name='registrationTemplateMasters'), + re_path(r'^registrationTemplateMasters$', RegistrationTemplateMasterView.as_view(), name='registrationTemplateMasters'), + + re_path(r'^registrationTemplates/(?P[0-9]+)$', RegistrationTemplateSimpleView.as_view(), name='registrationTemplates'), + re_path(r'^registrationTemplates$', RegistrationTemplateView.as_view(), name="registrationTemplates"), +] \ No newline at end of file diff --git a/Back-End/Subscriptions/util.py b/Back-End/Subscriptions/util.py new file mode 100644 index 0000000..622bac5 --- /dev/null +++ b/Back-End/Subscriptions/util.py @@ -0,0 +1,149 @@ +from django.shortcuts import render,get_object_or_404,get_list_or_404 +from .models import RegistrationForm, Student, Guardian, Sibling +import time +from datetime import date, datetime, timedelta +from zoneinfo import ZoneInfo +from django.conf import settings +from N3wtSchool import renderers +from N3wtSchool import bdd + +from io import BytesIO +from django.core.files import File +from pathlib import Path +import os +from enum import Enum + +import random +import string +from rest_framework.parsers import JSONParser +import pymupdf + +def recupereListeFichesInscription(): + """ + Retourne la liste complète des fiches d’inscription. + """ + context = { + "ficheInscriptions_list": bdd.getAllObjects(RegistrationForm), + } + return context + +def recupereListeFichesInscriptionEnAttenteSEPA(): + """ + Retourne les fiches d’inscription avec paiement SEPA en attente. + """ + ficheInscriptionsSEPA_list = RegistrationForm.objects.filter(modePaiement="Prélèvement SEPA").filter(etat=RegistrationForm.RegistrationFormStatus['SEPA_ENVOYE']) + return ficheInscriptionsSEPA_list + +def _now(): + """ + Retourne la date et l’heure en cours, avec fuseau. + """ + return datetime.now(ZoneInfo(settings.TZ_APPLI)) + +def convertToStr(dateValue, dateFormat): + """ + Convertit un objet datetime en chaîne selon un format donné. + """ + return dateValue.strftime(dateFormat) + +def convertToDate(date_time): + """ + Convertit une chaîne en objet datetime selon le format '%d-%m-%Y %H:%M'. + """ + format = '%d-%m-%Y %H:%M' + datetime_str = datetime.strptime(date_time, format) + + return datetime_str + +def convertTelephone(telephoneValue, separator='-'): + """ + Reformate un numéro de téléphone en y insérant un séparateur donné. + """ + return f"{telephoneValue[:2]}{separator}{telephoneValue[2:4]}{separator}{telephoneValue[4:6]}{separator}{telephoneValue[6:8]}{separator}{telephoneValue[8:10]}" + +def genereRandomCode(length): + """ + Génère un code aléatoire de longueur spécifiée. + """ + return ''.join(random.choice(string.ascii_letters) for i in range(length)) + +def calculeDatePeremption(_start, nbDays): + """ + Calcule la date de fin à partir d’un point de départ et d’un nombre de jours. + """ + return convertToStr(_start + timedelta(days=nbDays), settings.DATE_FORMAT) + +# Fonction permettant de retourner la valeur du QueryDict +# QueryDict [ index ] -> Dernière valeur d'une liste +# dict (QueryDict [ index ]) -> Toutes les valeurs de la liste +def _(liste): + """ + Retourne la première valeur d’une liste extraite d’un QueryDict. + """ + return liste[0] + +def getArgFromRequest(_argument, _request): + """ + Extrait la valeur d’un argument depuis la requête (JSON). + """ + resultat = None + data=JSONParser().parse(_request) + resultat = data[_argument] + return resultat + +def merge_files_pdf(filenames, output_filename): + """ + Fusionne plusieurs fichiers PDF en un seul document. + Vérifie l'existence des fichiers sources avant la fusion. + """ + merger = pymupdf.open() + valid_files = [] + + # Vérifier l'existence des fichiers et ne garder que ceux qui existent + for filename in filenames: + if os.path.exists(filename): + valid_files.append(filename) + + # Fusionner les fichiers valides + for filename in valid_files: + merger.insert_file(filename) + + # S'assurer que le dossier de destination existe + os.makedirs(os.path.dirname(output_filename), exist_ok=True) + + # Sauvegarder le fichier fusionné + merger.save(output_filename) + merger.close() + + return output_filename + +def rfToPDF(registerForm, filename): + """ + Génère le PDF d'un dossier d'inscription et l'associe au RegistrationForm. + """ + data = { + 'pdf_title': f"Dossier d'inscription de {registerForm.student.first_name}", + 'signatureDate': convertToStr(_now(), '%d-%m-%Y'), + 'signatureTime': convertToStr(_now(), '%H:%M'), + 'student': registerForm.student, + } + + # S'assurer que le dossier parent existe + os.makedirs(os.path.dirname(filename), exist_ok=True) + + # Générer le PDF + pdf = renderers.render_to_pdf('pdfs/dossier_inscription.html', data) + + # Écrire le fichier directement + with open(filename, 'wb') as f: + f.write(pdf.content) + + # Mettre à jour le champ registration_file du registerForm + with open(filename, 'rb') as f: + registerForm.registration_file.save( + os.path.basename(filename), + File(f), + save=True + ) + + return registerForm.registration_file \ No newline at end of file diff --git a/Back-End/Subscriptions/views/__init__.py b/Back-End/Subscriptions/views/__init__.py new file mode 100644 index 0000000..7fbf22e --- /dev/null +++ b/Back-End/Subscriptions/views/__init__.py @@ -0,0 +1,25 @@ +from .register_form_views import RegisterFormView, RegisterFormWithIdView, send, resend, archive, get_templates_by_rf +from .registration_file_views import RegistrationTemplateMasterView, RegistrationTemplateMasterSimpleView, RegistrationTemplateView, RegistrationTemplateSimpleView +from .registration_file_group_views import RegistrationFileGroupView, RegistrationFileGroupSimpleView, get_registration_files_by_group +from .student_views import StudentView, StudentListView, ChildrenListView +from .guardian_views import GuardianView + +__all__ = [ + 'RegisterFormView', + 'RegisterFormWithIdView', + 'send', + 'resend', + 'archive', + 'RegistrationTemplateView', + 'RegistrationTemplateSimpleView', + 'RegistrationTemplateMasterView', + 'RegistrationTemplateMasterSimpleView', + 'RegistrationFileGroupView', + 'RegistrationFileGroupSimpleView', + 'get_registration_files_by_group', + 'get_templates_by_rf', + 'StudentView', + 'StudentListView', + 'ChildrenListView', + 'GuardianView', +] diff --git a/Back-End/Subscriptions/views/guardian_views.py b/Back-End/Subscriptions/views/guardian_views.py new file mode 100644 index 0000000..13e0912 --- /dev/null +++ b/Back-End/Subscriptions/views/guardian_views.py @@ -0,0 +1,34 @@ +from django.http.response import JsonResponse +from rest_framework.views import APIView +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from Subscriptions.models import Guardian +from N3wtSchool import bdd + +class GuardianView(APIView): + """ + Gestion des responsables légaux. + """ + + @swagger_auto_schema( + operation_description="Récupère le dernier ID de responsable légal créé", + operation_summary="Récupèrer le dernier ID de responsable légal créé", + responses={ + 200: openapi.Response( + description="Dernier ID du responsable légal", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'lastid': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Dernier ID créé" + ) + } + ) + ) + } + ) + def get(self, request): + lastGuardian = bdd.getLastId(Guardian) + return JsonResponse({"lastid":lastGuardian}, safe=False) diff --git a/Back-End/Subscriptions/views/register_form_views.py b/Back-End/Subscriptions/views/register_form_views.py new file mode 100644 index 0000000..1cecb01 --- /dev/null +++ b/Back-End/Subscriptions/views/register_form_views.py @@ -0,0 +1,400 @@ +from django.http.response import JsonResponse +from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect +from django.utils.decorators import method_decorator +from rest_framework.parsers import JSONParser +from rest_framework.views import APIView +from rest_framework.decorators import action, api_view +from rest_framework import status +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +import json +import os +from django.core.files import File + +import Subscriptions.mailManager as mailer +import Subscriptions.util as util + +from Subscriptions.serializers import RegistrationFormSerializer +from Subscriptions.pagination import CustomPagination +from Subscriptions.models import Student, Guardian, RegistrationForm, RegistrationTemplate, RegistrationFileGroup +from Subscriptions.automate import updateStateMachine + +from N3wtSchool import settings, bdd + +import logging +logger = logging.getLogger(__name__) + +# /Subscriptions/registerForms +class RegisterFormView(APIView): + """ + Gère la liste des dossiers d’inscription, lecture et création. + """ + pagination_class = CustomPagination + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter('filter', openapi.IN_QUERY, description="filtre", type=openapi.TYPE_STRING, enum=['pending', 'archived', 'subscribed'], required=True), + openapi.Parameter('search', openapi.IN_QUERY, description="search", type=openapi.TYPE_STRING, required=False), + openapi.Parameter('page_size', openapi.IN_QUERY, description="limite de page lors de la pagination", type=openapi.TYPE_INTEGER, required=False), + openapi.Parameter('establishment_id', openapi.IN_QUERY, description="ID de l'établissement", type=openapi.TYPE_INTEGER, required=True), + ], + responses={200: RegistrationFormSerializer(many=True)}, + operation_description="Récupère les dossier d'inscriptions en fonction du filtre passé.", + operation_summary="Récupérer les dossier d'inscriptions", + examples={ + "application/json": [ + { + "id": 1, + "student": { + "id": 1, + "first_name": "John", + "last_name": "Doe", + "date_of_birth": "2010-01-01" + }, + "status": "pending", + "last_update": "10-02-2025 10:00" + }, + { + "id": 2, + "student": { + "id": 2, + "first_name": "Jane", + "last_name": "Doe", + "date_of_birth": "2011-02-02" + }, + "status": "archived", + "last_update": "09-02-2025 09:00" + } + ] + } + ) + def get(self, request): + """ + Récupère les fiches d'inscriptions en fonction du filtre passé. + """ + # Récupération des paramètres + filter = request.GET.get('filter', '').strip() + search = request.GET.get('search', '').strip() + page_size = request.GET.get('page_size', None) + establishment_id = request.GET.get('establishment_id', None) + + # Gestion du page_size + if page_size is not None: + try: + page_size = int(page_size) + except ValueError: + page_size = settings.NB_RESULT_PER_PAGE + + # Récupérer les dossier d'inscriptions en fonction du filtre + registerForms_List = None + if filter == 'pending': + exclude_states = [RegistrationForm.RegistrationFormStatus.RF_VALIDATED, RegistrationForm.RegistrationFormStatus.RF_ARCHIVED] + registerForms_List = bdd.searchObjects(RegistrationForm, search, _excludeStates=exclude_states) + elif filter == 'archived': + registerForms_List = bdd.getObjects(RegistrationForm, 'status', RegistrationForm.RegistrationFormStatus.RF_ARCHIVED) + elif filter == 'subscribed': + registerForms_List = bdd.getObjects(RegistrationForm, 'status', RegistrationForm.RegistrationFormStatus.RF_VALIDATED) + else: + registerForms_List = None + + if registerForms_List: + print(f'filtrate sur lestablishment : {establishment_id}') + registerForms_List = registerForms_List.filter(establishment=establishment_id) + + if not registerForms_List: + return JsonResponse({'error': 'aucune donnée trouvée', 'count': 0}, safe=False) + + # Pagination + paginator = self.pagination_class() + page = paginator.paginate_queryset(registerForms_List, request) + if page is not None: + registerForms_serializer = RegistrationFormSerializer(page, many=True) + response_data = paginator.get_paginated_response(registerForms_serializer.data) + return JsonResponse(response_data, safe=False) + + return JsonResponse({'error': 'aucune donnée trouvée', 'count': 0}, safe=False) + + @swagger_auto_schema( + request_body=RegistrationFormSerializer, + responses={200: RegistrationFormSerializer()}, + operation_description="Crée un dossier d'inscription.", + operation_summary="Créer un dossier d'inscription", + examples={ + "application/json": { + "student": { + "id": 1, + "first_name": "John", + "last_name": "Doe", + "date_of_birth": "2010-01-01" + }, + "status": "pending", + "last_update": "10-02-2025 10:00", + "codeLienInscription": "ABC123XYZ456" + } + } + ) + @method_decorator(csrf_protect, name='dispatch') + @method_decorator(ensure_csrf_cookie, name='dispatch') + def post(self, request): + """ + Crée un dossier d'inscription. + """ + regiterFormData = request.data.copy() + logger.info(f"Création d'un dossier d'inscription {request}") + # Ajout de la date de mise à jour + regiterFormData["last_update"] = util.convertToStr(util._now(), '%d-%m-%Y %H:%M') + # Ajout du code d'inscription + code = util.genereRandomCode(12) + regiterFormData["codeLienInscription"] = code + + guardiansId = regiterFormData.pop('idGuardians', []) + registerForm_serializer = RegistrationFormSerializer(data=regiterFormData) + fileGroupId = regiterFormData.pop('fileGroup', None) + + if registerForm_serializer.is_valid(): + di = registerForm_serializer.save() + + # Mise à jour de l'automate + updateStateMachine(di, 'creationDI') + + # Récupération du reponsable associé + for guardianId in guardiansId: + guardian = Guardian.objects.get(id=guardianId) + di.student.guardians.add(guardian) + di.save() + if fileGroupId: + di.fileGroup = RegistrationFileGroup.objects.get(id=fileGroupId) + di.save() + + return JsonResponse(registerForm_serializer.data, safe=False) + else: + logger.error(f"Erreur lors de la validation des données {regiterFormData}") + + return JsonResponse(registerForm_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + +# /Subscriptions/registerForms/{id} +class RegisterFormWithIdView(APIView): + """ + Gère la lecture, création, modification et suppression d’un dossier d’inscription. + """ + pagination_class = CustomPagination + + @swagger_auto_schema( + responses={200: RegistrationFormSerializer()}, + operation_description="Récupère un dossier d'inscription donné.", + operation_summary="Récupérer un dossier d'inscription", + examples={ + "application/json": { + "id": 1, + "student": { + "id": 1, + "first_name": "John", + "last_name": "Doe", + "date_of_birth": "2010-01-01" + }, + } + } + ) + def get(self, request, id): + """ + Récupère un dossier d'inscription donné. + """ + registerForm = bdd.getObject(RegistrationForm, "student__id", id) + if registerForm is None: + return JsonResponse({"errorMessage":'Le dossier d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + registerForm_serializer = RegistrationFormSerializer(registerForm) + return JsonResponse(registerForm_serializer.data, safe=False) + + @swagger_auto_schema( + request_body=RegistrationFormSerializer, + responses={200: RegistrationFormSerializer()}, + operation_description="Modifie un dossier d'inscription donné.", + operation_summary="Modifier un dossier d'inscription", + examples={ + "application/json": { + "id": 1, + "student": { + "id": 1, + "first_name": "John", + "last_name": "Doe", + "date_of_birth": "2010-01-01" + }, + "status": "under_review", + "last_update": "10-02-2025 10:00" + } + } + ) + @method_decorator(csrf_protect, name='dispatch') + @method_decorator(ensure_csrf_cookie, name='dispatch') + def put(self, request, id): + """ + Modifie un dossier d'inscription donné. + """ + studentForm_data = JSONParser().parse(request) + _status = studentForm_data.pop('status', 0) + studentForm_data["last_update"] = str(util.convertToStr(util._now(), '%d-%m-%Y %H:%M')) + registerForm = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id) + + if _status == RegistrationForm.RegistrationFormStatus.RF_UNDER_REVIEW: + try: + # Génération de la fiche d'inscription au format PDF + base_dir = f"data/registration_files/dossier_rf_{registerForm.pk}" + os.makedirs(base_dir, exist_ok=True) + + # Fichier PDF initial + initial_pdf = f"{base_dir}/rf_{registerForm.student.last_name}_{registerForm.student.first_name}.pdf" + registerForm.registration_file = util.rfToPDF(registerForm, initial_pdf) + registerForm.save() + + # Récupération des fichiers d'inscription + fileNames = RegistrationTemplate.get_files_from_rf(registerForm.pk) + if registerForm.registration_file: + fileNames.insert(0, registerForm.registration_file.path) + + # Création du fichier PDF Fusionné + merged_pdf = f"{base_dir}/dossier_complet_{registerForm.pk}.pdf" + util.merge_files_pdf(fileNames, merged_pdf) + + # Mise à jour du champ registration_file avec le fichier fusionné + with open(merged_pdf, 'rb') as f: + registerForm.registration_file.save( + os.path.basename(merged_pdf), + File(f), + save=True + ) + + # Mise à jour de l'automate + updateStateMachine(registerForm, 'saisiDI') + except Exception as e: + return JsonResponse({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + elif _status == RegistrationForm.RegistrationFormStatus.RF_VALIDATED: + # L'école a validé le dossier d'inscription + # Mise à jour de l'automate + updateStateMachine(registerForm, 'valideDI') + + studentForm_serializer = RegistrationFormSerializer(registerForm, data=studentForm_data) + if studentForm_serializer.is_valid(): + studentForm_serializer.save() + return JsonResponse(studentForm_serializer.data, safe=False) + + return JsonResponse(studentForm_serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + responses={204: 'No Content'}, + operation_description="Supprime un dossier d'inscription donné.", + operation_summary="Supprimer un dossier d'inscription" + ) + @method_decorator(csrf_protect, name='dispatch') + @method_decorator(ensure_csrf_cookie, name='dispatch') + def delete(self, request, id): + """ + Supprime un dossier d'inscription donné. + """ + register_form = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id) + if register_form != None: + student = register_form.student + student.guardians.clear() + student.profiles.clear() + student.registration_files.clear() + student.delete() + + return JsonResponse("La suppression du dossier a été effectuée avec succès", safe=False) + + return JsonResponse({"errorMessage":'Aucun dossier d\'inscription rattaché à l\'élève'}, safe=False, status=status.HTTP_404_NOT_FOUND) + +@swagger_auto_schema( + method='get', + responses={200: openapi.Response('Success', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING) + } + ))}, + operation_description="Envoie le dossier d'inscription par e-mail", + operation_summary="Envoyer un dossier d'inscription" +) +@api_view(['GET']) +def send(request,id): + """Envoie le dossier d'inscription par e-mail.""" + register_form = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id) + if register_form != None: + student = register_form.student + guardian = student.getMainGuardian() + email = guardian.profile_role.profile.email + errorMessage = mailer.sendRegisterForm(email, register_form.establishment.pk) + if errorMessage == '': + register_form.last_update=util.convertToStr(util._now(), '%d-%m-%Y %H:%M') + updateStateMachine(register_form, 'envoiDI') + return JsonResponse({"message": f"Le dossier d'inscription a bien été envoyé à l'addresse {email}"}, safe=False) + return JsonResponse({"errorMessage":errorMessage}, safe=False, status=status.HTTP_400_BAD_REQUEST) + return JsonResponse({"errorMessage":'Dossier d\'inscription non trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + +@swagger_auto_schema( + method='get', + responses={200: openapi.Response('Success', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING) + } + ))}, + operation_description="Archive le dossier d'inscription", + operation_summary="Archiver un dossier d'inscription" +) +@api_view(['GET']) +def archive(request,id): + """Archive le dossier d'inscription.""" + register_form = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id) + if register_form != None: + register_form.last_update=util.convertToStr(util._now(), '%d-%m-%Y %H:%M') + updateStateMachine(register_form, 'archiveDI') + return JsonResponse({"message": "Le dossier a été archivé avec succès"}, safe=False) + return JsonResponse({"errorMessage":'Dossier d\'inscription non trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + +@swagger_auto_schema( + method='get', + responses={200: openapi.Response('Success', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING) + } + ))}, + operation_description="Relance un dossier d'inscription par e-mail", + operation_summary="Relancer un dossier d'inscription" +) +@api_view(['GET']) +def resend(request,id): + """Relance un dossier d'inscription par e-mail.""" + register_form = bdd.getObject(_objectName=RegistrationForm, _columnName='student__id', _value=id) + if register_form != None: + student = register_form.student + guardian = student.getMainGuardian() + email = guardian.email + errorMessage = mailer.envoieRelanceDossierInscription(email, register_form.codeLienInscription) + if errorMessage == '': + register_form.status=RegistrationForm.RegistrationFormStatus.RF_SENT + register_form.last_update=util.convertToStr(util._now(), '%d-%m-%Y %H:%M') + register_form.save() + return JsonResponse({"message": f"Le dossier a été renvoyé à l'adresse {email}"}, safe=False) + return JsonResponse({"errorMessage":errorMessage}, safe=False, status=status.HTTP_400_BAD_REQUEST) + return JsonResponse({"errorMessage":'Dossier d\'inscription non trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + +@swagger_auto_schema( + method='get', + responses={200: openapi.Response('Success', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING) + } + ))}, + operation_description="Récupère les fichiers à signer d'un dossier d'inscription donné", + operation_summary="Récupérer les fichiers à signer d'un dossier d'inscription donné" +) +@api_view(['GET']) +def get_templates_by_rf(request, id): + try: + templates = RegistrationTemplate.objects.filter(registration_form=id) + templates_data = list(templates.values()) + return JsonResponse(templates_data, safe=False) + except RegistrationFileGroup.DoesNotExist: + return JsonResponse({'error': 'Le groupe de fichiers n\'a pas été trouvé'}, status=404) \ No newline at end of file diff --git a/Back-End/Subscriptions/views/registration_file_group_views.py b/Back-End/Subscriptions/views/registration_file_group_views.py new file mode 100644 index 0000000..cce88e9 --- /dev/null +++ b/Back-End/Subscriptions/views/registration_file_group_views.py @@ -0,0 +1,131 @@ +from django.http.response import JsonResponse +from drf_yasg.utils import swagger_auto_schema +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework import status +from rest_framework.decorators import action, api_view +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from Subscriptions.serializers import RegistrationFileGroupSerializer +from Subscriptions.models import RegistrationFileGroup, RegistrationTemplateMaster +from N3wtSchool import bdd + +class RegistrationFileGroupView(APIView): + @swagger_auto_schema( + operation_description="Récupère tous les groupes de fichiers d'inscription", + responses={200: RegistrationFileGroupSerializer(many=True)} + ) + def get(self, request): + """ + Récupère tous les groupes de fichiers d'inscription. + """ + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + groups = RegistrationFileGroup.objects.all() + if groups: + groups = groups.filter(establishment_id=establishment_id).distinct() + serializer = RegistrationFileGroupSerializer(groups, many=True) + return Response(serializer.data) + + @swagger_auto_schema( + operation_description="Crée un nouveau groupe de fichiers d'inscription", + request_body=RegistrationFileGroupSerializer, + responses={ + 201: RegistrationFileGroupSerializer, + 400: "Données invalides" + } + ) + def post(self, request): + """ + Crée un nouveau groupe de fichiers d'inscription. + """ + serializer = RegistrationFileGroupSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class RegistrationFileGroupSimpleView(APIView): + @swagger_auto_schema( + operation_description="Récupère un groupe de fichiers d'inscription spécifique", + responses={ + 200: RegistrationFileGroupSerializer, + 404: "Groupe non trouvé" + } + ) + def get(self, request, id): + """ + Récupère un groupe de fichiers d'inscription spécifique. + """ + group = bdd.getObject(_objectName=RegistrationFileGroup, _columnName='id', _value=id) + if group is None: + return JsonResponse({"errorMessage": "Le groupe de fichiers n'a pas été trouvé"}, + status=status.HTTP_404_NOT_FOUND) + serializer = RegistrationFileGroupSerializer(group) + return JsonResponse(serializer.data) + + @swagger_auto_schema( + operation_description="Met à jour un groupe de fichiers d'inscription", + request_body=RegistrationFileGroupSerializer, + responses={ + 200: RegistrationFileGroupSerializer, + 400: "Données invalides", + 404: "Groupe non trouvé" + } + ) + def put(self, request, id): + """ + Met à jour un groupe de fichiers d'inscription existant. + """ + group = bdd.getObject(_objectName=RegistrationFileGroup, _columnName='id', _value=id) + if group is None: + return JsonResponse({'erreur': "Le groupe de fichiers n'a pas été trouvé"}, + status=status.HTTP_404_NOT_FOUND) + serializer = RegistrationFileGroupSerializer(group, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_description="Supprime un groupe de fichiers d'inscription", + responses={ + 204: "Suppression réussie", + 404: "Groupe non trouvé" + } + ) + def delete(self, request, id): + """ + Supprime un groupe de fichiers d'inscription. + """ + group = bdd.getObject(_objectName=RegistrationFileGroup, _columnName='id', _value=id) + if group is not None: + group.delete() + return JsonResponse({'message': 'La suppression du groupe a été effectuée avec succès'}, + status=status.HTTP_204_NO_CONTENT) + return JsonResponse({'erreur': "Le groupe de fichiers n'a pas été trouvé"}, + status=status.HTTP_404_NOT_FOUND) + +@swagger_auto_schema( + method='get', + responses={200: openapi.Response('Success', schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING) + } + ))}, + operation_description="Récupère les fichiers d'inscription d'un groupe donné", + operation_summary="Récupèrer les fichiers d'inscription d'un groupe donné" +) +@api_view(['GET']) +def get_registration_files_by_group(request, id): + try: + group = RegistrationFileGroup.objects.get(id=id) + templateMasters = RegistrationTemplateMaster.objects.filter(groups=group) + templates_data = list(templateMasters.values()) + return JsonResponse(templates_data, safe=False) + except RegistrationFileGroup.DoesNotExist: + return JsonResponse({'error': 'Le groupe de fichiers n\'a pas été trouvé'}, status=404) \ No newline at end of file diff --git a/Back-End/Subscriptions/views/registration_file_views.py b/Back-End/Subscriptions/views/registration_file_views.py new file mode 100644 index 0000000..7df225a --- /dev/null +++ b/Back-End/Subscriptions/views/registration_file_views.py @@ -0,0 +1,159 @@ +from django.http.response import JsonResponse +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework import status + +from Subscriptions.serializers import RegistrationTemplateMasterSerializer, RegistrationTemplateSerializer +from Subscriptions.models import RegistrationTemplateMaster, RegistrationTemplate +from N3wtSchool import bdd + +class RegistrationTemplateMasterView(APIView): + @swagger_auto_schema( + operation_description="Récupère tous les masters de templates d'inscription", + responses={200: RegistrationTemplateMasterSerializer(many=True)} + ) + def get(self, request): + masters = RegistrationTemplateMaster.objects.all() + serializer = RegistrationTemplateMasterSerializer(masters, many=True) + return Response(serializer.data) + + @swagger_auto_schema( + operation_description="Crée un nouveau master de template d'inscription", + request_body=RegistrationTemplateMasterSerializer, + responses={ + 201: RegistrationTemplateMasterSerializer, + 400: "Données invalides" + } + ) + def post(self, request): + serializer = RegistrationTemplateMasterSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class RegistrationTemplateMasterSimpleView(APIView): + @swagger_auto_schema( + operation_description="Récupère un master de template d'inscription spécifique", + responses={ + 200: RegistrationTemplateMasterSerializer, + 404: "Master non trouvé" + } + ) + def get(self, request, id): + master = bdd.getObject(_objectName=RegistrationTemplateMaster, _columnName='id', _value=id) + if master is None: + return JsonResponse({"errorMessage":'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + serializer = RegistrationTemplateMasterSerializer(master) + return JsonResponse(serializer.data, safe=False) + + @swagger_auto_schema( + operation_description="Met à jour un master de template d'inscription existant", + request_body=RegistrationTemplateMasterSerializer, + responses={ + 200: RegistrationTemplateMasterSerializer, + 400: "Données invalides", + 404: "Master non trouvé" + } + ) + def put(self, request, id): + master = bdd.getObject(_objectName=RegistrationTemplateMaster, _columnName='id', _value=id) + if master is None: + return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + serializer = RegistrationTemplateMasterSerializer(master, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_description="Supprime un master de template d'inscription", + responses={ + 204: "Suppression réussie", + 404: "Master non trouvé" + } + ) + def delete(self, request, id): + master = bdd.getObject(_objectName=RegistrationTemplateMaster, _columnName='id', _value=id) + if master is not None: + master.delete() + return JsonResponse({'message': 'La suppression du master de template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT) + else: + return JsonResponse({'erreur': 'Le master de template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + +class RegistrationTemplateView(APIView): + @swagger_auto_schema( + operation_description="Récupère tous les templates d'inscription", + responses={200: RegistrationTemplateSerializer(many=True)} + ) + def get(self, request): + templates = RegistrationTemplate.objects.all() + serializer = RegistrationTemplateSerializer(templates, many=True) + return Response(serializer.data) + + @swagger_auto_schema( + operation_description="Crée un nouveau template d'inscription", + request_body=RegistrationTemplateSerializer, + responses={ + 201: RegistrationTemplateSerializer, + 400: "Données invalides" + } + ) + def post(self, request): + serializer = RegistrationTemplateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class RegistrationTemplateSimpleView(APIView): + @swagger_auto_schema( + operation_description="Récupère un template d'inscription spécifique", + responses={ + 200: RegistrationTemplateSerializer, + 404: "Template non trouvé" + } + ) + def get(self, request, id): + template = bdd.getObject(_objectName=RegistrationTemplate, _columnName='id', _value=id) + if template is None: + return JsonResponse({"errorMessage":'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + serializer = RegistrationTemplateSerializer(template) + return JsonResponse(serializer.data, safe=False) + + @swagger_auto_schema( + operation_description="Met à jour un template d'inscription existant", + request_body=RegistrationTemplateSerializer, + responses={ + 200: RegistrationTemplateSerializer, + 400: "Données invalides", + 404: "Template non trouvé" + } + ) + def put(self, request, id): + template = bdd.getObject(_objectName=RegistrationTemplate, _columnName='id', _value=id) + if template is None: + return JsonResponse({'erreur': 'Le template d\'inscription n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + serializer = RegistrationTemplateSerializer(template, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response({'message': 'Template mis à jour avec succès', 'data': serializer.data}, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @swagger_auto_schema( + operation_description="Supprime un template d'inscription", + responses={ + 204: "Suppression réussie", + 404: "Template non trouvé" + } + ) + def delete(self, request, id): + template = bdd.getObject(_objectName=RegistrationTemplate, _columnName='id', _value=id) + if template is not None: + template.delete() + return JsonResponse({'message': 'La suppression du template a été effectuée avec succès'}, safe=False, status=status.HTTP_204_NO_CONTENT) + else: + return JsonResponse({'erreur': 'Le template n\'a pas été trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) diff --git a/Back-End/Subscriptions/views/student_views.py b/Back-End/Subscriptions/views/student_views.py new file mode 100644 index 0000000..02cfed4 --- /dev/null +++ b/Back-End/Subscriptions/views/student_views.py @@ -0,0 +1,101 @@ +from django.http.response import JsonResponse +from rest_framework.views import APIView +from rest_framework import status +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from Subscriptions.serializers import StudentByRFCreationSerializer, RegistrationFormByParentSerializer, StudentSerializer +from Subscriptions.models import Student, RegistrationForm + +from N3wtSchool import bdd + +class StudentView(APIView): + """ + Gère la lecture d’un élève donné. + """ + @swagger_auto_schema( + operation_summary="Récupérer les informations d'un élève", + operation_description="Retourne les détails d'un élève spécifique à partir de son ID", + responses={ + 200: openapi.Response('Détails de l\'élève', StudentSerializer), + 404: openapi.Response('Élève non trouvé') + }, + manual_parameters=[ + openapi.Parameter( + 'id', openapi.IN_PATH, + description="ID de l'élève", + type=openapi.TYPE_INTEGER, + required=True + ) + ] + ) + def get(self, request, id): + student = bdd.getObject(_objectName=Student, _columnName='id', _value=id) + if student is None: + return JsonResponse({"errorMessage":'Aucun élève trouvé'}, safe=False, status=status.HTTP_404_NOT_FOUND) + student_serializer = StudentSerializer(student) + return JsonResponse(student_serializer.data, safe=False) + +# API utilisée pour la vue de création d'un DI +class StudentListView(APIView): + """ + Pour la vue de création d’un dossier d’inscription : liste les élèves disponibles. + """ + @swagger_auto_schema( + operation_summary="Lister tous les élèves", + operation_description="Retourne la liste de tous les élèves inscrits ou en cours d'inscription", + responses={ + 200: openapi.Response('Liste des élèves', StudentByRFCreationSerializer(many=True)) + }, + manual_parameters=[ + openapi.Parameter( + 'establishment_id', openapi.IN_QUERY, + description="ID de l'établissement", + type=openapi.TYPE_INTEGER, + required=True + ) + ] + ) + # Récupération de la liste des élèves inscrits ou en cours d'inscriptions + def get(self, request): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + students = Student.objects.filter(registrationform__establishment_id=establishment_id) + students_serializer = StudentByRFCreationSerializer(students, many=True) + return JsonResponse(students_serializer.data, safe=False) + + +# API utilisée pour la vue parent +class ChildrenListView(APIView): + """ + Pour la vue parent : liste les élèves rattachés à un profil donné. + """ + @swagger_auto_schema( + operation_summary="Lister les élèves d'un parent", + operation_description="Retourne la liste des élèves associés à un profil parent spécifique", + responses={ + 200: openapi.Response('Liste des élèves du parent', RegistrationFormByParentSerializer(many=True)) + }, + manual_parameters=[ + openapi.Parameter( + 'id', openapi.IN_PATH, + description="ID du profil parent", + type=openapi.TYPE_INTEGER, + required=True + ) + ] + ) + # Récupération des élèves d'un parent + # idProfile : identifiant du profil connecté rattaché aux fiches d'élèves + def get(self, request, id): + establishment_id = request.GET.get('establishment_id', None) + if establishment_id is None: + return JsonResponse({'error': 'establishment_id est requis'}, safe=False, status=status.HTTP_400_BAD_REQUEST) + + students = bdd.getObjects(_objectName=RegistrationForm, _columnName='student__guardians__profile_role__profile__id', _value=id) + if students: + students = students.filter(establishment=establishment_id).distinct() + students_serializer = RegistrationFormByParentSerializer(students, many=True) + return JsonResponse(students_serializer.data, safe=False) diff --git a/Back-End/requirements.txt b/Back-End/requirements.txt index b12640e..85047c7 100644 Binary files a/Back-End/requirements.txt and b/Back-End/requirements.txt differ diff --git a/Back-End/start.py b/Back-End/start.py index 224d3f7..4e08847 100644 --- a/Back-End/start.py +++ b/Back-End/start.py @@ -10,21 +10,34 @@ def run_command(command): print(f"stderr: {stderr.decode()}") return process.returncode +test_mode = os.getenv('TEST_MODE', 'False') == 'True' + commands = [ ["python", "manage.py", "collectstatic", "--noinput"], ["python", "manage.py", "flush", "--noinput"], - ["python", "manage.py", "makemigrations", "GestionInscriptions"], - ["python", "manage.py", "makemigrations", "GestionNotification"], - ["python", "manage.py", "makemigrations", "GestionMessagerie"], - ["python", "manage.py", "makemigrations", "GestionLogin"], - ["python", "manage.py", "makemigrations", "GestionEnseignants"], - ["python", "manage.py", "migrate"] + ["python", "manage.py", "makemigrations", "Establishment", "--noinput"], + ["python", "manage.py", "makemigrations", "Subscriptions", "--noinput"], + ["python", "manage.py", "makemigrations", "Planning", "--noinput"], + ["python", "manage.py", "makemigrations", "GestionNotification", "--noinput"], + ["python", "manage.py", "makemigrations", "GestionMessagerie", "--noinput"], + ["python", "manage.py", "makemigrations", "Auth", "--noinput"], + ["python", "manage.py", "makemigrations", "School", "--noinput"], + ["python", "manage.py", "migrate", "--noinput"] +] + +test_commands = [ + ["python", "manage.py", "init_mock_datas"] ] for command in commands: if run_command(command) != 0: exit(1) +if test_mode: + for test_command in test_commands: + if run_command(test_command) != 0: + exit(1) + # Lancer les processus en parallèle processes = [ subprocess.Popen(["python", "manage.py", "runserver", "0.0.0.0:8080"]), diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a516dac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM postgres:latest + +# Installer curl +RUN apt-get update && apt-get install -y curl ruby ruby-dev build-essential +RUN gem install bcrypt + +# Copier le script d'initialisation +COPY initDocusealUsers.sh /docker-entrypoint-initdb.d/initDocusealUsers.sh + +# Donner les permissions d'exécution au script +RUN chmod +x /docker-entrypoint-initdb.d/initDocusealUsers.sh + +# Commande par défaut pour démarrer le conteneur +ENTRYPOINT ["/bin/bash", "/docker-entrypoint-initdb.d/initDocusealUsers.sh"] \ No newline at end of file diff --git a/Front-End/.env b/Front-End/.env index f95783e..2c1e0f1 100644 --- a/Front-End/.env +++ b/Front-End/.env @@ -1,2 +1,5 @@ NEXT_PUBLIC_API_URL=http://localhost:8080 -NEXT_PUBLIC_USE_FAKE_DATA='false' \ No newline at end of file +NEXT_PUBLIC_USE_FAKE_DATA='false' +AUTH_SECRET='false' +NEXTAUTH_URL=http://localhost:3000 +DOCUSEAL_API_KEY="LRvUTQCbMSSpManYKshdQk9Do6rBQgjHyPrbGfxU3Jg" \ No newline at end of file diff --git a/Front-End/.vscode/settings.json b/Front-End/.vscode/settings.json deleted file mode 100644 index c99754e..0000000 --- a/Front-End/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "i18n-ally.localesPaths": [ - "messages" - ], - "i18n-ally.keystyle": "nested" -} \ No newline at end of file diff --git a/Front-End/Dockerfile b/Front-End/Dockerfile new file mode 100644 index 0000000..e4f9948 --- /dev/null +++ b/Front-End/Dockerfile @@ -0,0 +1,45 @@ +# Build argument pour choisir le mode +ARG BUILD_MODE=production + +# Build stage pour production uniquement +FROM node:18-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +COPY prod.env .env +RUN npm run build + +# Development stage +FROM node:18-alpine AS development +ENV NODE_ENV=development +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +# Ajout de la surveillance des fichiers pour le hot reload +ENV WATCHPACK_POLLING=true +ENV CHOKIDAR_USEPOLLING=true +COPY . . +EXPOSE 3000 +CMD ["npm", "run", "dev"] + +# Production stage +FROM node:18-alpine AS production +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/messages ./messages +COPY docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +RUN chown 1001:1001 -R /app +USER nextjs +ENV HOSTNAME="0.0.0.0" +EXPOSE 3000 +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["node", "server.js"] + +# Final stage selection +FROM ${BUILD_MODE} diff --git a/Front-End/docker/entrypoint.sh b/Front-End/docker/entrypoint.sh new file mode 100644 index 0000000..7f6499e --- /dev/null +++ b/Front-End/docker/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh + + +# Fonction pour échapper les caractères spéciaux +escape_value() { + echo "$1" | sed 's/[\/&]/\\&/g' +} + +replace_value() { + key=$1 + value=$2 + file=$3 + escaped_value=$(escape_value "$value") + find . -type f -exec sed -i "s|_${key}_|${value}|g" {} \; +} + +# Lire les clés et valeurs depuis un fichier .env +if [ -f .env ]; then + while IFS='=' read -r key value; do + # Ignorer les lignes vides et les commentaires + [ -z "$key" ] && continue + [ "${key#\#}" != "$key" ] && continue + + replace_value $key $value /app/ + done < .env +fi + +exec "$@" \ No newline at end of file diff --git a/Front-End/messages/en/dashboard.json b/Front-End/messages/en/dashboard.json index 5c10d64..60bee0d 100644 --- a/Front-End/messages/en/dashboard.json +++ b/Front-End/messages/en/dashboard.json @@ -1,9 +1,10 @@ { "dashboard": "Dashboard", "totalStudents": "Total Students", - "averageInscriptionTime": "Average Registration Time", + "pendingRegistrations": "Pending Registration", "reInscriptionRate": "Re-enrollment Rate", "structureCapacity": "Structure Capacity", + "capacityRate": "Capacity Rate", "inscriptionTrends": "Enrollment Trends", "upcomingEvents": "Upcoming Events" } \ No newline at end of file diff --git a/Front-End/messages/en/sidebar.json b/Front-End/messages/en/sidebar.json index 7c93cf5..2fce791 100644 --- a/Front-End/messages/en/sidebar.json +++ b/Front-End/messages/en/sidebar.json @@ -1,8 +1,9 @@ { "dashboard": "Dashboard", - "students": "Students", + "subscriptions": "Subscriptions", "structure": "Structure", - "planning": "Schedule", + "directory": "Directory", + "events": "Events", "grades": "Grades", "settings": "Settings", "schoolAdmin": "School Administration" diff --git a/Front-End/messages/en/students.json b/Front-End/messages/en/subscriptions.json similarity index 89% rename from Front-End/messages/en/students.json rename to Front-End/messages/en/subscriptions.json index a44b858..ffde89a 100644 --- a/Front-End/messages/en/students.json +++ b/Front-End/messages/en/subscriptions.json @@ -3,6 +3,7 @@ "addStudent": "New", "allStudents": "All Students", "pending": "Pending Registrations", + "subscribed": "Subscribed", "archived": "Archived", "name": "Name", "class": "Class", @@ -25,6 +26,8 @@ "mainContactMail":"Main contact email", "phone":"Phone", "lastUpdateDate":"Last update", + "classe":"Class", "registrationFileStatus":"Registration file status", - "files":"Files" + "files":"Files", + "subscribeFiles":"Subscribe files" } \ No newline at end of file diff --git a/Front-End/messages/fr/dashboard.json b/Front-End/messages/fr/dashboard.json index 4ac34ef..2bee5a0 100644 --- a/Front-End/messages/fr/dashboard.json +++ b/Front-End/messages/fr/dashboard.json @@ -1,9 +1,10 @@ { "dashboard": "Tableau de bord", "totalStudents": "Total des étudiants", - "averageInscriptionTime": "Temps moyen d'inscription", + "pendingRegistrations": "Inscriptions en attente", "reInscriptionRate": "Taux de réinscription", - "structureCapacity": "Remplissage de la structure", + "structureCapacity": "Capacité de la structure", + "capacityRate": "Remplissage de la structure", "inscriptionTrends": "Tendances d'inscription", "upcomingEvents": "Événements à venir" } \ No newline at end of file diff --git a/Front-End/messages/fr/sidebar.json b/Front-End/messages/fr/sidebar.json index 79610e7..5be92eb 100644 --- a/Front-End/messages/fr/sidebar.json +++ b/Front-End/messages/fr/sidebar.json @@ -1,8 +1,9 @@ { "dashboard": "Tableau de bord", - "students": "Élèves", + "subscriptions": "Inscriptions", "structure": "Structure", - "planning": "Emploi du temps", + "directory": "Annuaire", + "events": "Evenements", "grades": "Notes", "settings": "Paramètres", "schoolAdmin": "Administration Scolaire" diff --git a/Front-End/messages/fr/students.json b/Front-End/messages/fr/subscriptions.json similarity index 89% rename from Front-End/messages/fr/students.json rename to Front-End/messages/fr/subscriptions.json index 302e57d..eb1f509 100644 --- a/Front-End/messages/fr/students.json +++ b/Front-End/messages/fr/subscriptions.json @@ -3,6 +3,7 @@ "addStudent": "Nouveau", "allStudents": "Tous les élèves", "pending": "Inscriptions en attente", + "subscribed": "Inscrits", "archived": "Archivés", "name": "Nom", "class": "Classe", @@ -25,6 +26,8 @@ "mainContactMail":"Email de contact principal", "phone":"Téléphone", "lastUpdateDate":"Dernière mise à jour", + "classe":"Classe", "registrationFileStatus":"État du dossier d'inscription", - "files":"Fichiers" + "files":"Fichiers", + "subscribeFiles":"Fichiers d'inscription" } \ No newline at end of file diff --git a/Front-End/next.config.mjs b/Front-End/next.config.mjs index ff2db5f..371cd51 100644 --- a/Front-End/next.config.mjs +++ b/Front-End/next.config.mjs @@ -1,8 +1,43 @@ import createNextIntlPlugin from 'next-intl/plugin'; +import pkg from "./package.json" assert { type: "json" }; const withNextIntl = createNextIntlPlugin(); /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + output: "standalone", + reactStrictMode: true, + experimental: { + instrumentationHook: true, + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "www.gravatar.com", + }, + ], + }, + env: { + NEXT_PUBLIC_APP_VERSION: pkg.version, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080", + NEXT_PUBLIC_USE_FAKE_DATA: process.env.NEXT_PUBLIC_USE_FAKE_DATA || 'false', + AUTH_SECRET: process.env.AUTH_SECRET || 'false', + NEXTAUTH_URL: process.env.NEXTAUTH_URL || "http://localhost:3000", + DOCUSEAL_API_KEY: process.env.DOCUSEAL_API_KEY, + }, + async rewrites() { + return [ + { + source: '/api/documents/:path*', + destination: 'https://api.docuseal.com/v1/documents/:path*', + }, + { + source: '/api/auth/:path*', + destination: '/api/auth/:path*', // Exclure les routes NextAuth des réécritures de proxy + }, + ]; + } +}; export default withNextIntl(nextConfig); \ No newline at end of file diff --git a/Front-End/package-lock.json b/Front-End/package-lock.json index 6cf2c1b..6ab1776 100644 --- a/Front-End/package-lock.json +++ b/Front-End/package-lock.json @@ -8,16 +8,24 @@ "name": "n3wt-school-front-end", "version": "0.0.1", "dependencies": { + "@docuseal/react": "^1.0.56", "@radix-ui/react-dialog": "^1.1.2", "@tailwindcss/forms": "^0.5.9", "date-fns": "^4.1.0", "framer-motion": "^11.11.11", "ics": "^3.8.1", + "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", "lucide-react": "^0.453.0", "next": "14.2.11", + "next-auth": "^4.24.11", "next-intl": "^3.24.0", + "next-logger": "^5.0.1", + "pino": "^9.6.0", "react": "^18", "react-cookie": "^7.2.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18", "react-phone-number-input": "^3.4.8", "react-tooltip": "^5.28.0" @@ -106,6 +114,18 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", @@ -160,6 +180,34 @@ "node": ">=6.9.0" } }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "optional": true, + "peer": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "optional": true, + "peer": true, + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@docuseal/react": { + "version": "1.0.56", + "resolved": "https://registry.npmjs.org/@docuseal/react/-/react-1.0.56.tgz", + "integrity": "sha512-xna62Op4WLIVmgz2U0mi4paFayslxBUk2P8u3D70e1JgVRXsPFwzH6b1WhotedN9PMPS+cG2HP1PmpYoEzdZTQ==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "dev": true, @@ -577,6 +625,15 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "license": "MIT", @@ -897,6 +954,24 @@ } } }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==", + "license": "MIT" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==", + "license": "MIT" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "dev": true, @@ -974,6 +1049,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "optional": true, + "peer": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.2.0", "dev": true, @@ -1439,6 +1521,21 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "optional": true, + "peer": true + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -1572,6 +1669,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/busboy": { "version": "1.6.0", "dependencies": { @@ -1692,6 +1795,17 @@ "version": "0.0.1", "license": "MIT" }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "optional": true, + "peer": true, + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "license": "MIT", @@ -1706,6 +1820,45 @@ "version": "1.1.4", "license": "MIT" }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "optional": true, + "peer": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "optional": true, + "peer": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "optional": true, + "peer": true + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "optional": true, + "peer": true, + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -1720,9 +1873,10 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -1733,7 +1887,9 @@ "integrity": "sha512-4JwHNqaKZ19doQoNcBjsoYA+I7NqCH/mC/6f5cBWvdKzcK5TMmzLpq3Z/syVHMHJuDGFwJ+rPpGizvrqJybJow==" }, "node_modules/cross-spawn": { - "version": "7.0.3", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1934,6 +2090,17 @@ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, "node_modules/doctrine": { "version": "3.0.0", "dev": true, @@ -1949,6 +2116,15 @@ "version": "0.2.0", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.47", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.47.tgz", @@ -1959,6 +2135,13 @@ "version": "9.2.2", "license": "MIT" }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "optional": true, + "peer": true + }, "node_modules/enhanced-resolve": { "version": "5.17.1", "dev": true, @@ -2562,7 +2745,6 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2599,6 +2781,14 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "engines": { + "node": ">=6" + } + }, "node_modules/fastq": { "version": "1.17.1", "license": "ISC", @@ -2606,6 +2796,13 @@ "reusify": "^1.0.4" } }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "optional": true, + "peer": true + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -2660,6 +2857,13 @@ "dev": true, "license": "ISC" }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "optional": true, + "peer": true + }, "node_modules/for-each": { "version": "0.3.3", "dev": true, @@ -3074,7 +3278,7 @@ }, "node_modules/inherits": { "version": "2.0.4", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/input-format": { @@ -3149,6 +3353,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "optional": true, + "peer": true + }, "node_modules/is-async-function": { "version": "2.0.0", "dev": true, @@ -3400,6 +3611,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.0.7", "dev": true, @@ -3524,6 +3748,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -3577,6 +3810,27 @@ "json5": "lib/cli.js" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "dev": true, @@ -3591,6 +3845,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -3599,6 +3874,13 @@ "json-buffer": "3.0.1" } }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "optional": true, + "peer": true + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "dev": true, @@ -3659,11 +3941,77 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "optional": true, + "peer": true, + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "license": "MIT", @@ -3741,7 +4089,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3823,6 +4170,38 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next-intl": { "version": "3.24.0", "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.24.0.tgz", @@ -3853,6 +4232,38 @@ "node": ">= 0.6" } }, + "node_modules/next-logger": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/next-logger/-/next-logger-5.0.1.tgz", + "integrity": "sha512-zWTPtS0YwTB+4iSK4VxUVtCYt+zg8+Sx2Tjbtgmpd4SXsFnWdmCbXAeFZFKtEH8yNlucLCUaj0xqposMQ9rKRg==", + "dependencies": { + "lilconfig": "^3.1.2" + }, + "peerDependencies": { + "next": ">=9.0.0", + "pino": "^8.0.0 || ^9.0.0", + "winston": "^3.0.0" + }, + "peerDependenciesMeta": { + "pino": { + "optional": true + }, + "winston": { + "optional": true + } + } + }, + "node_modules/next-logger/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -3903,6 +4314,12 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "license": "MIT", @@ -4028,6 +4445,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "dev": true, @@ -4036,6 +4470,52 @@ "wrappy": "1" } }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "optional": true, + "peer": true, + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -4162,6 +4642,40 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", + "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" + }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -4325,6 +4839,28 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/preact": { + "version": "10.25.4", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.4.tgz", + "integrity": "sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -4333,6 +4869,27 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", @@ -4373,6 +4930,11 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/react": { "version": "18.3.1", "license": "MIT", @@ -4396,6 +4958,45 @@ "react": ">= 16.3.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "license": "MIT", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", @@ -4518,6 +5119,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "optional": true, + "peer": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4529,6 +5145,23 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "dev": true, @@ -4549,6 +5182,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", "dev": true, @@ -4681,6 +5320,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/safe-regex-test": { "version": "1.0.3", "dev": true, @@ -4697,6 +5355,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.23.2", "license": "MIT", @@ -4706,7 +5372,6 @@ }, "node_modules/semver": { "version": "7.6.3", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4789,6 +5454,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "optional": true, + "peer": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "dev": true, @@ -4797,6 +5472,14 @@ "node": ">=8" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -4804,6 +5487,24 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "optional": true, + "peer": true, + "engines": { + "node": "*" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "dev": true, @@ -4821,6 +5522,16 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "optional": true, + "peer": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "license": "MIT", @@ -5112,6 +5823,13 @@ "node": ">=6" } }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "optional": true, + "peer": true + }, "node_modules/text-table": { "version": "0.2.0", "dev": true, @@ -5136,6 +5854,14 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tiny-case": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", @@ -5156,6 +5882,16 @@ "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.0.0" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "dev": true, @@ -5306,12 +6042,13 @@ } }, "node_modules/universal-cookie": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.0.tgz", - "integrity": "sha512-PvcyflJAYACJKr28HABxkGemML5vafHmiL4ICe3e+BEKXRMt0GaFLZhAwgv637kFFnnfiSJ8e6jknrKkMrU+PQ==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.2.tgz", + "integrity": "sha512-fMiOcS3TmzP2x5QV26pIH3mvhexLIT0HmPa3V7Q7knRfT9HG6kTwq02HZGLPw0sAOXrAmotElGRvTLCMbJsvxQ==", + "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", - "cookie": "^0.6.0" + "cookie": "^0.7.2" } }, "node_modules/update-browserslist-db": { @@ -5413,6 +6150,15 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "license": "ISC", @@ -5501,6 +6247,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "optional": true, + "peer": true, + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "optional": true, + "peer": true, + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, @@ -5594,6 +6378,12 @@ "dev": true, "license": "ISC" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", diff --git a/Front-End/package.json b/Front-End/package.json index 29d968c..e05b435 100644 --- a/Front-End/package.json +++ b/Front-End/package.json @@ -10,16 +10,24 @@ "check-strings": "node scripts/check-hardcoded-strings.js" }, "dependencies": { + "@docuseal/react": "^1.0.56", "@radix-ui/react-dialog": "^1.1.2", "@tailwindcss/forms": "^0.5.9", "date-fns": "^4.1.0", "framer-motion": "^11.11.11", "ics": "^3.8.1", + "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", "lucide-react": "^0.453.0", "next": "14.2.11", + "next-auth": "^4.24.11", "next-intl": "^3.24.0", + "next-logger": "^5.0.1", + "pino": "^9.6.0", "react": "^18", "react-cookie": "^7.2.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18", "react-phone-number-input": "^3.4.8", "react-tooltip": "^5.28.0" @@ -33,4 +41,4 @@ "postcss": "^8.4.47", "tailwindcss": "^3.4.14" } -} \ No newline at end of file +} diff --git a/Front-End/prod.env b/Front-End/prod.env new file mode 100644 index 0000000..70fb338 --- /dev/null +++ b/Front-End/prod.env @@ -0,0 +1,5 @@ +NEXT_PUBLIC_API_URL=_NEXT_PUBLIC_API_URL_ +NEXT_PUBLIC_USE_FAKE_DATA=_NEXT_PUBLIC_USE_FAKE_DATA_ +AUTH_SECRET=_AUTH_SECRET_ +NEXTAUTH_URL=_NEXTAUTH_URL_ +DOCUSEAL_API_KEY=_DOCUSEAL_API_KEY_ \ No newline at end of file diff --git a/Front-End/project.inlang/.gitignore b/Front-End/project.inlang/.gitignore deleted file mode 100644 index 5e46596..0000000 --- a/Front-End/project.inlang/.gitignore +++ /dev/null @@ -1 +0,0 @@ -cache \ No newline at end of file diff --git a/Front-End/project.inlang/project_id b/Front-End/project.inlang/project_id deleted file mode 100644 index f461c3d..0000000 --- a/Front-End/project.inlang/project_id +++ /dev/null @@ -1 +0,0 @@ -2ff5cbbb4bc1c6d178400871dfa342ac4f0b18e9b86cb64a1110be1ec54238c1 \ No newline at end of file diff --git a/Front-End/project.inlang/settings.json b/Front-End/project.inlang/settings.json deleted file mode 100644 index fb6c9ff..0000000 --- a/Front-End/project.inlang/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - // official schema ensures that your project file is valid - "$schema": "https://inlang.com/schema/project-settings", - // the "source" language tag that is used in your project - "sourceLanguageTag": "fr", - // all the language tags you want to support in your project - "languageTags": ["fr", "en"], - "modules": [ - "https://cdn.jsdelivr.net/npm/@inlang/plugin-json@4/dist/index.js" - ], // or use another storage module: https://inlang.com/c/plugins (i18next, json, inlang message format) - "settings": {} -} \ No newline at end of file diff --git a/Front-End/src/app/500.js b/Front-End/src/app/500.js new file mode 100644 index 0000000..e2a6f48 --- /dev/null +++ b/Front-End/src/app/500.js @@ -0,0 +1,15 @@ +import Link from 'next/link' +import Logo from '../components/Logo' + +export default function Custom500() { + return ( +
          +
          + +

          500 | Erreur interne

          +

          Une erreur interne est survenue.

          + Retour Accueil +
          +
          + ) +} \ No newline at end of file diff --git a/Front-End/src/app/[locale]/admin/classes/page.js b/Front-End/src/app/[locale]/admin/classes/page.js deleted file mode 100644 index 30b6a9d..0000000 --- a/Front-End/src/app/[locale]/admin/classes/page.js +++ /dev/null @@ -1,49 +0,0 @@ -'use client' -import React, { useState, useEffect } from 'react'; -import Table from '@/components/Table'; -import Button from '@/components/Button'; - -const columns = [ - { name: 'Nom', transform: (row) => row.Nom }, - { name: 'Niveau', transform: (row) => row.Niveau }, - { name: 'Effectif', transform: (row) => row.Effectif }, -]; - -export default function Page() { - const [classes, setClasses] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - - useEffect(() => { - fetchClasses(); - }, [currentPage]); - - const fetchClasses = async () => { - const fakeData = { - classes: [ - { Nom: 'Classe A', Niveau: '1ère année', Effectif: 30 }, - { Nom: 'Classe B', Niveau: '2ème année', Effectif: 25 }, - { Nom: 'Classe C', Niveau: '3ème année', Effectif: 28 }, - ], - totalPages: 3 - }; - setClasses(fakeData.classes); - setTotalPages(fakeData.totalPages); - }; - - const handlePageChange = (page) => { - setCurrentPage(page); - }; - - const handleCreateClass = () => { - console.log('Créer une nouvelle classe'); - }; - - return ( -
          -

          Gestion des Classes

          -
          + {selectedStudent && ( +
          +

          + Responsables associés à {selectedStudent.last_name} {selectedStudent.first_name} : +

          + {existingGuardians.map((guardian) => ( +
          + - + + {guardian.last_name && guardian.first_name + ? `${guardian.last_name} ${guardian.first_name} - ${guardian.associated_profile_email}` + : `${guardian.associated_profile_email}`} + + +
          + ))}
          - {responsableType === 'new' && ( -
          - setResponsableEmail(e.target.value)} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 italic" - /> -
          - )} - - {responsableType === 'existing' && ( -
          -
          - - - - - - - - - {allEleves.map((eleve, index) => ( - handleEleveSelection(eleve)} - > - - - - ))} - -
          NomPrénom
          {eleve.nom}{eleve.prenom}
          -
          - {selectedEleve && ( -
          -

          Responsables associés à {selectedEleve.nom} {selectedEleve.prenom} :

          - {existingResponsables.map((responsable) => ( -
          - -
          - ))} -
          - )} -
          - )} + )}
          - )} - {step === 3 && ( -
          -

          Récapitulatif

          -
          -

          Élève

          -

          Nom : {eleveNom}

          -

          Prénom : {elevePrenom}

          -
          -
          -

          Responsable(s)

          - {responsableType === 'new' && ( -

          Email du nouveau responsable : {responsableEmail}

          - )} - {responsableType === 'existing' && selectedEleve && ( -
          -

          Responsables associés à {selectedEleve.nom} {selectedEleve.prenom} :

          -
            - {existingResponsables.filter(responsable => selectedResponsables.includes(responsable.id)).map((responsable) => ( -
          • - {responsable.nom && responsable.prenom ? `${responsable.nom} ${responsable.prenom}` : responsable.mail} -
          • - ))} -
          -
          - )} -
          +
          + )} + + {step === 3 && ( +
          + {registrationFees.length > 0 ? ( + <> +
          +
          + +
          + {registrationDiscounts.length > 0 ? ( + + ) : ( +

          + Information + Aucune réduction n'a été créée sur les frais d'inscription. +

          + )} +
          + + MONTANT TOTAL + }, + { + name: 'TOTAL', + transform: () => {totalRegistrationAmount} € + } + ]} + defaultTheme='bg-cyan-100' + /> + + ) : ( +

          + Attention! + Aucun frais d'inscription n'a été créé. +

          )} -
          - {step > 1 && ( -
          MONTANT TOTAL + }, + { + name: 'TOTAL', + transform: () => {totalTuitionAmount} € + } + ]} + defaultTheme='bg-cyan-100' + /> + + ) : ( +

          + Attention! + Aucun frais de scolarité n'a été créé. +

          + )} + + )} + + {step === 5 && ( +
          + {groups.length > 0 ? ( +
          +

          Sélectionnez un groupe de documents

          + {groups.map((group) => ( +
          + setFormData({ + ...formData, + selectedFileGroup: parseInt(e.target.value) + })} + className="form-radio h-4 w-4 text-emerald-600" + /> + +
          + ))} +
          + ) : ( +

          + Attention! + Aucun groupe de documents n'a été créé. +

          + )} +
          + )} + + {step === steps.length && ( +
          +
          +
          +

          Élève

          +
          + + + + + + + + + + + + +
          NomPrénom
          {formData.studentLastName}{formData.studentFirstName}
          + +
          +

          Responsable(s)

          + {formData.responsableType === 'new' && ( + + + + + + + + + + + + + +
          EmailTéléphone
          {formData.guardianEmail}{formData.guardianPhone}
          + )} + {formData.responsableType === 'existing' && selectedStudent && ( +
          +

          Associé(s) à : {selectedStudent.nom} {selectedStudent.prenom}

          + + + + + + + + + + {existingGuardians.filter(guardian => formData.selectedGuardians.includes(guardian.id)).map((guardian) => ( + + + + + + ))} + +
          NomPrénomEmail
          {guardian.last_name}{guardian.first_name}{guardian.associated_profile_email}
          +
          + )} +
          +
          +
          + +
          +
        + )} + +
        + {step > 1 && ( + - )} - {step < 3 ? ( - - ) : ( - <> - - - - )} -
        + primary + name="Create" /> + )}
      + + setPopupVisible(false)} + onCancel={() => setPopupVisible(false)} + /> +
); } diff --git a/Front-End/src/components/Inscription/InscriptionFormShared.js b/Front-End/src/components/Inscription/InscriptionFormShared.js index f524263..1aa1c46 100644 --- a/Front-End/src/components/Inscription/InscriptionFormShared.js +++ b/Front-End/src/components/Inscription/InscriptionFormShared.js @@ -1,3 +1,4 @@ +// Import des dépendances nécessaires import React, { useState, useEffect } from 'react'; import InputText from '@/components/InputText'; import SelectChoice from '@/components/SelectChoice'; @@ -5,177 +6,373 @@ import ResponsableInputFields from '@/components/Inscription/ResponsableInputFie import Loader from '@/components/Loader'; import Button from '@/components/Button'; import DjangoCSRFToken from '@/components/DjangoCSRFToken'; +import Table from '@/components/Table'; +import { fetchRegisterForm, fetchTemplatesFromRegistrationFiles } from '@/app/actions/subscriptionAction'; +import { fetchRegistrationFileFromGroup, + fetchRegistrationTemplateMaster, + downloadTemplate, + createRegistrationTemplates, + editRegistrationTemplates, + deleteRegistrationTemplates + } from '@/app/actions/registerFileGroupAction'; +import { Download, Upload, Trash2, Eye } from 'lucide-react'; +import { BASE_URL } from '@/utils/Url'; +import DraggableFileUpload from '@/components/DraggableFileUpload'; +import Modal from '@/components/Modal'; +import FileStatusLabel from '@/components/FileStatusLabel'; +import logger from '@/utils/logger'; +import StudentInfoForm from '@/components/Inscription/StudentInfoForm'; +import FilesToSign from '@/components/Inscription/FilesToSign'; +import FilesToUpload from '@/components/Inscription/FilesToUpload'; +import { DocusealForm } from '@docuseal/react'; +import { ESTABLISHMENT_ID } from '@/utils/Url'; - -const niveaux = [ - { value:'1', label: 'TPS - Très Petite Section'}, - { value:'2', label: 'PS - Petite Section'}, - { value:'3', label: 'MS - Moyenne Section'}, - { value:'4', label: 'GS - Grande Section'}, -]; - +/** + * Composant de formulaire d'inscription partagé + * @param {string} studentId - ID de l'étudiant + * @param {string} csrfToken - Token CSRF pour la sécurité + * @param {function} onSubmit - Fonction de soumission du formulaire + * @param {string} cancelUrl - URL de redirection en cas d'annulation + * @param {object} errors - Erreurs de validation du formulaire + */ export default function InscriptionFormShared({ - initialData, + studentId, csrfToken, onSubmit, cancelUrl, - isLoading = false + errors = {} // Nouvelle prop pour les erreurs }) { + // États pour gérer les données du formulaire + const [isLoading, setIsLoading] = useState(true); + const [formData, setFormData] = useState({ + id: '', + last_name: '', + first_name: '', + address: '', + birth_date: '', + birth_place: '', + birth_postal_code: '', + nationality: '', + attending_physician: '', + level: '' + }); - const [formData, setFormData] = useState(() => ({ - id: initialData?.id || '', - nom: initialData?.nom || '', - prenom: initialData?.prenom || '', - adresse: initialData?.adresse || '', - dateNaissance: initialData?.dateNaissance || '', - lieuNaissance: initialData?.lieuNaissance || '', - codePostalNaissance: initialData?.codePostalNaissance || '', - nationalite: initialData?.nationalite || '', - medecinTraitant: initialData?.medecinTraitant || '', - niveau: initialData?.niveau || '' - })); + const [guardians, setGuardians] = useState([]); - const [responsables, setReponsables] = useState(() => - initialData?.responsables || [] - ); + // États pour la gestion des fichiers + const [uploadedFiles, setUploadedFiles] = useState([]); + const [fileTemplates, setFileTemplates] = useState([]); + const [fileGroup, setFileGroup] = useState(null); + const [fileName, setFileName] = useState(""); + const [file, setFile] = useState(""); + const [showUploadModal, setShowUploadModal] = useState(false); + const [currentTemplateId, setCurrentTemplateId] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + + // Chargement initial des données // Mettre à jour les données quand initialData change useEffect(() => { - if (initialData) { - setFormData({ - id: initialData.id || '', - nom: initialData.nom || '', - prenom: initialData.prenom || '', - adresse: initialData.adresse || '', - dateNaissance: initialData.dateNaissance || '', - lieuNaissance: initialData.lieuNaissance || '', - codePostalNaissance: initialData.codePostalNaissance || '', - nationalite: initialData.nationalite || '', - medecinTraitant: initialData.medecinTraitant || '', - niveau: initialData.niveau || '' - }); - setReponsables(initialData.responsables || []); - } - }, [initialData]); + if (studentId) { + fetchRegisterForm(studentId).then((data) => { + logger.debug(data); + setFormData({ + id: data?.student?.id || '', + last_name: data?.student?.last_name || '', + first_name: data?.student?.first_name || '', + address: data?.student?.address || '', + birth_date: data?.student?.birth_date || '', + birth_place: data?.student?.birth_place || '', + birth_postal_code: data?.student?.birth_postal_code || '', + nationality: data?.student?.nationality || '', + attending_physician: data?.student?.attending_physician || '', + level: data?.student?.level || '' + }); + setGuardians(data?.student?.guardians || []); + setUploadedFiles(data.registration_files || []); + }); + + setIsLoading(false); + } + }, [studentId]); + + useEffect(() => { + fetchTemplatesFromRegistrationFiles(studentId).then((data) => { + setFileTemplates(data); + }) + }, []); + + // Fonctions de gestion du formulaire et des fichiers const updateFormField = (field, value) => { setFormData(prev => ({...prev, [field]: value})); }; - const handleSubmit = (e) => { - e.preventDefault(); - onSubmit({ - eleve: { - ...formData, - responsables + // Gestion du téléversement de fichiers + const handleFileUpload = async (file, fileName) => { + if (!file || !currentTemplateId || !formData.id) { + logger.error('Missing required data for upload'); + return; + } + + const data = new FormData(); + data.append('file', file); + data.append('name', fileName); + data.append('template', currentTemplateId); + data.append('register_form', formData.id); + + try { + const response = await createRegistrationTemplates(data, csrfToken); + if (response) { + setUploadedFiles(prev => { + const newFiles = prev.filter(f => parseInt(f.template) !== currentTemplateId); + return [...newFiles, { + name: fileName, + template: currentTemplateId, + file: response.file + }]; + }); + + // Rafraîchir les données du formulaire pour avoir les fichiers à jour + if (studentId) { + fetchRegisterForm(studentId).then((data) => { + setUploadedFiles(data.registration_files || []); + }); + } } - }); + } catch (error) { + logger.error('Error uploading file:', error); + } }; + // Vérification si un fichier est déjà uploadé + const isFileUploaded = (templateId) => { + return uploadedFiles.find(template => + template.template === templateId + ); + }; + + // Récupération d'un fichier uploadé + const getUploadedFile = (templateId) => { + return uploadedFiles.find(file => parseInt(file.template) === templateId); + }; + + // Suppression d'un fichier + const handleDeleteFile = async (templateId) => { + const fileToDelete = getUploadedFile(templateId); + if (!fileToDelete) return; + + try { + await deleteRegistrationTemplates(fileToDelete.id, csrfToken); + setUploadedFiles(prev => prev.filter(f => parseInt(f.template) !== templateId)); + } catch (error) { + logger.error('Error deleting file:', error); + } + }; + + // Soumission du formulaire + const handleSubmit = (e) => { + e.preventDefault(); + const data ={ + student: { + ...formData, + guardians + }, + establishment: ESTABLISHMENT_ID, + status:3 + } + onSubmit(data); + }; + + // Récupération des messages d'erreur + const getError = (field) => { + return errors?.student?.[field]?.[0]; + }; + + const handleNextPage = () => { + setCurrentPage(currentPage + 1); + }; + + const handlePreviousPage = () => { + setCurrentPage(currentPage - 1); + }; + + const requiredFileTemplates = fileTemplates; + + // Configuration des colonnes pour le tableau des fichiers + const columns = [ + { name: 'Nom du fichier', transform: (row) => row.name }, + { name: 'Fichier à Remplir', transform: (row) => row.is_required ? 'Oui' : 'Non' }, + { name: 'Fichier de référence', transform: (row) => row.file && }, + { name: 'Statut', transform: (row) => + row.is_required && ( + + ) + }, + { name: 'Actions', transform: (row) => { + if (!row.is_required) return null; + + const uploadedFile = getUploadedFile(row.id); + + if (uploadedFile) { + return ( +
+ + + + +
+ ); + } + + return ( + + ); + }}, + ]; + + // Affichage du loader pendant le chargement if (isLoading) return ; + // Rendu du composant return (
- {/* Section Élève */} -
-

Informations de l'élève

-
- updateFormField('nom', e.target.value)} - required - /> - updateFormField('prenom', e.target.value)} - required - /> - updateFormField('nationalite', e.target.value)} - /> - updateFormField('dateNaissance', e.target.value)} - required - /> - updateFormField('lieuNaissance', e.target.value)} - /> - updateFormField('codePostalNaissance', e.target.value)} - /> -
- updateFormField('adresse', e.target.value)} - /> -
- updateFormField('medecinTraitant', e.target.value)} - /> - updateFormField('niveau', e.target.value)} - choices={niveaux} - required - /> -
-
- - {/* Section Responsables */} -
-

Responsables

- { - const updatedResponsables = responsables.map(resp => - resp.id === id ? { ...resp, [field]: value } : resp - ); - setReponsables(updatedResponsables); - }} - addResponsible={(e) => { - e.preventDefault(); - setReponsables([...responsables, { id: Date.now() }]); - }} - deleteResponsable={(index) => { - const newArray = [...responsables]; - newArray.splice(index, 1); - setReponsables(newArray); - }} + {/* Page 1 : Informations de l'élève et Responsables */} + {currentPage === 1 && ( + -
+ )} + + {/* Pages suivantes : Section Fichiers d'inscription */} + {currentPage > 1 && currentPage <= requiredFileTemplates.length + 1 && ( +
+

{requiredFileTemplates[currentPage - 2].name}

+ { + downloadTemplate(requiredFileTemplates[currentPage - 2].slug) + .then((data) => fetch(data)) + .then((response) => response.blob()) + .then((blob) => { + const file = new File([blob], `${requiredFileTemplates[currentPage - 2].name}.pdf`, { type: blob.type }); + const updateData = new FormData(); + updateData.append('file', file); + + return editRegistrationTemplates(requiredFileTemplates[currentPage - 2].id, updateData, csrfToken); + }) + .then((data) => { + logger.debug("EDIT TEMPLATE : ", data); + }) + .catch((error) => { + logger.error("error editing template : ", error); + }); + }} + > + +
+ )} + + {/* Dernière page : Section Fichiers parents */} + {currentPage === requiredFileTemplates.length + 2 && ( + <> + !template.is_required)} + columns={columns} + /> + + )} {/* Boutons de contrôle */}
-
+ {fileTemplates.length > 0 && ( + ( + <> + { + if (selectedFile) { + setFile(selectedFile); + setFileName(selectedFile.name); + } + }} + /> +
+
+ + )} + /> + )}
); } \ No newline at end of file diff --git a/Front-End/src/components/Inscription/ResponsableInputFields.js b/Front-End/src/components/Inscription/ResponsableInputFields.js index 8b68846..e4cbc52 100644 --- a/Front-End/src/components/Inscription/ResponsableInputFields.js +++ b/Front-End/src/components/Inscription/ResponsableInputFields.js @@ -4,21 +4,25 @@ import Button from '@/components/Button'; import React from 'react'; import { useTranslations } from 'next-intl'; import 'react-phone-number-input/style.css' +import { Trash2, Plus } from 'lucide-react'; -export default function ResponsableInputFields({responsables, onResponsablesChange, addResponsible, deleteResponsable}) { - const t = useTranslations('ResponsableInputFields'); +export default function ResponsableInputFields({guardians, onGuardiansChange, addGuardian, deleteGuardian, errors = []}) { + const t = useTranslations('ResponsableInputFields'); + + const getError = (index, field) => { + return errors[index]?.[field]?.[0]; + }; return (
- {responsables.map((item, index) => ( + {guardians.map((item, index) => (

{t('responsable')} {index+1}

- {responsables.length > 1 && ( -
@@ -31,15 +35,19 @@ export default function ResponsableInputFields({responsables, onResponsablesChan name="nomResponsable" type="text" label={t('lastname')} - value={item.nom} - onChange={(event) => {onResponsablesChange(item.id, "nom", event.target.value)}} + value={item.last_name} + onChange={(event) => {onGuardiansChange(item.id, "last_name", event.target.value)}} + errorMsg={getError(index, 'last_name')} + required /> {onResponsablesChange(item.id, "prenom", event.target.value)}} + value={item.first_name} + onChange={(event) => {onGuardiansChange(item.id, "first_name", event.target.value)}} + errorMsg={getError(index, 'first_name')} + required />
@@ -48,14 +56,18 @@ export default function ResponsableInputFields({responsables, onResponsablesChan name="mailResponsable" type="email" label={t('email')} - value={item.mail} - onChange={(event) => {onResponsablesChange(item.id, "mail", event.target.value)}} + value={item.email} + onChange={(event) => {onGuardiansChange(item.id, "email", event.target.value)}} + required + errorMsg={getError(index, 'email')} /> {onResponsablesChange(item.id, "telephone", event)}} + value={item.phone} + onChange={(event) => {onGuardiansChange(item.id, "phone", event)}} + required + errorMsg={getError(index, 'phone')} />
@@ -64,15 +76,19 @@ export default function ResponsableInputFields({responsables, onResponsablesChan name="dateNaissanceResponsable" type="date" label={t('birthdate')} - value={item.dateNaissance} - onChange={(event) => {onResponsablesChange(item.id, "dateNaissance", event.target.value)}} + value={item.birth_date} + onChange={(event) => {onGuardiansChange(item.id, "birth_date", event.target.value)}} + required + errorMsg={getError(index, 'birth_date')} /> {onResponsablesChange(item.id, "profession", event.target.value)}} + onChange={(event) => {onGuardiansChange(item.id, "profession", event.target.value)}} + required + errorMsg={getError(index, 'profession')} /> @@ -81,21 +97,19 @@ export default function ResponsableInputFields({responsables, onResponsablesChan name="adresseResponsable" type="text" label={t('address')} - value={item.adresse} - onChange={(event) => {onResponsablesChange(item.id, "adresse", event.target.value)}} + value={item.address} + onChange={(event) => {onGuardiansChange(item.id, "address", event.target.value)}} + required + errorMsg={getError(index, 'address')} /> ))}
-
diff --git a/Front-End/src/components/Inscription/StudentInfoForm.js b/Front-End/src/components/Inscription/StudentInfoForm.js new file mode 100644 index 0000000..f4665dd --- /dev/null +++ b/Front-End/src/components/Inscription/StudentInfoForm.js @@ -0,0 +1,126 @@ +import React from 'react'; +import InputText from '@/components/InputText'; +import SelectChoice from '@/components/SelectChoice'; +import ResponsableInputFields from '@/components/Inscription/ResponsableInputFields'; + +const levels = [ + { value:'1', label: 'TPS - Très Petite Section'}, + { value:'2', label: 'PS - Petite Section'}, + { value:'3', label: 'MS - Moyenne Section'}, + { value:'4', label: 'GS - Grande Section'}, +]; + +export default function StudentInfoForm({ formData, updateFormField, guardians, setGuardians, errors }) { + const getError = (field) => { + return errors?.student?.[field]?.[0]; + }; + + return ( + <> +
+

Informations de l'élève

+
+ updateFormField('last_name', e.target.value)} + required + errorMsg={getError('last_name')} + /> + updateFormField('first_name', e.target.value)} + errorMsg={getError('first_name')} + required + /> + updateFormField('nationality', e.target.value)} + /> + updateFormField('birth_date', e.target.value)} + required + errorMsg={getError('birth_date')} + /> + updateFormField('birth_place', e.target.value)} + required + errorMsg={getError('birth_place')} + /> + updateFormField('birth_postal_code', e.target.value)} + required + errorMsg={getError('birth_postal_code')} + /> +
+ updateFormField('address', e.target.value)} + required + errorMsg={getError('address')} + /> +
+ updateFormField('attending_physician', e.target.value)} + required + errorMsg={getError('attending_physician')} + /> + updateFormField('level', e.target.value)} + choices={levels} + required + errorMsg={getError('level')} + /> +
+
+ +
+

Responsables

+ { + const updatedGuardians = guardians.map(resp => + resp.id === id ? { ...resp, [field]: value } : resp + ); + setGuardians(updatedGuardians); + }} + addGuardian={(e) => { + e.preventDefault(); + setGuardians([...guardians, { id: Date.now() }]); + }} + deleteGuardian={(index) => { + const newArray = [...guardians]; + newArray.splice(index, 1); + setGuardians(newArray); + }} + errors={errors?.student?.guardians || []} + /> +
+ + ); +} \ No newline at end of file diff --git a/Front-End/src/components/Logo.js b/Front-End/src/components/Logo.js index c6b902d..38acac0 100644 --- a/Front-End/src/components/Logo.js +++ b/Front-End/src/components/Logo.js @@ -4,8 +4,8 @@ import logoImage from '@/img/logo_min.svg'; // Assurez-vous que le chemin vers l const Logo = ({ className }) => { return ( -
- Logo +
+ Logo
); }; diff --git a/Front-End/src/components/Modal.js b/Front-End/src/components/Modal.js index 2382d6e..55acb9b 100644 --- a/Front-End/src/components/Modal.js +++ b/Front-End/src/components/Modal.js @@ -1,28 +1,31 @@ import * as Dialog from '@radix-ui/react-dialog'; -const Modal = ({ isOpen, setIsOpen, title, ContentComponent }) => { +const Modal = ({ isOpen, setIsOpen, title, ContentComponent, modalClassName }) => { return ( - -
- - {title} - -
- -
-
+ +
+
+ + {title} +
+
+ +
diff --git a/Front-End/src/components/MultiSelect.js b/Front-End/src/components/MultiSelect.js new file mode 100644 index 0000000..2989d86 --- /dev/null +++ b/Front-End/src/components/MultiSelect.js @@ -0,0 +1,81 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Check, ChevronDown } from 'lucide-react'; + +const MultiSelect = ({ name, label, options, selectedOptions, onChange, errorMsg }) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const handleSelect = (option) => { + const isSelected = selectedOptions.some(selected => selected.id === option.id); + let newSelectedOptions; + if (isSelected) { + newSelectedOptions = selectedOptions.filter(selected => selected.id !== option.id); + } else { + newSelectedOptions = [...selectedOptions, option]; + } + onChange(newSelectedOptions); + }; + + const handleClickOutside = (event) => { + if (containerRef.current && !containerRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
+
+ + {isOpen && ( +
    + {options.map(option => ( +
  • selected.id === option.id) ? 'text-white bg-emerald-600' : 'text-gray-900 hover:bg-emerald-100 hover:text-emerald-900' + }`} + onClick={() => handleSelect(option)} + > + selected.id === option.id) ? 'font-semibold' : 'font-normal'}`}> + {option.name} + + {selectedOptions.some(selected => selected.id === option.id) && ( + + + + )} +
  • + ))} +
+ )} +
+ {errorMsg &&

{errorMsg}

} +
+ ); +}; + +export default MultiSelect; \ No newline at end of file diff --git a/Front-End/src/components/PaymentModeSelector.js b/Front-End/src/components/PaymentModeSelector.js new file mode 100644 index 0000000..eb69881 --- /dev/null +++ b/Front-End/src/components/PaymentModeSelector.js @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import { DollarSign } from 'lucide-react'; + +const paymentModesOptions = [ + { id: 1, name: 'Prélèvement SEPA' }, + { id: 2, name: 'Virement' }, + { id: 3, name: 'Chèque' }, + { id: 4, name: 'Espèce' }, +]; + +const PaymentModeSelector = ({ paymentModes, setPaymentModes, handleEdit, type }) => { + const [activePaymentModes, setActivePaymentModes] = useState([]); + + useEffect(() => { + // Initialiser activePaymentModes avec les modes dont is_active est à true + const activeModes = paymentModes.filter(mode => mode.is_active).map(mode => mode.mode); + setActivePaymentModes(activeModes); + }, [paymentModes]); + + const handleModeToggle = (modeId) => { + setActivePaymentModes((prevActiveModes) => { + const newActiveModes = prevActiveModes.includes(modeId) + ? prevActiveModes.filter((mode) => mode !== modeId) + : [...prevActiveModes, modeId]; + + // Mettre à jour le mode de paiement dans le backend + const updatedMode = paymentModes.find(mode => mode.mode === modeId); + if (updatedMode) { + handleEdit(updatedMode.id, { ...updatedMode, is_active: !updatedMode.is_active }); + } + + return newActiveModes; + }); + }; + + return ( +
+
+ +

Modes de paiement

+
+
+ {paymentModesOptions.map((mode) => ( + + ))} +
+
+ ); +}; + +export default PaymentModeSelector; \ No newline at end of file diff --git a/Front-End/src/components/PaymentPlanSelector.js b/Front-End/src/components/PaymentPlanSelector.js new file mode 100644 index 0000000..b44863e --- /dev/null +++ b/Front-End/src/components/PaymentPlanSelector.js @@ -0,0 +1,278 @@ +import React, { useState, useEffect } from 'react'; +import { Calendar, Eye, EyeOff, Clock, Check } from 'lucide-react'; +import Table from '@/components/Table'; +import DateTab from '@/components/DateTab'; +import InputTextIcon from '@/components/InputTextIcon'; +import Popup from '@/components/Popup'; + +const paymentPlansOptions = [ + { id: 0, name: '1 fois', frequency: 1 }, + { id: 1, name: '3 fois', frequency: 3 }, + { id: 2, name: '10 fois', frequency: 10 }, + { id: 3, name: '12 fois', frequency: 12 }, +]; + +const PaymentPlanSelector = ({ paymentPlans, setPaymentPlans, handleEdit, type }) => { + const [dates, setDates] = useState({}); + const [selectedFrequency, setSelectedFrequency] = useState(null); + const [activeFrequencies, setActiveFrequencies] = useState([]); + const [defaultDay, setDefaultDay] = useState('-'); + const [isDefaultDayModified, setIsDefaultDayModified] = useState(false); + const [popupVisible, setPopupVisible] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + const [errorMsg, setErrorMsg] = useState(''); + const [resetModifiedDates, setResetModifiedDates] = useState(false); + + useEffect(() => { + if (paymentPlans && paymentPlans.length > 0) { + const activePlans = paymentPlans.filter(plan => plan.is_active); + const frequencies = activePlans.map(plan => { + const paymentPlanOption = paymentPlansOptions.find(p => p.frequency === plan.frequency); + return paymentPlanOption ? paymentPlanOption.id : null; + }).filter(id => id !== null); + setActiveFrequencies(frequencies); + + if (activePlans.length > 0) { + const firstDueDate = new Date(activePlans[0].due_dates[0]); + setDefaultDay(firstDueDate.getDate()); + } + + const initialDates = {}; + paymentPlans.forEach(plan => { + const paymentPlanOption = paymentPlansOptions.find(p => p.frequency === plan.frequency); + if (paymentPlanOption) { + initialDates[paymentPlanOption.id] = plan.due_dates; + } + }); + setDates(initialDates); + } + }, [paymentPlans]); + + useEffect(() => { + updateDefaultDay(); + }, [dates, selectedFrequency]); + + const updateDefaultDay = () => { + const currentDates = dates[selectedFrequency]; + if (currentDates && currentDates.length > 0) { + const days = currentDates.map(date => new Date(date).getDate()); + const allSameDay = days.every(day => day === days[0]); + if (allSameDay) { + setDefaultDay(days[0]); + } else { + setDefaultDay('-'); + setIsDefaultDayModified(false); + } + } else { + setDefaultDay('-'); + } + }; + + const handleActivationChange = (value) => { + const selectedPlan = paymentPlans.find(plan => plan.frequency === paymentPlansOptions.find(p => p.id === value)?.frequency); + if (!selectedPlan) return; + + const updatedData = { + ...selectedPlan, + is_active: !selectedPlan.is_active + }; + + handleEdit(selectedPlan.id, updatedData) + .then(() => { + setPaymentPlans(prevPlans => prevPlans.map(plan => + plan.id === selectedPlan.id ? { ...plan, is_active: updatedData.is_active } : plan + )); + setActiveFrequencies(prevFrequencies => { + if (updatedData.is_active) { + setPopupMessage(`L'option de paiement en ${paymentPlansOptions.find(p => p.id === value).name} a été activée.`); + setPopupVisible(true); + return [...prevFrequencies, value]; + } else { + setPopupMessage(`L'option de paiement en ${paymentPlansOptions.find(p => p.id === value).name} a été désactivée.`); + setPopupVisible(true); + return prevFrequencies.filter(item => item !== value); + } + }); + }) + .catch(error => { + console.error(error); + }); + }; + + const handleRowClick = (row) => { + const value = row.id; + if (selectedFrequency === value) { + setSelectedFrequency(null); // Désélectionner l'onglet si la ligne est déjà sélectionnée + } else { + setSelectedFrequency(value); + if (!dates[value]) { + const frequencyValue = paymentPlansOptions.find(plan => plan.id === value)?.frequency || 1; + const newDates = Array(frequencyValue).fill('').map((_, index) => { + const newDate = new Date(); + newDate.setDate(defaultDay); + if (value === 1) { + newDate.setMonth(newDate.getMonth() + index * 4); // Espacer de 4 mois pour le paiement en 3 fois + } else { + newDate.setMonth(newDate.getMonth() + index); + } + return newDate.toISOString().split('T')[0]; + }); + setDates(prevDates => ({ ...prevDates, [value]: newDates })); + } + } + }; + + const handleDateChange = (planId, index, date) => { + setDates((prevDates) => { + const newDates = { ...prevDates }; + newDates[planId][index] = date; + return newDates; + }); + }; + + const handleDefaultDayChange = (e) => { + const value = e.target.value; + if (value === '') { + setDefaultDay('-'); + setErrorMsg(''); + setIsDefaultDayModified(false); + return; + } + + const day = parseInt(value, 10); + setDefaultDay(day); + + if (day < 1 || day > 31) { + setErrorMsg('Le jour doit être compris entre 1 et 31.'); + setIsDefaultDayModified(false); + return; + } + + setErrorMsg(''); + setIsDefaultDayModified(true); + setResetModifiedDates(true); + setTimeout(() => setResetModifiedDates(false), 0); + + // Mettre à jour les dates d'échéance en fonction du jour sélectionné + const updatedDates = dates[selectedFrequency].map(date => { + const newDate = new Date(date); + newDate.setDate(day); + return newDate.toISOString().split('T')[0]; + }); + setDates(prevDates => ({ ...prevDates, [selectedFrequency]: updatedDates })); + }; + + const handleSubmitDefaultDay = () => { + const selectedPlan = paymentPlans.find(plan => plan.frequency === paymentPlansOptions.find(p => p.id === selectedFrequency)?.frequency); + if (!selectedPlan) return; + + const updatedData = { + ...selectedPlan, + due_dates: dates[selectedFrequency] + }; + + handleEdit(selectedPlan.id, updatedData) + .then(() => { + setPopupMessage(`Mise à jour des dates d'échéances effectuée avec succès`); + setPopupVisible(true); + setIsDefaultDayModified(false); + }) + .catch(error => { + console.error(error); + }); + }; + + const columns = [ + { name: 'OPTIONS', label: 'Option' }, + { name: 'ACTIONS', label: 'Action' }, + ]; + + const renderCell = (row, column) => { + switch (column) { + case 'OPTIONS': + return {row.name}; + case 'ACTIONS': + return ( + + ); + default: + return null; + } + }; + + const selectedPaymentPlan = paymentPlans.find(plan => plan.frequency === paymentPlansOptions.find(p => p.id === selectedFrequency)?.frequency); + + return ( +
+
+ +

Paiement en plusieurs fois

+
+
+ + {selectedFrequency !== null && selectedPaymentPlan && ( +
+
+
+ +
+ {isDefaultDayModified && defaultDay && ( + + )} +
+ +
+ )} + + setPopupVisible(false)} + onCancel={() => setPopupVisible(false)} + uniqueConfirmButton={true} + /> + + ); +}; + +export default PaymentPlanSelector; \ No newline at end of file diff --git a/Front-End/src/components/Popup.js b/Front-End/src/components/Popup.js index 1371455..261d63a 100644 --- a/Front-End/src/components/Popup.js +++ b/Front-End/src/components/Popup.js @@ -1,16 +1,45 @@ import React from 'react'; import ReactDOM from 'react-dom'; -const Popup = ({ visible, message, onConfirm, onCancel }) => { +const Popup = ({ visible, message, onConfirm, onCancel, uniqueConfirmButton = false }) => { if (!visible) return null; + // Vérifier si le message est une chaîne de caractères + const isStringMessage = typeof message === 'string'; + // Diviser le message en lignes seulement si c'est une chaîne + const messageLines = isStringMessage ? message.split('\n') : null; + return ReactDOM.createPortal(
-
-

{message}

-
- - +
+
+ {isStringMessage ? ( + // Afficher le message sous forme de lignes si c'est une chaîne + messageLines.map((line, index) => ( +

+ {line} +

+ )) + ) : ( + // Sinon, afficher directement le contenu React + message + )} +
+
+ {!uniqueConfirmButton && ( + + )} +
, diff --git a/Front-End/src/components/ProfileDirectory.js b/Front-End/src/components/ProfileDirectory.js new file mode 100644 index 0000000..2c6533b --- /dev/null +++ b/Front-End/src/components/ProfileDirectory.js @@ -0,0 +1,244 @@ +import React, { useState } from 'react'; +import { Trash2, Eye, EyeOff, ToggleLeft, ToggleRight, Info } from 'lucide-react'; +import Table from '@/components/Table'; +import Popup from '@/components/Popup'; +import StatusLabel from '@/components/StatusLabel'; +import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; +import Tooltip from '@/components/Tooltip'; + +const roleTypeToLabel = (roleType) => { + switch (roleType) { + case 0: + return 'ECOLE'; + case 1: + return 'ADMIN'; + case 2: + return 'PARENT'; + default: + return 'UNKNOWN'; + } +}; + +const roleTypeToBadgeClass = (roleType) => { + switch (roleType) { + case 0: + return 'bg-blue-100 text-blue-600'; + case 1: + return 'bg-red-100 text-red-600'; + case 2: + return 'bg-green-100 text-green-600'; + default: + return 'bg-gray-100 text-gray-600'; + } +}; + +const ProfileDirectory = ({ profileRoles, handleActivateProfile, handleDeleteProfile }) => { + const parentProfiles = profileRoles.filter(profileRole => profileRole.role_type === 2); + const schoolAdminProfiles = profileRoles.filter(profileRole => profileRole.role_type !== 2); + + const [popupVisible, setPopupVisible] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + const [confirmPopupVisible, setConfirmPopupVisible] = useState(false); + const [confirmPopupMessage, setConfirmPopupMessage] = useState(""); + const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {}); + + const handleConfirmActivateProfile = (profileRole) => { + setConfirmPopupMessage(`Êtes-vous sûr de vouloir ${profileRole.is_active ? 'désactiver' : 'activer'} ce profil ?`); + setConfirmPopupOnConfirm(() => () => { + handleActivateProfile(profileRole) + .then(() => { + setPopupMessage(`Le profil a été ${profileRole.is_active ? 'désactivé' : 'activé'} avec succès.`); + setPopupVisible(true); + }) + .catch(error => { + setPopupMessage(`Erreur lors de la ${profileRole.is_active ? 'désactivation' : 'activation'} du profil.`); + setPopupVisible(true); + }); + setConfirmPopupVisible(false); + }); + setConfirmPopupVisible(true); + }; + + const handleConfirmDeleteProfile = (id) => { + setConfirmPopupMessage("Êtes-vous sûr de vouloir supprimer ce profil ?"); + setConfirmPopupOnConfirm(() => () => { + handleDeleteProfile(id) + .then(() => { + setPopupMessage("Le profil a été supprimé avec succès."); + setPopupVisible(true); + }) + .catch(error => { + setPopupMessage("Erreur lors de la suppression du profil."); + setPopupVisible(true); + }); + setConfirmPopupVisible(false); + }); + setConfirmPopupVisible(true); + }; + + const parentColumns = [ + { name: 'Identifiant', transform: (row) => row.associated_profile_email }, + { name: 'Rôle', transform: (row) => ( + + {roleTypeToLabel(row.role_type)} + + ) + }, + { name: 'Utilisateur', transform: (row) => ( +
+ {row.associated_person?.guardian_name} + {row.associated_person && ( + +
+ Elève(s) associé(s): +
+ {row.associated_person?.students?.map(student => ( +
+ + {student.student_name} + + +
+ ))} +
+
+
+ }> + + + )} +
+ ) + }, + { + name: 'Actions', + transform: (row) => ( +
+ + +
+ ) + } + ]; + + const schoolAdminColumns = [ + { name: 'Identifiant', transform: (row) => row.associated_profile_email }, + { name: 'Rôle', transform: (row) => ( + + {roleTypeToLabel(row.role_type)} + + ) + }, + { name: 'Utilisateur', transform: (row) => ( +
+ {row.associated_person?.teacher_name} + {row.associated_person && ( + +
+ Classes associées: +
+ {row.associated_person?.classes?.map(classe => ( + + {classe.name} + + ))} +
+
+
+ Spécialités: +
+ {row.associated_person?.specialities?.map(speciality => ( + + ))} +
+
+
+ }> + + + )} +
+ ) + }, + { + name: 'Actions', + transform: (row) => ( +
+ + +
+ ) + } + ]; + + return ( +
+
+
+ {parentProfiles.length === 0 ? ( +
Aucun profil trouvé
+ ) : ( +
+ )} + +
+ {schoolAdminProfiles.length === 0 ? ( +
Aucun profil trouvé
+ ) : ( +
+ )} + + + setPopupVisible(false)} + uniqueConfirmButton={true} + /> + setConfirmPopupVisible(false)} + /> + + ); +}; + +export default ProfileDirectory; \ No newline at end of file diff --git a/Front-End/src/components/ProfileSelector.js b/Front-End/src/components/ProfileSelector.js new file mode 100644 index 0000000..5f86e1d --- /dev/null +++ b/Front-End/src/components/ProfileSelector.js @@ -0,0 +1,31 @@ +import React from 'react'; + +const ProfileSelector = ({ selectedProfile, setSelectedProfile }) => { + return ( +
+ + + +
+ ); +}; + +export default ProfileSelector; \ No newline at end of file diff --git a/Front-End/src/components/ProgressStep.js b/Front-End/src/components/ProgressStep.js new file mode 100644 index 0000000..312d133 --- /dev/null +++ b/Front-End/src/components/ProgressStep.js @@ -0,0 +1,157 @@ +import React, { useState, useEffect } from 'react'; + +const Step = ({ number, title, isActive, isValid, isCompleted, onClick }) => { + return ( +
+
+ {isCompleted ? ( + + + + ) : ( + number + )} +
+
+ + {title} + +
+
+ ); +}; + +const SpacerStep = ({ isCompleted }) => { + return ( +
+ ); +}; + +const Dots = () => { + return ( +
+ ... +
+ ... +
+
+ ); +}; + +const ProgressStep = ({ steps, stepTitles, currentStep, setStep, isStepValid }) => { + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + const [visibleSteps, setVisibleSteps] = useState(steps); + + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + const calculateVisibleSteps = () => { + const minWidth = 150; // Largeur minimale estimée par étape + const maxVisibleSteps = Math.floor(windowWidth / minWidth); + + if (maxVisibleSteps >= steps.length) { + setVisibleSteps(steps); + return; + } + + if (maxVisibleSteps < 4) { + // Garder seulement première, dernière et courante + let filtered = [steps[0]]; + if (currentStep > 1 && currentStep < steps.length) { + filtered.push('...'); + filtered.push(steps[currentStep - 1]); + } + if (currentStep < steps.length) { + filtered.push('...'); + } + filtered.push(steps[steps.length - 1]); + setVisibleSteps(filtered); + } else { + // Garder première, dernière, courante et quelques étapes adjacentes + let filtered = [steps[0]]; + if (currentStep > 2) filtered.push('...'); + if (currentStep > 1 && currentStep < steps.length) { + filtered.push(steps[currentStep - 1]); + } + if (currentStep < steps.length - 1) filtered.push('...'); + filtered.push(steps[steps.length - 1]); + setVisibleSteps(filtered); + } + }; + + calculateVisibleSteps(); + }, [windowWidth, currentStep, steps]); + + const handleStepClick = (stepIndex) => { + // Vérifie si on peut naviguer vers l'étape (toutes les étapes précédentes doivent être valides) + const canNavigate = Array.from({ length: stepIndex }, (_, i) => i + 1) + .every(step => isStepValid(step)); + + if (canNavigate) { + setStep(stepIndex + 1); + } + }; + + return ( +
+
+ {visibleSteps.map((step, index) => { + if (step === '...') { + return ( +
+ + {index !== visibleSteps.length - 1 && } +
+ ); + } + + const originalIndex = steps.indexOf(step); + return ( +
i + 1).every(s => isStepValid(s)) ? 'cursor-pointer' : 'cursor-not-allowed'} + `} + onClick={() => handleStepClick(originalIndex)} + > +
+
+ originalIndex + 1} + isValid={isStepValid(originalIndex + 1)} + /> + {index !== visibleSteps.length - 1 && ( + originalIndex + 1} /> + )} +
+
+
+ ); + })} +
+
+ ); +}; + +export default ProgressStep; diff --git a/Front-End/src/components/ProtectedRoute.js b/Front-End/src/components/ProtectedRoute.js new file mode 100644 index 0000000..fd13f76 --- /dev/null +++ b/Front-End/src/components/ProtectedRoute.js @@ -0,0 +1,33 @@ +import React, { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import Loader from '@/components/Loader'; // Importez le composant Loader +import { FE_USERS_LOGIN_URL } from '@/utils/Url'; + +const ProtectedRoute = ({ children, requiredRight }) => { + const { data: session, status } = useSession({ + required: true, + onUnauthenticated() { + router.push(`${FE_USERS_LOGIN_URL}`); + } + }); + const router = useRouter(); + + // Ne vérifier que si le statut est définitif + if (status === 'loading') { + return ; + } + + // Vérifier si l'utilisateur a au moins un rôle correspondant au requiredRight + const hasRequiredRight = session?.user?.roles?.some(role => role.role_type === requiredRight); + + if (session && requiredRight && !hasRequiredRight) { + router.push(`${FE_USERS_LOGIN_URL}`); + return null; + } + + // Autoriser l'affichage si authentifié et rôle correct + return session ? children : null; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/Front-End/src/components/Providers.js b/Front-End/src/components/Providers.js new file mode 100644 index 0000000..9c3bc62 --- /dev/null +++ b/Front-End/src/components/Providers.js @@ -0,0 +1,29 @@ +'use client' + +import { SessionProvider } from "next-auth/react" +import { CsrfProvider } from '@/context/CsrfContext' +import { NextIntlClientProvider } from 'next-intl' +import { EstablishmentProvider } from '@/context/EstablishmentContext'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; + + +export default function Providers({ children, messages, locale, session }) { + if (!locale) { + console.error('Locale non définie dans Providers'); + locale = 'fr'; // Valeur par défaut + } + return ( + + + + + + {children} + + + + + + ) +} \ No newline at end of file diff --git a/Front-End/src/components/RadioList.js b/Front-End/src/components/RadioList.js new file mode 100644 index 0000000..e50d3b4 --- /dev/null +++ b/Front-End/src/components/RadioList.js @@ -0,0 +1,33 @@ +import React from 'react'; + +const RadioList = ({ items, formData, handleChange, fieldName, icon: Icon, className }) => { + return ( +
+
+ {items.map(item => ( +
+ + +
+ ))} +
+
+ ); +}; + +export default RadioList; diff --git a/Front-End/src/components/SectionTitle.js b/Front-End/src/components/SectionTitle.js new file mode 100644 index 0000000..6f58e86 --- /dev/null +++ b/Front-End/src/components/SectionTitle.js @@ -0,0 +1,16 @@ +import React from 'react'; + +const SectionTitle = ({ title }) => { + return ( +
+
+
+
+
+
{title}
+
+
+ ); +}; + +export default SectionTitle; \ No newline at end of file diff --git a/Front-End/src/components/SelectChoice.js b/Front-End/src/components/SelectChoice.js index 7fd8783..6618396 100644 --- a/Front-End/src/components/SelectChoice.js +++ b/Front-End/src/components/SelectChoice.js @@ -1,20 +1,36 @@ -export default function SelectChoice({type, name, label, choices, callback, selected, error }) { - return ( - <> -
- - - {error &&

{error}

} -
- - ) +export default function SelectChoice({ type, name, label, required, placeHolder, choices, callback, selected, errorMsg, IconItem, disabled = false }) { + return ( + <> +
+ +
+ {IconItem && + + {} + + } + +
+ {errorMsg &&

{errorMsg}

} +
+ + ); } \ No newline at end of file diff --git a/Front-End/src/components/Sidebar.js b/Front-End/src/components/Sidebar.js index 3c673ea..8b52f19 100644 --- a/Front-End/src/components/Sidebar.js +++ b/Front-End/src/components/Sidebar.js @@ -1,6 +1,7 @@ 'use client' import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { useEstablishment } from '@/context/EstablishmentContext'; const SidebarItem = ({ icon: Icon, text, active, url, onClick }) => (
(
); -function Sidebar({ currentPage, items }) { +function Sidebar({ establishments, currentPage, items, onCloseMobile, onEstablishmentChange }) { const router = useRouter(); + const { selectedEstablishmentId, setSelectedEstablishmentId, setProfileRole } = useEstablishment(); const [selectedItem, setSelectedItem] = useState(currentPage); useEffect(() => { @@ -25,31 +27,49 @@ function Sidebar({ currentPage, items }) { const handleItemClick = (url) => { setSelectedItem(url); router.push(url); + if (onCloseMobile) { + onCloseMobile(); + } }; - return <> - {/* Sidebar */} -
+ const handleEstablishmentChange = (e) => { + const establishmentId = parseInt(e.target.value, 10); + setSelectedEstablishmentId(establishmentId); + const role = establishments.find(est => est.id === establishmentId)?.role_type; + setProfileRole(role); + onEstablishmentChange(establishmentId); + }; + + return ( +
-
Collège Saint-Joseph
+
- + ); } export default Sidebar; \ No newline at end of file diff --git a/Front-End/src/components/SidebarTabs.js b/Front-End/src/components/SidebarTabs.js new file mode 100644 index 0000000..ada4f20 --- /dev/null +++ b/Front-End/src/components/SidebarTabs.js @@ -0,0 +1,30 @@ +import React, { useState } from 'react'; + +const SidebarTabs = ({ tabs }) => { + const [activeTab, setActiveTab] = useState(tabs[0].id); + + return ( +
+
+ {tabs.map(tab => ( + + ))} +
+
+ {tabs.map(tab => ( +
+ {tab.content} +
+ ))} +
+
+ ); +}; + +export default SidebarTabs; \ No newline at end of file diff --git a/Front-End/src/components/Slider.js b/Front-End/src/components/Slider.js index d0fa221..d20584a 100644 --- a/Front-End/src/components/Slider.js +++ b/Front-End/src/components/Slider.js @@ -23,28 +23,30 @@ const Slider = ({ min, max, value, onChange }) => { return (
-
- {value[0]} - -
-
- {value[1]} - -
+
+
+ {value[0]} + +
+
+ {value[1]} + +
+
); }; diff --git a/Front-End/src/components/SpecialitiesSection.js b/Front-End/src/components/SpecialitiesSection.js deleted file mode 100644 index e451175..0000000 --- a/Front-End/src/components/SpecialitiesSection.js +++ /dev/null @@ -1,87 +0,0 @@ -import { BookOpen, Trash2, MoreVertical, Edit3, Plus } from 'lucide-react'; -import { useState } from 'react'; -import Table from '@/components/Table'; -import DropdownMenu from '@/components/DropdownMenu'; -import Modal from '@/components/Modal'; -import SpecialityForm from '@/components/SpecialityForm'; - -const SpecialitiesSection = ({ specialities, handleCreate, handleEdit, handleDelete }) => { - - const [isOpen, setIsOpen] = useState(false); - const [editingSpeciality, setEditingSpeciality] = useState(null); - - const openEditModal = (speciality) => { - setIsOpen(true); - setEditingSpeciality(speciality); - } - - const closeEditModal = () => { - setIsOpen(false); - setEditingSpeciality(null); - }; - - const handleModalSubmit = (updatedData) => { - if (editingSpeciality) { - handleEdit(editingSpeciality.id, updatedData); - } else { - handleCreate(updatedData); - } - closeEditModal(); - }; - - return ( -
-
-

- - Spécialités -

- -
-
-
row.nom.toUpperCase() }, - { name: 'CODE', transform: (row) => ( -
- )}, - { name: 'DATE DE CREATION', transform: (row) => row.dateCreation_formattee }, - { name: 'ACTIONS', transform: (row) => ( - } - items={[ - { label: 'Modifier', icon:Edit3, onClick: () => openEditModal(row) }, - { label: 'Supprimer', icon: Trash2, onClick: () => handleDelete(row.id) } - ] - } - buttonClassName="text-gray-400 hover:text-gray-600" - menuClassName="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10 flex flex-col items-center" - /> - )} - ]} - data={specialities} - /> - - {isOpen && ( - ( - - )} - /> - )} - - ); -}; - -export default SpecialitiesSection; diff --git a/Front-End/src/components/SpecialityForm.js b/Front-End/src/components/SpecialityForm.js deleted file mode 100644 index 9849fa5..0000000 --- a/Front-End/src/components/SpecialityForm.js +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from 'react'; - -const SpecialityForm = ({ speciality = {}, onSubmit, isNew }) => { - const [nom, setNom] = useState(speciality.nom || ''); - const [codeCouleur, setCodeCouleur] = useState(speciality.codeCouleur || '#FFFFFF'); - - const handleSubmit = () => { - const updatedData = { - nom, - codeCouleur, - }; - onSubmit(updatedData, isNew); - }; - - return ( -
-
- setNom(e.target.value)} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 italic" - /> -
-
- - setCodeCouleur(e.target.value)} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 h-10 w-10 p-0 cursor-pointer" - style={{ appearance: 'none', borderRadius: '0' }} - /> -
-
- -
-
- ); -}; - -export default SpecialityForm; diff --git a/Front-End/src/components/StatCard.js b/Front-End/src/components/StatCard.js new file mode 100644 index 0000000..823de62 --- /dev/null +++ b/Front-End/src/components/StatCard.js @@ -0,0 +1,16 @@ +// Composant StatCard pour afficher une statistique +const StatCard = ({ title, value, icon, color = "blue" }) => ( +
+
+
+

{title}

+

{value}

+
+
+ {icon} +
+
+
+); + +export default StatCard; \ No newline at end of file diff --git a/Front-End/src/components/StatusLabel.js b/Front-End/src/components/StatusLabel.js index 31e3e33..142c0e2 100644 --- a/Front-End/src/components/StatusLabel.js +++ b/Front-End/src/components/StatusLabel.js @@ -2,7 +2,7 @@ import { useState } from 'react'; import { ChevronUp } from 'lucide-react'; import DropdownMenu from './DropdownMenu'; -const StatusLabel = ({ etat, onChange, showDropdown = true }) => { +const StatusLabel = ({ status, onChange, showDropdown = true }) => { const [dropdownOpen, setDropdownOpen] = useState(false); const statusOptions = [ { value: 1, label: 'Créé' }, @@ -13,7 +13,7 @@ const StatusLabel = ({ etat, onChange, showDropdown = true }) => { { value: 6, label: 'Archivé' }, ]; - const currentStatus = statusOptions.find(option => option.value === etat); + const currentStatus = statusOptions.find(option => option.value === status); return ( <> {showDropdown ? ( @@ -29,12 +29,12 @@ const StatusLabel = ({ etat, onChange, showDropdown = true }) => { onClick: () => onChange(option.value), }))} buttonClassName={`w-[150px] flex items-center justify-center gap-2 px-2 py-2 rounded-md text-sm text-center font-medium ${ - etat === 1 && 'bg-blue-50 text-blue-600' || - etat === 2 && 'bg-orange-50 text-orange-600' || - etat === 3 && 'bg-purple-50 text-purple-600' || - etat === 4 && 'bg-red-50 text-red-600' || - etat === 5 && 'bg-green-50 text-green-600' || - etat === 6 && 'bg-red-50 text-red-600' + status === 1 && 'bg-blue-50 text-blue-600' || + status === 2 && 'bg-orange-50 text-orange-600' || + status === 3 && 'bg-purple-50 text-purple-600' || + status === 4 && 'bg-red-50 text-red-600' || + status === 5 && 'bg-green-50 text-green-600' || + status === 6 && 'bg-red-50 text-red-600' }`} menuClassName="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-md shadow-lg z-10" dropdownOpen={dropdownOpen} @@ -42,12 +42,12 @@ const StatusLabel = ({ etat, onChange, showDropdown = true }) => { /> ) : (
{currentStatus ? currentStatus.label : 'Statut inconnu'}
diff --git a/Front-End/src/components/Structure/Configuration/ClassesSection.js b/Front-End/src/components/Structure/Configuration/ClassesSection.js new file mode 100644 index 0000000..740d48f --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/ClassesSection.js @@ -0,0 +1,486 @@ +import { Trash2, Edit3, Plus, ZoomIn, Users, Check, X, Hand } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import Table from '@/components/Table'; +import Popup from '@/components/Popup'; +import InputText from '@/components/InputText'; +import SelectChoice from '@/components/SelectChoice'; +import TeacherItem from '@/components/Structure/Configuration/TeacherItem'; +import MultiSelect from '@/components/MultiSelect'; +import LevelLabel from '@/components/CustomLabels/LevelLabel'; +import { DndProvider, useDrop } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { ESTABLISHMENT_ID } from '@/utils/Url'; +import logger from '@/utils/logger'; +import ClasseDetails from '@/components/ClasseDetails'; + +const ItemTypes = { + TEACHER: 'teacher', +}; + +const TeachersDropZone = ({ classe, handleTeachersChange, teachers, isEditing }) => { + const [localTeachers, setLocalTeachers] = useState(classe.teachers_details || []); + + useEffect(() => { + }, [teachers]); + + useEffect(() => { + setLocalTeachers(classe.teachers_details || []); + }, [classe.teachers_details]); + + useEffect(() => { + handleTeachersChange(localTeachers.map(teacher => teacher.id)); + }, [localTeachers]); + + const [{ isOver, canDrop }, drop] = useDrop({ + accept: ItemTypes.TEACHER, + drop: (item) => { + const teacherDetails = teachers.find(teacher => teacher.id === item.id); + const exists = localTeachers.some(teacher => teacher.id === item.id); + if (!exists) { + setLocalTeachers(prevTeachers => { + const updatedTeachers = [ + ...prevTeachers, + { id: item.id, last_name: teacherDetails.last_name, first_name: teacherDetails.first_name } + ]; + return updatedTeachers; + }); + } + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + canDrop: !!monitor.canDrop(), + }), + canDrop: () => { + return isEditing; + }, + }); + + const handleRemoveTeacher = (id) => { + setLocalTeachers(prevTeachers => { + const updatedTeachers = prevTeachers.filter(teacher => teacher.id !== id); + return updatedTeachers; + }); + }; + + return ( +
+ {isEditing && ( +
+ {/* Ajoutez l'icône Hand */} + Déposez un enseignant ici +
+ )} + {localTeachers.map((teacher, index) => ( +
+ + {isEditing && ( + + )} +
+ ))} +
+ ); +}; + +const ClassesSection = ({ classes, setClasses, teachers, handleCreate, handleEdit, handleDelete }) => { + const [formData, setFormData] = useState({}); + const [editingClass, setEditingClass] = useState(null); + const [newClass, setNewClass] = useState(null); + const [localErrors, setLocalErrors] = useState({}); + const [popupVisible, setPopupVisible] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + const [removePopupVisible, setRemovePopupVisible] = useState(false); + const [removePopupMessage, setRemovePopupMessage] = useState(""); + const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); + const [detailsModalVisible, setDetailsModalVisible] = useState(false); + const [selectedClass, setSelectedClass] = useState(null); + + const niveauxPremierCycle = [ + { id: 1, name: 'TPS', age: 2 }, + { id: 2, name: 'PS', age: 3 }, + { id: 3, name: 'MS', age: 4 }, + { id: 4, name: 'GS', age: 5 }, + ]; + + const niveauxSecondCycle = [ + { id: 5, name: 'CP', age: 6 }, + { id: 6, name: 'CE1', age: 7 }, + { id: 7, name: 'CE2', age: 8 }, + ]; + + const niveauxTroisiemeCycle = [ + { id: 8, name: 'CM1', age: 9 }, + { id: 9, name: 'CM2', age: 10 }, + ]; + + const allNiveaux = [...niveauxPremierCycle, ...niveauxSecondCycle, ...niveauxTroisiemeCycle]; + + const getNiveauxLabels = (levels) => { + return levels.map(niveauId => { + const niveau = allNiveaux.find(n => n.id === niveauId); + return niveau ? niveau.name : niveauId; + }); + }; + + // Fonction pour générer les années scolaires + const getSchoolYearChoices = () => { + const currentDate = new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = currentDate.getMonth() + 1; // Les mois sont indexés à partir de 0 + + // Si nous sommes avant septembre, l'année scolaire en cours a commencé l'année précédente + const startYear = currentMonth >= 9 ? currentYear : currentYear - 1; + + const choices = []; + for (let i = 0; i < 3; i++) { + const year = startYear + i; + choices.push({ value: `${year}-${year + 1}`, label: `${year}-${year + 1}` }); + } + return choices; + }; + + // Récupération des messages d'erreur + const getError = (field) => { + return localErrors?.[field]?.[0]; + }; + + const handleAddClass = () => { + setNewClass({ id: Date.now(), atmosphere_name: '', age_range: '', levels: [], number_of_students: '', school_year: '', teachers: [], establishment: ESTABLISHMENT_ID }); + setFormData({ atmosphere_name: '', age_range: '', levels: [], number_of_students: '', school_year: '', teachers: [], establishment: ESTABLISHMENT_ID }); + }; + + const handleChange = (e) => { + const { name, value } = e.target; + + if (editingClass) { + setFormData((prevData) => ({ + ...prevData, + [name]: value, + })); + } else if (newClass) { + setNewClass((prevData) => ({ + ...prevData, + [name]: value, + })); + } + }; + + const handleSaveNewClass = () => { + if (newClass.atmosphere_name && newClass.age_range && newClass.levels.length > 0 && newClass.number_of_students && newClass.school_year) { + handleCreate(newClass) + .then((createdClass) => { + setClasses((prevClasses) => [createdClass, ...classes]); + setNewClass(null); + setLocalErrors({}); + }) + .catch((error) => { + logger.error('Error:', error.message); + if (error.details) { + logger.error('Form errors:', error.details); + setLocalErrors(error.details); + } + }); + } else { + setPopupMessage("Tous les champs doivent être remplis et valides"); + setPopupVisible(true); + } + }; + + const handleUpdateClass = (id, updatedData) => { + if (updatedData.atmosphere_name && updatedData.age_range && updatedData.levels.length > 0 && updatedData.number_of_students && updatedData.school_year) { + handleEdit(id, updatedData) + .then((updatedClass) => { + setClasses((prevClasses) => prevClasses.map((classe) => (classe.id === id ? updatedClass : classe))); + setEditingClass(null); + setFormData({}); + setLocalErrors({}); + }) + .catch((error) => { + logger.error('Error:', error.message); + if (error.details) { + logger.error('Form errors:', error.details); + setLocalErrors(error.details); + } + }); + } else { + setPopupMessage("Tous les champs doivent être remplis et valides"); + setPopupVisible(true); + } + }; + + const handleTeachersChange = (selectedTeachers) => { + if (editingClass) { + setFormData((prevData) => ({ + ...prevData, + teachers: selectedTeachers, + })); + } else if (newClass) { + setNewClass((prevData) => ({ + ...prevData, + teachers: selectedTeachers, + })); + setFormData((prevData) => ({ + ...prevData, + teachers: selectedTeachers, + })); + } + }; + + const handleMultiSelectChange = (selectedOptions) => { + const levels = selectedOptions.map(option => option.id); + + if (editingClass) { + setFormData((prevData) => ({ + ...prevData, + levels, + })); + } else if (newClass) { + setNewClass((prevData) => ({ + ...prevData, + levels, + })); + setFormData((prevData) => ({ + ...prevData, + levels, + })); + } + }; + + const openEditModalDetails = (classe) => { + setSelectedClass(classe); + setDetailsModalVisible(true); + }; + + const renderClassCell = (classe, column) => { + const isEditing = editingClass === classe.id; + const isCreating = newClass && newClass.id === classe.id; + const currentData = isEditing ? formData : newClass || {}; + + if (isEditing || isCreating) { + switch (column) { + case 'AMBIANCE': + return ( + + ); + case 'TRANCHE D\'AGE': + return ( + + ) + case 'NIVEAUX': + return ( + allNiveaux.find(level => level.id === levelId)) : []} + onChange={handleMultiSelectChange} + errorMsg={getError('levels')} + /> + ); + case 'CAPACITE': + return ( + + ) + case 'ANNÉE SCOLAIRE' : + return ( + + ) + case 'ENSEIGNANTS': + return ( + + ); + case 'ACTIONS': + return ( +
+ + +
+ ); + default: + return null; + } + } else { + switch (column) { + case 'AMBIANCE': + return classe.atmosphere_name; + case 'TRANCHE D\'AGE': + return classe.age_range; + case 'NIVEAUX': + const levelLabels = Array.isArray(classe.levels) ? getNiveauxLabels(classe.levels) : []; + return ( +
+ {levelLabels.length > 0 + ? levelLabels.map((label, index) => ( + + )) + : 'Aucun niveau'} +
+ ); + case 'CAPACITE': + return classe.number_of_students; + case 'ANNÉE SCOLAIRE' : + return classe.school_year; + case 'ENSEIGNANTS': + return ( +
+ {classe.teachers_details.map((teacher) => ( + + ))} +
+ ); + case 'MISE A JOUR': + return classe.updated_date_formatted; + case 'ACTIONS': + return ( +
+ + + +
+ ); + default: + return null; + } + } + }; + + const columns = [ + { name: 'AMBIANCE', label: 'Nom d\'ambiance' }, + { name: 'TRANCHE D\'AGE', label: 'Tranche d\'âge' }, + { name: 'NIVEAUX', label: 'Niveaux' }, + { name: 'CAPACITE', label: 'Capacité max' }, + { name: 'ANNÉE SCOLAIRE', label: 'Année scolaire' }, + { name: 'ENSEIGNANTS', label: 'Enseignants' }, + { name: 'MISE A JOUR', label: 'Date mise à jour' }, + { name: 'ACTIONS', label: 'Actions' } + ]; + + return ( + +
+
+
+ +

Classes

+
+ +
+
+ : null} + onConfirm={() => setDetailsModalVisible(false)} + onCancel={() => setDetailsModalVisible(false)} + uniqueConfirmButton={true} + /> + setPopupVisible(false)} + onCancel={() => setPopupVisible(false)} + uniqueConfirmButton={true} + /> + setRemovePopupVisible(false)} + /> + + + ); +}; + +export default ClassesSection; \ No newline at end of file diff --git a/Front-End/src/components/Structure/Configuration/DateRange.js b/Front-End/src/components/Structure/Configuration/DateRange.js new file mode 100644 index 0000000..308622c --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/DateRange.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { Calendar } from 'lucide-react'; + +const DateRange = ({ nameStart, nameEnd, valueStart, valueEnd, onChange, label }) => { + return ( +
+ +
+
+ Du + + +
+
+ Au + + +
+
+
+ ); +}; + +export default DateRange; diff --git a/Front-End/src/components/Structure/Configuration/PlanningConfiguration.js b/Front-End/src/components/Structure/Configuration/PlanningConfiguration.js new file mode 100644 index 0000000..8582362 --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/PlanningConfiguration.js @@ -0,0 +1,113 @@ +import React from 'react'; +import RadioList from '@/components/RadioList'; +import DateRange from '@/components/Structure/Configuration/DateRange'; +import TimeRange from '@/components/Structure/Configuration/TimeRange'; +import CheckBoxList from '@/components/CheckBoxList'; + +const PlanningConfiguration = ({ formData, handleChange, handleTimeChange, handleJoursChange, typeEmploiDuTemps }) => { + const daysOfWeek = [ + { id: 1, name: 'lun' }, + { id: 2, name: 'mar' }, + { id: 3, name: 'mer' }, + { id: 4, name: 'jeu' }, + { id: 5, name: 'ven' }, + { id: 6, name: 'sam' }, + ]; + + const isLabelAttenuated = (item) => { + return !formData.opening_days.includes(parseInt(item.id)); + }; + + return ( +
+ + +
+
+ +
+ + {/* Plage horaire */} +
+ handleTimeChange(e, 0)} + onEndChange={(e) => handleTimeChange(e, 1)} + /> + + {/* CheckBoxList */} + +
+
+ + {/* DateRange */} +
+ {formData.type === 2 && ( + <> + + + + )} + + {formData.type === 3 && ( + <> + + + + + )} +
+
+ ); +}; + +export default PlanningConfiguration; diff --git a/Front-End/src/components/Structure/Configuration/SpecialitiesSection.js b/Front-End/src/components/Structure/Configuration/SpecialitiesSection.js new file mode 100644 index 0000000..2192ed3 --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/SpecialitiesSection.js @@ -0,0 +1,241 @@ +import { Plus, Trash2, Edit3, Check, X, BookOpen } from 'lucide-react'; +import { useState } from 'react'; +import Table from '@/components/Table'; +import Popup from '@/components/Popup'; +import InputTextWithColorIcon from '@/components/InputTextWithColorIcon'; +import { DndProvider } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; +import logger from '@/utils/logger'; + +const SpecialitiesSection = ({ specialities, setSpecialities, handleCreate, handleEdit, handleDelete }) => { + + const [newSpeciality, setNewSpeciality] = useState(null); + const [editingSpeciality, setEditingSpeciality] = useState(null); + const [formData, setFormData] = useState({}); + const [localErrors, setLocalErrors] = useState({}); + const [popupVisible, setPopupVisible] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + + const [removePopupVisible, setRemovePopupVisible] = useState(false); + const [removePopupMessage, setRemovePopupMessage] = useState(""); + const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); + + // Récupération des messages d'erreur + const getError = (field) => { + return localErrors?.[field]?.[0]; + }; + + const handleAddSpeciality = () => { + setNewSpeciality({ id: Date.now(), name: '', color_code: '' }); + }; + + const handleRemoveSpeciality = (id) => { + return handleDelete(id) + .then(() => { + setSpecialities(prevSpecialities => prevSpecialities.filter(speciality => speciality.id !== id)); + }) + .catch(error => { + logger.error(error); + }); + }; + + const handleSaveNewSpeciality = () => { + if ( + newSpeciality.name) { + handleCreate(newSpeciality) + .then((createdSpeciality) => { + setSpecialities([createdSpeciality, ...specialities]); + setNewSpeciality(null); + setLocalErrors({}); + }) + .catch((error) => { + logger.error('Error:', error.message); + if (error.details) { + logger.error('Form errors:', error.details); + setLocalErrors(error.details); + } + }); + } else { + setPopupMessage("Tous les champs doivent être remplis et valides"); + setPopupVisible(true); + } + }; + + const handleUpdateSpeciality = (id, updatedSpeciality) => { + if ( + updatedSpeciality.name) { + handleEdit(id, updatedSpeciality) + .then((updatedSpeciality) => { + setSpecialities(specialities.map(speciality => speciality.id === id ? updatedSpeciality : speciality)); + setEditingSpeciality(null); + setLocalErrors({}); + }) + .catch((error) => { + logger.error('Error:', error.message); + if (error.details) { + logger.error('Form errors:', error.details); + setLocalErrors(error.details); + } + }); + } else { + setPopupMessage("Tous les champs doivent être remplis et valides"); + setPopupVisible(true); + } + }; + + const handleChange = (e) => { + const { name, value } = e.target; + let parsedValue = value; + if (name.includes('_color')) { + parsedValue = value; + } + + const fieldName = name.includes('_color') ? 'color_code' : name; + if (editingSpeciality) { + setFormData((prevData) => ({ + ...prevData, + [fieldName]: parsedValue, + })); + } else if (newSpeciality) { + setNewSpeciality((prevData) => ({ + ...prevData, + [fieldName]: parsedValue, + })); + } + }; + + const renderSpecialityCell = (speciality, column) => { + const isEditing = editingSpeciality === speciality.id; + const isCreating = newSpeciality && newSpeciality.id === speciality.id; + const currentData = isEditing ? formData : newSpeciality; + + if (isEditing || isCreating) { + switch (column) { + case 'LIBELLE': + return ( + + ); + case 'ACTIONS': + return ( +
+ + +
+ ); + default: + return null; + } + } else { + switch (column) { + case 'LIBELLE': + return ( + + ); + case 'MISE A JOUR': + return speciality.updated_date_formatted; + case 'ACTIONS': + return ( +
+ + +
+ ); + default: + return null; + } + } + }; + + const columns = [ + { name: 'LIBELLE', label: 'Libellé' }, + { name: 'MISE A JOUR', label: 'Date mise à jour' }, + { name: 'ACTIONS', label: 'Actions' } + ]; + + return ( + +
+
+
+ +

Spécialités

+
+ +
+
+ setPopupVisible(false)} + onCancel={() => setPopupVisible(false)} + uniqueConfirmButton={true} + /> + setRemovePopupVisible(false)} + /> + + + ); +}; + +export default SpecialitiesSection; diff --git a/Front-End/src/components/Structure/Configuration/SpecialityItem.js b/Front-End/src/components/Structure/Configuration/SpecialityItem.js new file mode 100644 index 0000000..6975b5d --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/SpecialityItem.js @@ -0,0 +1,54 @@ +import { useDrag } from 'react-dnd'; +import React from 'react'; + +const ItemTypes = { + SPECIALITY: 'speciality', +}; + +const lightenColor = (color, percent) => { + const num = parseInt(color.slice(1), 16), + amt = Math.round(2.55 * percent), + R = (num >> 16) + amt, + G = (num >> 8 & 0x00FF) + amt, + B = (num & 0x0000FF) + amt; + return `#${(0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1).toUpperCase()}`; +}; + +const darkenColor = (color, percent) => { + const num = parseInt(color.slice(1), 16), + amt = Math.round(2.55 * percent), + R = (num >> 16) - amt, + G = (num >> 8 & 0x00FF) - amt, + B = (num & 0x0000FF) - amt; + return `#${(0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1).toUpperCase()}`; +}; + +const SpecialityItem = ({ speciality, isDraggable = true }) => { + const [{ isDragging }, drag] = useDrag(() => ({ + type: ItemTypes.SPECIALITY, + item: { id: speciality.id, name: speciality.name }, + collect: (monitor) => ({ + isDragging: !!monitor.isDragging(), + }), + canDrag: () => isDraggable, + }), [isDraggable]); + + return ( +
+ {speciality.name} +
+ ); +}; + +export default SpecialityItem; \ No newline at end of file diff --git a/Front-End/src/components/Structure/Configuration/StructureManagement.js b/Front-End/src/components/Structure/Configuration/StructureManagement.js new file mode 100644 index 0000000..dd2378e --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/StructureManagement.js @@ -0,0 +1,46 @@ +import React, { useState, useEffect } from 'react'; +import SpecialitiesSection from '@/components/Structure/Configuration/SpecialitiesSection'; +import TeachersSection from '@/components/Structure/Configuration/TeachersSection'; +import ClassesSection from '@/components/Structure/Configuration/ClassesSection'; +import { ClassesProvider } from '@/context/ClassesContext'; +import { BE_SCHOOL_SPECIALITIES_URL, BE_SCHOOL_TEACHERS_URL, BE_SCHOOL_SCHOOLCLASSES_URL } from '@/utils/Url'; + +const StructureManagement = ({ specialities, setSpecialities, teachers, setTeachers, classes, setClasses, handleCreate, handleEdit, handleDelete }) => { + return ( +
+ +
+ handleCreate(`${BE_SCHOOL_SPECIALITIES_URL}`, newData, setSpecialities)} + handleEdit={(id, updatedData) => handleEdit(`${BE_SCHOOL_SPECIALITIES_URL}`, id, updatedData, setSpecialities)} + handleDelete={(id) => handleDelete(`${BE_SCHOOL_SPECIALITIES_URL}`, id, setSpecialities)} + /> +
+
+ handleCreate(`${BE_SCHOOL_TEACHERS_URL}`, newData, setTeachers)} + handleEdit={(id, updatedData) => handleEdit(`${BE_SCHOOL_TEACHERS_URL}`, id, updatedData, setTeachers)} + handleDelete={(id) => handleDelete(`${BE_SCHOOL_TEACHERS_URL}`, id, setTeachers)} + /> +
+
+ handleCreate(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, newData, setClasses)} + handleEdit={(id, updatedData) => handleEdit(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, updatedData, setClasses)} + handleDelete={(id) => handleDelete(`${BE_SCHOOL_SCHOOLCLASSES_URL}`, id, setClasses)} + /> +
+
+
+ ); +}; + +export default StructureManagement; diff --git a/Front-End/src/components/Structure/Configuration/TabsStructure.js b/Front-End/src/components/Structure/Configuration/TabsStructure.js new file mode 100644 index 0000000..1e33d5d --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/TabsStructure.js @@ -0,0 +1,23 @@ +import React from 'react'; + +const TabsStructure = ({ activeTab, setActiveTab, tabs }) => { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +}; + +export default TabsStructure; + + + diff --git a/Front-End/src/components/Structure/Configuration/TeacherItem.js b/Front-End/src/components/Structure/Configuration/TeacherItem.js new file mode 100644 index 0000000..a0d6287 --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/TeacherItem.js @@ -0,0 +1,35 @@ +import { useDrag } from 'react-dnd'; +import React from 'react'; + +const ItemTypes = { + TEACHER: 'teacher', +}; + +const TeacherItem = ({ teacher, isDraggable = true }) => { + const [{ isDragging }, drag] = useDrag(() => ({ + type: ItemTypes.TEACHER, + item: { id: teacher.id, name: `${teacher.last_name} ${teacher.first_name}` }, + collect: (monitor) => ({ + isDragging: !!monitor.isDragging(), + }), + canDrag: () => isDraggable, + }), [isDraggable]); + + return ( +
+ {teacher.last_name} {teacher.first_name} +
+ ); +}; + +export default TeacherItem; \ No newline at end of file diff --git a/Front-End/src/components/Structure/Configuration/TeachersSection.js b/Front-End/src/components/Structure/Configuration/TeachersSection.js new file mode 100644 index 0000000..eded255 --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/TeachersSection.js @@ -0,0 +1,442 @@ +import React, { useState, useEffect } from 'react'; +import { Plus, Edit3, Trash2, GraduationCap, Check, X, Hand, Search } from 'lucide-react'; +import Table from '@/components/Table'; +import Popup from '@/components/Popup'; +import ToggleSwitch from '@/components/ToggleSwitch'; +import { createProfile, updateProfile } from '@/app/actions/authAction'; +import { useCsrfToken } from '@/context/CsrfContext'; +import { DndProvider, useDrag, useDrop } from 'react-dnd'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import InputText from '@/components/InputText'; +import SpecialityItem from '@/components/Structure/Configuration/SpecialityItem'; +import TeacherItem from './TeacherItem'; +import logger from '@/utils/logger'; +import { fetchProfiles } from '@/app/actions/authAction'; +import { useEstablishment } from '@/context/EstablishmentContext'; + +const ItemTypes = { + SPECIALITY: 'speciality', +}; + +const SpecialitiesDropZone = ({ teacher, handleSpecialitiesChange, specialities, isEditing }) => { + const [localSpecialities, setLocalSpecialities] = useState(teacher.specialities_details || []); + + useEffect(() => { + }, [specialities]); + + useEffect(() => { + setLocalSpecialities(teacher.specialities_details || []); + }, [teacher.specialities_details]); + + useEffect(() => { + handleSpecialitiesChange(localSpecialities.map(speciality => speciality.id)); + }, [localSpecialities]); + + const [{ isOver, canDrop }, drop] = useDrop({ + accept: ItemTypes.SPECIALITY, + drop: (item) => { + const specialityDetails = specialities.find(speciality => speciality.id === item.id); + const exists = localSpecialities.some(speciality => speciality.id === item.id); + if (!exists) { + setLocalSpecialities(prevSpecialities => { + const updatedSpecialities = [ + ...prevSpecialities, + { id: item.id, name: specialityDetails.name, color_code: specialityDetails.color_code } + ]; + return updatedSpecialities; + }); + } + }, + collect: (monitor) => ({ + isOver: !!monitor.isOver(), + canDrop: !!monitor.canDrop(), + }), + canDrop: () => { + return isEditing; + }, + }); + + const handleRemoveSpeciality = (id) => { + setLocalSpecialities(prevSpecialities => { + const updatedSpecialities = prevSpecialities.filter(speciality => speciality.id !== id); + return updatedSpecialities; + }); + }; + + return ( +
+ {isEditing && ( +
+ {/* Ajoutez l'icône Hand */} + Déposez une spécialité ici +
+ )} + {localSpecialities.map((speciality, index) => ( +
+ + {isEditing && ( + + )} +
+ ))} +
+ ); +}; + +const TeachersSection = ({ teachers, setTeachers, specialities, handleCreate, handleEdit, handleDelete }) => { + const csrfToken = useCsrfToken(); + const [editingTeacher, setEditingTeacher] = useState(null); + const [newTeacher, setNewTeacher] = useState(null); + const [formData, setFormData] = useState({}); + const [localErrors, setLocalErrors] = useState({}); + const [popupVisible, setPopupVisible] = useState(false); + const [popupMessage, setPopupMessage] = useState(""); + + const [removePopupVisible, setRemovePopupVisible] = useState(false); + const [removePopupMessage, setRemovePopupMessage] = useState(""); + const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); + + const [confirmPopupVisible, setConfirmPopupVisible] = useState(false); + const [confirmPopupMessage, setConfirmPopupMessage] = useState(""); + const [confirmPopupOnConfirm, setConfirmPopupOnConfirm] = useState(() => {}); + + const { selectedEstablishmentId } = useEstablishment(); + + const handleCancelConfirmation = () => { + setConfirmPopupVisible(false); + }; + + // Récupération des messages d'erreur + const getError = (field) => { + return localErrors?.[field]?.[0]; + }; + + const handleAddTeacher = () => { + setNewTeacher({ id: Date.now(), last_name: '', first_name: '', associated_profile_email: '', specialities: [], role_type: 0 }); + setFormData({ last_name: '', first_name: '', associated_profile_email: '', specialities: [], role_type: 0}); + }; + + const handleRemoveTeacher = (id) => { + return handleDelete(id) + .then(() => { + setTeachers(prevTeachers => prevTeachers.filter(teacher => teacher.id !== id)); + }) + .catch(error => { + logger.error(error); + }); + }; + + const handleSaveNewTeacher = () => { + if (formData.last_name && formData.first_name && formData.associated_profile_email) { + const data = { + last_name: formData.last_name, + first_name: formData.first_name, + associated_profile_email: formData.associated_profile_email, + establishment: selectedEstablishmentId, + role_type: formData.role_type, + specialities: formData.specialities + }; + + handleCreate(data) + .then((createdTeacher) => { + setTeachers([createdTeacher, ...teachers]); + setNewTeacher(null); + setLocalErrors({}); + }) + .catch((error) => { + logger.error('Error:', error.message); + if (error.details) { + logger.error('Form errors:', error.details); + setLocalErrors(error.details); + } + }); + } else { + setPopupMessage("Tous les champs doivent être remplis et valides"); + setPopupVisible(true); + } + }; + + const handleUpdateTeacher = (id, updatedData) => { + if (updatedData.last_name && updatedData.first_name && updatedData.associated_profile_email_display) { + const data = { + last_name: formData.last_name, + first_name: formData.first_name, + associated_profile_email: formData.associated_profile_email || formData.associated_profile_email_display, + establishment: selectedEstablishmentId, + role_type: formData.role_type, + specialities: formData.specialities + }; + handleEdit(id, data) + .then((updatedTeacher) => { + setTeachers(prevTeachers => prevTeachers.map(teacher => teacher.id === id ? { ...teacher, ...updatedTeacher } : teacher)); + setEditingTeacher(null); + setFormData({}); + }) + .catch((error) => { + logger.error('Error:', error.message); + if (error.details) { + logger.error('Form errors:', error.details); + setLocalErrors(error.details); + } + }); + } + else { + setPopupMessage("Tous les champs doivent être remplis et valides"); + setPopupVisible(true); + } + }; + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + let parsedValue = value; + + if (type === 'checkbox') { + parsedValue = checked ? 1 : 0; + } + + if (editingTeacher) { + setFormData((prevData) => ({ + ...prevData, + [name]: parsedValue, + })); + } else if (newTeacher) { + setNewTeacher((prevData) => ({ + ...prevData, + [name]: parsedValue, + })); + setFormData((prevData) => ({ + ...prevData, + [name]: parsedValue, + })); + } + }; + + const handleSpecialitiesChange = (selectedSpecialities) => { + if (editingTeacher) { + setFormData((prevData) => ({ + ...prevData, + specialities: selectedSpecialities, + })); + } else if (newTeacher) { + setNewTeacher((prevData) => ({ + ...prevData, + specialities: selectedSpecialities, + })); + setFormData((prevData) => ({ + ...prevData, + specialities: selectedSpecialities, + })); + } + }; + + const handleEditTeacher = (teacher) => { + setEditingTeacher(teacher.id); + setFormData({ + ...teacher, + associated_profile_email: teacher.associated_profile_email_display, + role_type: teacher.role_type_display, + }); + }; + + const renderTeacherCell = (teacher, column) => { + const isEditing = editingTeacher === teacher.id; + const isCreating = newTeacher && newTeacher.id === teacher.id; + const currentData = isEditing ? formData : newTeacher; + + if (isEditing || isCreating) { + switch (column) { + case 'NOM - PRENOM': + return ( +
+ + +
+ ); + case 'EMAIL': + return ( + + ); + case 'SPECIALITES': + return ( + + ); + case 'ADMINISTRATEUR': + return ( +
+ +
+ ); + case 'ACTIONS': + return ( +
+ + +
+ ); + default: + return null; + } + } else { + switch (column) { + case 'NOM - PRENOM': + return ( + + ); + case 'EMAIL': + return teacher.associated_profile_email_display; + case 'SPECIALITES': + return ( +
+ {teacher.specialities_details.map((speciality) => ( + + ))} +
+ ); + case 'ADMINISTRATEUR': + if (teacher.associated_profile_email_display) { + const badgeClass = teacher.role_type_display === 1 ? 'bg-red-100 text-red-600' : 'bg-blue-100 text-blue-600'; + const label = teacher.role_type_display === 1 ? 'OUI' : 'NON'; + return ( +
+ + {label} + +
+ ); + } else { + return Non définie; + }; + case 'MISE A JOUR': + return teacher.updated_date_formatted; + case 'ACTIONS': + return ( +
+ + +
+ ); + default: + return null; + } + } + }; + + const columns = [ + { name: 'NOM - PRENOM', label: 'Nom et prénom' }, + { name: 'EMAIL', label: 'Email' }, + { name: 'SPECIALITES', label: 'Spécialités' }, + { name: 'ADMINISTRATEUR', label: 'Profil' }, + { name: 'MISE A JOUR', label: 'Mise à jour' }, + { name: 'ACTIONS', label: 'Actions' } + ]; + + return ( + +
+
+
+ +

Enseignants

+
+ +
+
+ setPopupVisible(false)} + onCancel={() => setPopupVisible(false)} + uniqueConfirmButton={true} + /> + setRemovePopupVisible(false)} + /> + + + ); +}; + +export default TeachersSection; diff --git a/Front-End/src/components/Structure/Configuration/TeachersSelectionConfiguration.js b/Front-End/src/components/Structure/Configuration/TeachersSelectionConfiguration.js new file mode 100644 index 0000000..05969a5 --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/TeachersSelectionConfiguration.js @@ -0,0 +1,46 @@ +import React from 'react'; +import Table from '@/components/Table'; + +const TeachersSelectionConfiguration = ({ formData, teachers, handleTeacherSelection, selectedTeachers }) => { + return ( +
+ + +
row.last_name, + }, + { + name: 'Prénom', + transform: (row) => row.first_name, + }, + // { + // name: 'Spécialités', + // transform: (row) => ( + //
+ // {row.specialites.map(specialite => ( + // + //
+ // {specialite.nom} + //
+ // ))} + //
+ // ), + // }, + ]} + data={teachers} + onRowClick={handleTeacherSelection} + selectedRows={selectedTeachers} + isSelectable={true} + /> + + ); +}; + +export default TeachersSelectionConfiguration; diff --git a/Front-End/src/components/Structure/Configuration/TimeRange.js b/Front-End/src/components/Structure/Configuration/TimeRange.js new file mode 100644 index 0000000..b7d3e95 --- /dev/null +++ b/Front-End/src/components/Structure/Configuration/TimeRange.js @@ -0,0 +1,32 @@ +import React from 'react'; + +const TimeRange = ({ startTime, endTime, onStartChange, onEndChange }) => { + return ( +
+
+
+ + +
+
+ + +
+
+
+ ); +}; + +export default TimeRange; diff --git a/Front-End/src/components/Structure/Files/FileUpload.js b/Front-End/src/components/Structure/Files/FileUpload.js new file mode 100644 index 0000000..21e9d2d --- /dev/null +++ b/Front-End/src/components/Structure/Files/FileUpload.js @@ -0,0 +1,184 @@ +import React, { useState, useEffect } from 'react'; +import ToggleSwitch from '@/components/ToggleSwitch'; // Import du composant ToggleSwitch +import { fetchRegistrationFileGroups, createRegistrationTemplates, cloneTemplate } from '@/app/actions/registerFileGroupAction'; +import { DocusealBuilder } from '@docuseal/react'; +import logger from '@/utils/logger'; +import { BE_DOCUSEAL_GET_JWT, BASE_URL } from '@/utils/Url'; +import Button from '@/components/Button'; // Import du composant Button +import MultiSelect from '@/components/MultiSelect'; // Import du composant MultiSelect +import { useCsrfToken } from '@/context/CsrfContext'; +import { useEstablishment } from '@/context/EstablishmentContext'; + +export default function FileUpload({ handleCreateTemplateMaster, handleEditTemplateMaster, fileToEdit = null, onSuccess }) { + const [isRequired, setIsRequired] = useState(false); // État pour le toggle isRequired + const [order, setOrder] = useState(0); + const [groups, setGroups] = useState([]); + const [token, setToken] = useState(null); + const [templateMaster, setTemplateMaster] = useState(null); + const [uploadedFileName, setUploadedFileName] = useState(''); + const [selectedGroups, setSelectedGroups] = useState([]); + const [guardianDetails, setGuardianDetails] = useState([]); + + const csrfToken = useCsrfToken(); + + const { selectedEstablishmentId } = useEstablishment(); + + useEffect(() => { + fetchRegistrationFileGroups(selectedEstablishmentId).then(data => setGroups(data)); + + if (fileToEdit) { + setUploadedFileName(fileToEdit.name || ''); + setSelectedGroups(fileToEdit.groups || []); + } + }, [fileToEdit]); + + useEffect(() => { + const body = fileToEdit + ? JSON.stringify({ + user_email: 'n3wt.school@gmail.com', + id: fileToEdit.id + }) + : JSON.stringify({ + user_email: 'n3wt.school@gmail.com' + }); + + fetch('/api/docuseal/generateToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: body, + }) + .then((response) => response.json()) + .then((data) => { + setToken(data.token); + }) + .catch((error) => console.error(error)); + }, [fileToEdit]); + + const handleFileNameChange = (event) => { + setUploadedFileName(event.target.value); + }; + + const handleGroupChange = (selectedGroups) => { + setSelectedGroups(selectedGroups); + + const details = selectedGroups.flatMap(group => + group.registration_forms.flatMap(form => + form.guardians.map(guardian => ({ + email: guardian.email, + last_name: form.last_name, + first_name: form.first_name, + registration_form: form.student_id + })) + ) + ); + setGuardianDetails(details); // Mettre à jour la variable d'état avec les détails des guardians + }; + + const handleLoad = (detail) => { + const templateId = detail?.id; + logger.debug('loading template id : ', detail) + setTemplateMaster(detail); + } + + const handleUpload = (detail) => { + logger.debug('Uploaded file detail:', detail); + setUploadedFileName(detail.name); + }; + + const handleChange = (detail) => { + logger.debug(detail) + setUploadedFileName(detail.name); + } + + const handleSubmit = (data) => { + const is_required = (data.fields.length > 0) + if (fileToEdit) { + logger.debug('Modification du template master:', templateMaster?.id); + handleEditTemplateMaster({ + name: uploadedFileName, + group_ids: selectedGroups.map(group => group.id), + id: templateMaster?.id, + is_required: is_required + }); + } else { + logger.debug('Création du template master:', templateMaster?.id); + handleCreateTemplateMaster({ + name: uploadedFileName, + group_ids: selectedGroups.map(group => group.id), + id: templateMaster?.id, + is_required: is_required + }); + + guardianDetails.forEach((guardian, index) => { + logger.debug('creation du clone avec required : ', is_required) + cloneTemplate(templateMaster?.id, guardian.email, is_required) + .then(clonedDocument => { + // Sauvegarde des templates clonés dans la base de données + const data = { + name: `${uploadedFileName}_${guardian.first_name}_${guardian.last_name}`, + slug: clonedDocument.slug, + id: clonedDocument.id, + master: templateMaster?.id, + registration_form: guardian.registration_form + }; + createRegistrationTemplates(data, csrfToken) + .then(response => { + logger.debug('Template enregistré avec succès:', response); + onSuccess(); + }) + .catch(error => { + logger.error('Erreur lors de l\'enregistrement du template:', error); + }); + + // Logique pour envoyer chaque template au submitter + logger.debug('Sending template to:', guardian.email); + }) + .catch(error => { + logger.error('Error during cloning or sending:', error); + }); + }); + } + }; + + return ( +
+
+
+ +
+
+ {token && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/Front-End/src/components/Structure/Files/FilesGroupsManagement.js b/Front-End/src/components/Structure/Files/FilesGroupsManagement.js new file mode 100644 index 0000000..38289dc --- /dev/null +++ b/Front-End/src/components/Structure/Files/FilesGroupsManagement.js @@ -0,0 +1,360 @@ +import React, { useState, useEffect } from 'react'; +import { Plus, Download, Edit3, Trash2, FolderPlus, Signature } from 'lucide-react'; +import Modal from '@/components/Modal'; +import Table from '@/components/Table'; +import FileUpload from '@/components/Structure/Files/FileUpload'; +import { BASE_URL } from '@/utils/Url'; +import { + fetchRegistrationFileGroups, + createRegistrationFileGroup, + deleteRegistrationFileGroup, + editRegistrationFileGroup, + fetchRegistrationTemplateMaster, + createRegistrationTemplateMaster, + editRegistrationTemplateMaster, + deleteRegistrationTemplateMaster, + fetchRegistrationTemplates +} from '@/app/actions/registerFileGroupAction'; +import RegistrationFileGroupForm from '@/components/Structure/Files/RegistrationFileGroupForm'; +import logger from '@/utils/logger'; + +export default function FilesGroupsManagement({ csrfToken, selectedEstablishmentId }) { + const [templateMasters, setTemplateMasters] = useState([]); + const [templates, setTemplates] = useState([]); + const [groups, setGroups] = useState([]); + const [selectedGroup, setSelectedGroup] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [fileToEdit, setFileToEdit] = useState(null); + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [groupToEdit, setGroupToEdit] = useState(null); + const [reloadTemplates, setReloadTemplates] = useState(false); + + const handleReloadTemplates = () => { + setReloadTemplates(true); + } + + const transformFileData = (file, groups) => { + const groupInfos = file.groups.map(groupId => groups.find(g => g.id === groupId) || { id: groupId, name: 'Groupe inconnu' }); + return { + ...file, + groups: groupInfos + }; + }; + + useEffect(() => { + if (selectedEstablishmentId) { + Promise.all([ + fetchRegistrationTemplateMaster(), + fetchRegistrationFileGroups(selectedEstablishmentId), + fetchRegistrationTemplates() + ]).then(([filesTemplateMasters, groupsData, filesTemplates]) => { + setGroups(groupsData); + setTemplates(filesTemplates); + // Transformer chaque fichier pour inclure les informations complètes du groupe + const transformedFiles = filesTemplateMasters.map(file => transformFileData(file, groupsData)); + setTemplateMasters(transformedFiles); + }).catch(err => { + console.log(err.message); + }).finally(() => { + setReloadTemplates(false); + }); + } + }, [reloadTemplates, selectedEstablishmentId]); + + const deleteTemplateMaster = (templateMaster) => { + // Supprimer les clones associés via l'API DocuSeal + const removeClonesPromises = templates + .filter(template => template.master === templateMaster.id) + .map(template => removeTemplate(template.id)); + + // Ajouter la suppression du master à la liste des promesses + removeClonesPromises.push(removeTemplate(templateMaster.id)); + + // Attendre que toutes les suppressions dans DocuSeal soient terminées + Promise.all(removeClonesPromises) + .then(responses => { + const allSuccessful = responses.every(response => response.ok); + if (allSuccessful) { + logger.debug('Master et clones supprimés avec succès de DocuSeal.'); + + // Supprimer le template master de la base de données + deleteRegistrationTemplateMaster(templateMaster.id, csrfToken) + .then(response => { + if (response.ok) { + setTemplateMasters(templateMasters.filter(fichier => fichier.id !== templateMaster.id)); + alert('Fichier supprimé avec succès.'); + } else { + alert('Erreur lors de la suppression du fichier dans la base de données.'); + } + }) + .catch(error => { + console.error('Error deleting file from database:', error); + alert('Erreur lors de la suppression du fichier dans la base de données.'); + }); + } else { + alert('Erreur lors de la suppression du master ou des clones dans DocuSeal.'); + } + }) + .catch(error => { + console.error('Error removing template from DocuSeal:', error); + alert('Erreur lors de la suppression du master ou des clones dans DocuSeal.'); + }); + }; + + const removeTemplate = (templateId) => { + return fetch('/api/docuseal/removeTemplate/', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ + templateId + }) + }) + .then(response => { + if (!response.ok) { + return response.json().then(err => { throw new Error(err.message); }); + } + return response; + }) + .catch(error => { + console.error('Error removing template:', error); + throw error; + }); + }; + + const editTemplateMaster = (file) => { + setIsEditing(true); + setFileToEdit(file); + setIsModalOpen(true); + }; + + const handleCreateTemplateMaster = ({name, group_ids, id, is_required}) => { + const data = { + name: name, + id: id, + groups: group_ids, + is_required: is_required + }; + logger.debug(data); + + createRegistrationTemplateMaster(data, csrfToken) + .then(data => { + // Transformer le nouveau fichier avec les informations du groupe + const transformedFile = transformFileData(data, groups); + setTemplateMasters(prevFiles => [...prevFiles, transformedFile]); + setIsModalOpen(false); + }) + .catch(error => { + console.error('Error uploading file:', error); + }); + }; + + const handleEditTemplateMaster = ({name, group_ids, id, is_required}) => { + const data = { + name: name, + id: id, + groups: group_ids, + is_required: is_required + }; + logger.debug(data); + + editRegistrationTemplateMaster(id, data, csrfToken) + .then(data => { + // Transformer le fichier mis à jour avec les informations du groupe + const transformedFile = transformFileData(data, groups); + setTemplateMasters(prevFichiers => + prevFichiers.map(f => f.id === id ? transformedFile : f) + ); + setIsModalOpen(false); + }) + .catch(error => { + console.error('Error editing file:', error); + alert('Erreur lors de la modification du fichier'); + }); + }; + + const handleGroupSubmit = (groupData) => { + if (groupToEdit) { + editRegistrationFileGroup(groupToEdit.id, groupData, csrfToken) + .then(updatedGroup => { + setGroups(groups.map(group => group.id === groupToEdit.id ? updatedGroup : group)); + setGroupToEdit(null); + setIsGroupModalOpen(false); + }) + .catch(error => { + console.error('Error handling group:', error); + alert('Erreur lors de l\'opération sur le groupe'); + }); + } else { + createRegistrationFileGroup(groupData, csrfToken) + .then(newGroup => { + setGroups([...groups, newGroup]); + setIsGroupModalOpen(false); + }) + .catch(error => { + console.error('Error handling group:', error); + alert('Erreur lors de l\'opération sur le groupe'); + }); + } + }; + + const handleGroupEdit = (group) => { + setGroupToEdit(group); + setIsGroupModalOpen(true); + }; + + const handleGroupDelete = (groupId) => { + // Vérifier si des templateMasters utilisent ce groupe + const filesInGroup = templateMasters.filter(file => file.group && file.group.id === groupId); + if (filesInGroup.length > 0) { + alert('Impossible de supprimer ce groupe car il contient des templateMasters. Veuillez d\'abord retirer tous les templateMasters de ce groupe.'); + return; + } + + if (window.confirm('Êtes-vous sûr de vouloir supprimer ce groupe ?')) { + deleteRegistrationFileGroup(groupId, csrfToken) + .then((response) => { + if (response.status === 409) { + throw new Error('Ce groupe est lié à des inscriptions existantes.'); + } + if (!response.ok) { + throw new Error('Erreur lors de la suppression du groupe.'); + } + setGroups(groups.filter(group => group.id !== groupId)); + alert('Groupe supprimé avec succès.'); + }) + .catch(error => { + console.error('Error deleting group:', error); + alert(error.message || 'Erreur lors de la suppression du groupe. Vérifiez qu\'aucune inscription n\'utilise ce groupe.'); + }); + } + }; + + const filteredFiles = templateMasters.filter(file => { + if (!selectedGroup) return true; + return file.groups && file.groups.some(group => group.id === parseInt(selectedGroup)); + }); + + const columnsFiles = [ + { name: 'Nom du fichier', transform: (row) => row.name }, + { name: 'Groupes', transform: (row) => row.groups && row.groups.length > 0 ? row.groups.map(group => group.name).join(', ') : 'Aucun' }, + { name: 'Actions', transform: (row) => ( +
+ {row.file && ( + + + + )} + + +
+ )} + ]; + + const columnsGroups = [ + { name: 'Nom du groupe', transform: (row) => row.name }, + { name: 'Description', transform: (row) => row.description }, + { name: 'Actions', transform: (row) => ( +
+ + +
+ )} + ]; + + return ( +
+ { + setIsModalOpen(isOpen); + if (!isOpen) { + setFileToEdit(null); + } + }} + title={isEditing ? 'Modification du document' : 'Ajouter un document'} + ContentComponent={() => ( + + )} + modalClassName='w-4/5 h-4/5' + /> + ( + + )} + /> +
+
+

Groupes de fichiers

+ +
+
+ + {groups.length > 0 && ( +
+
+

Fichiers

+
+ + +
+
+
+ + )} + + ); +} diff --git a/Front-End/src/components/Structure/Files/FilesManagement.js b/Front-End/src/components/Structure/Files/FilesManagement.js new file mode 100644 index 0000000..5744766 --- /dev/null +++ b/Front-End/src/components/Structure/Files/FilesManagement.js @@ -0,0 +1,343 @@ +import React, { useState, useEffect } from 'react'; +import { Plus, Download, Edit, Trash2, FolderPlus, Signature } from 'lucide-react'; +import Modal from '@/components/Modal'; +import Table from '@/components/Table'; +import FileUpload from '@/components/FileUpload'; +import { formatDate } from '@/utils/Date'; +import { BASE_URL } from '@/utils/Url'; +import { + fetchRegisterFormFileTemplate, + createRegistrationFormFileTemplate, + editRegistrationFormFileTemplate, + deleteRegisterFormFileTemplate, + getRegisterFormFileTemplate +} from '@/app/actions/subscriptionAction'; +import { + fetchRegistrationFileGroups, + createRegistrationFileGroup, + deleteRegistrationFileGroup, + editRegistrationFileGroup +} from '@/app/actions/registerFileGroupAction'; +import RegistrationFileGroupForm from '@/components/RegistrationFileGroupForm'; +import { useEstablishment } from '@/context/EstablishmentContext'; + +export default function FilesManagement({ csrfToken }) { + const [fichiers, setFichiers] = useState([]); + const [groups, setGroups] = useState([]); + const [selectedGroup, setSelectedGroup] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [fileToEdit, setFileToEdit] = useState(null); + const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); + const [groupToEdit, setGroupToEdit] = useState(null); + + const { selectedEstablishmentId } = useEstablishment(); + + // Fonction pour transformer les données des fichiers avec les informations complètes du groupe + const transformFileData = (file, groups) => { + if (!file.group) return file; + + const groupInfo = groups.find(g => g.id === file.group); + return { + ...file, + group: groupInfo || { id: file.group, name: 'Groupe inconnu' } + }; + }; + + useEffect(() => { + Promise.all([ + fetchRegisterFormFileTemplate(), + fetchRegistrationFileGroups(selectedEstablishmentId) + ]).then(([filesData, groupsData]) => { + setGroups(groupsData); + // Sélectionner automatiquement le premier groupe s'il existe + if (groupsData.length > 0) { + setSelectedGroup(groupsData[0].id.toString()); + } + // Transformer chaque fichier pour inclure les informations complètes du groupe + const transformedFiles = filesData.map(file => transformFileData(file, groupsData)); + setFichiers(transformedFiles); + }).catch(err => { + console.log(err.message); + }); + }, []); + + const handleFileDelete = (fileId) => { + deleteRegisterFormFileTemplate(fileId, csrfToken) + .then(response => { + if (response.ok) { + setFichiers(fichiers.filter(fichier => fichier.id !== fileId)); + alert('Fichier supprimé avec succès.'); + } else { + alert('Erreur lors de la suppression du fichier.'); + } + }) + .catch(error => { + console.error('Error deleting file:', error); + alert('Erreur lors de la suppression du fichier.'); + }); + }; + + const handleFileEdit = (file) => { + setIsEditing(true); + setFileToEdit(file); + setIsModalOpen(true); + }; + + const handleFileUpload = ({file, name, is_required, order, groupId}) => { + if (!name) { + alert('Veuillez entrer un nom de fichier.'); + return; + } + + const formData = new FormData(); + if(file) { + formData.append('file', file); + } + formData.append('name', name); + formData.append('is_required', is_required); + formData.append('order', order); + + // Modification ici : vérifier si groupId existe et n'est pas vide + if (groupId && groupId !== '') { + formData.append('group', groupId); // Notez que le nom du champ est 'group' et non 'group_id' + } + + if (isEditing && fileToEdit) { + editRegistrationFormFileTemplate(fileToEdit.id, formData, csrfToken) + .then(data => { + // Transformer le fichier mis à jour avec les informations du groupe + const transformedFile = transformFileData(data, groups); + setFichiers(prevFichiers => + prevFichiers.map(f => f.id === fileToEdit.id ? transformedFile : f) + ); + setIsModalOpen(false); + setFileToEdit(null); + setIsEditing(false); + }) + .catch(error => { + console.error('Error editing file:', error); + alert('Erreur lors de la modification du fichier'); + }); + } else { + createRegistrationFormFileTemplate(formData, csrfToken) + .then(data => { + // Transformer le nouveau fichier avec les informations du groupe + const transformedFile = transformFileData(data, groups); + setFichiers(prevFiles => [...prevFiles, transformedFile]); + setIsModalOpen(false); + }) + .catch(error => { + console.error('Error uploading file:', error); + }); + } + }; + + const handleGroupSubmit = (groupData) => { + if (groupToEdit) { + editRegistrationFileGroup(groupToEdit.id, groupData, csrfToken) + .then(updatedGroup => { + setGroups(groups.map(group => group.id === groupToEdit.id ? updatedGroup : group)); + setGroupToEdit(null); + setIsGroupModalOpen(false); + }) + .catch(error => { + console.error('Error handling group:', error); + alert('Erreur lors de l\'opération sur le groupe'); + }); + } else { + createRegistrationFileGroup(groupData, csrfToken) + .then(newGroup => { + setGroups([...groups, newGroup]); + setIsGroupModalOpen(false); + }) + .catch(error => { + console.error('Error handling group:', error); + alert('Erreur lors de l\'opération sur le groupe'); + }); + } + }; + + const handleGroupEdit = (group) => { + setGroupToEdit(group); + setIsGroupModalOpen(true); + }; + + const handleGroupDelete = (groupId) => { + // Vérifier si des fichiers utilisent ce groupe + const filesInGroup = fichiers.filter(file => file.group && file.group.id === groupId); + if (filesInGroup.length > 0) { + alert('Impossible de supprimer ce groupe car il contient des fichiers. Veuillez d\'abord retirer tous les fichiers de ce groupe.'); + return; + } + + if (window.confirm('Êtes-vous sûr de vouloir supprimer ce groupe ?')) { + deleteRegistrationFileGroup(groupId, csrfToken) + .then((response) => { + if (response.status === 409) { + throw new Error('Ce groupe est lié à des inscriptions existantes.'); + } + if (!response.ok) { + throw new Error('Erreur lors de la suppression du groupe.'); + } + setGroups(groups.filter(group => group.id !== groupId)); + alert('Groupe supprimé avec succès.'); + }) + .catch(error => { + console.error('Error deleting group:', error); + alert(error.message || 'Erreur lors de la suppression du groupe. Vérifiez qu\'aucune inscription n\'utilise ce groupe.'); + }); + } + }; + + // Ajouter cette fonction de filtrage + const filteredFiles = fichiers.filter(file => { + if (!selectedGroup) return true; + return file.group && file.group.id === parseInt(selectedGroup); + }); + + const columnsFiles = [ + { name: 'Nom du fichier', transform: (row) => row.name }, + { name: 'Groupe', transform: (row) => row.group ? row.group.name : 'Aucun' }, + { name: 'Date de création', transform: (row) => formatDate(new Date (row.date_added),"DD/MM/YYYY hh:mm:ss") }, + { name: 'Fichier Obligatoire', transform: (row) => row.is_required ? 'Oui' : 'Non' }, + { name: 'Ordre de fusion', transform: (row) => row.order }, + { name: 'Actions', transform: (row) => ( +
+ {row.file && ( + + + + )} + + + +
+ )} + ]; + + const columnsGroups = [ + { name: 'Nom du groupe', transform: (row) => row.name }, + { name: 'Description', transform: (row) => row.description }, + { name: 'Actions', transform: (row) => ( +
+ + +
+ )} + ]; + + // Fonction pour gérer la demande de signature + const handleSignatureRequest = (file) => { + const formData = new FormData(); + formData.append('file', file); + console.log('Demande de signature pour le fichier :', file); + + fetch('http://localhost:8080:/DocuSeal/generateToken', { + method: 'POST', + headers: { + 'Authorization': 'Bearer NFPZy6BBGvYs1BwTuXMQ3XAu5N1kLFiXWftGQhkiz2A', + }, + body: formData, + }) + .then((response) => { + if (!response.ok) { + throw new Error('Erreur lors du téléversement du document : ' + response.statusText); + } + return response.json(); + }) + .then((data) => { + const documentId = data.documentId; + console.log('Document téléversé avec succès, ID :', documentId); + onUpload(documentId); + }); + .catch((error) => console.error(error)); + }; + + return ( +
+ ( + + )} + /> + ( + + )} + /> +
+
+

Groupes de fichiers

+ +
+
+ + {groups.length > 0 && ( +
+
+

Fichiers

+
+ + +
+
+
+ + )} + + ); +} diff --git a/Front-End/src/components/Structure/Files/RegistrationFileGroupForm.js b/Front-End/src/components/Structure/Files/RegistrationFileGroupForm.js new file mode 100644 index 0000000..c3ac53e --- /dev/null +++ b/Front-End/src/components/Structure/Files/RegistrationFileGroupForm.js @@ -0,0 +1,56 @@ +import React, { useState, useEffect } from 'react'; + +export default function RegistrationFileGroupForm({ onSubmit, initialData }) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + + useEffect(() => { + if (initialData) { + setName(initialData.name); + setDescription(initialData.description); + } + }, [initialData]); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit({ name, description }); + }; + + return ( + +
+ + setName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" + required + /> +
+ +
+ +