diff --git a/Back-End/Common/urls.py b/Back-End/Common/urls.py index 4dd0ba9..9ebf942 100644 --- a/Back-End/Common/urls.py +++ b/Back-End/Common/urls.py @@ -3,6 +3,7 @@ from django.urls import path, re_path from .views import ( DomainListCreateView, DomainDetailView, CategoryListCreateView, CategoryDetailView, + ServeFileView, ) urlpatterns = [ @@ -11,4 +12,6 @@ urlpatterns = [ re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"), re_path(r'^categories/(?P[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"), + + path('serve-file/', ServeFileView.as_view(), name="serve_file"), ] \ No newline at end of file diff --git a/Back-End/Common/views.py b/Back-End/Common/views.py index b88a78c..24608b5 100644 --- a/Back-End/Common/views.py +++ b/Back-End/Common/views.py @@ -1,3 +1,8 @@ +import os +import mimetypes + +from django.conf import settings +from django.http import FileResponse from django.http.response import JsonResponse from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect from django.utils.decorators import method_decorator @@ -117,3 +122,55 @@ class CategoryDetailView(APIView): return JsonResponse({'message': 'Deleted'}, safe=False) except Category.DoesNotExist: return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND) + + +class ServeFileView(APIView): + """Sert les fichiers media de manière sécurisée avec authentification JWT.""" + permission_classes = [IsAuthenticated] + + def get(self, request): + file_path = request.query_params.get('path', '') + + if not file_path: + return JsonResponse( + {'error': 'Le paramètre "path" est requis'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Nettoyer le préfixe /data/ si présent + if file_path.startswith('/data/'): + file_path = file_path[len('/data/'):] + elif file_path.startswith('data/'): + file_path = file_path[len('data/'):] + + # Construire le chemin absolu et le résoudre pour éliminer les traversals + absolute_path = os.path.realpath( + os.path.join(settings.MEDIA_ROOT, file_path) + ) + + # Protection contre le path traversal + media_root = os.path.realpath(settings.MEDIA_ROOT) + if not absolute_path.startswith(media_root + os.sep) and absolute_path != media_root: + return JsonResponse( + {'error': 'Accès non autorisé'}, + status=status.HTTP_403_FORBIDDEN, + ) + + if not os.path.isfile(absolute_path): + return JsonResponse( + {'error': 'Fichier introuvable'}, + status=status.HTTP_404_NOT_FOUND, + ) + + content_type, _ = mimetypes.guess_type(absolute_path) + if content_type is None: + content_type = 'application/octet-stream' + + response = FileResponse( + open(absolute_path, 'rb'), + content_type=content_type, + ) + response['Content-Disposition'] = ( + f'inline; filename="{os.path.basename(absolute_path)}"' + ) + return response diff --git a/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js b/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js index 19393d5..2104fde 100644 --- a/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js +++ b/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js @@ -8,7 +8,8 @@ import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart'; import { EvaluationStudentView } from '@/components/Evaluation'; import Button from '@/components/Form/Button'; import logger from '@/utils/logger'; -import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url'; +import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL } from '@/utils/Url'; +import { getSecureFileUrl } from '@/utils/fileUrl'; import { fetchStudents, fetchStudentCompetencies, @@ -147,21 +148,33 @@ export default function StudentGradesPage() { // Load evaluations for the student useEffect(() => { - if (student?.associated_class_id && selectedPeriod && selectedEstablishmentId) { + if ( + student?.associated_class_id && + selectedPeriod && + selectedEstablishmentId + ) { const periodString = getPeriodString( selectedPeriod, selectedEstablishmentEvaluationFrequency ); - + // Load evaluations for the class - fetchEvaluations(selectedEstablishmentId, student.associated_class_id, periodString) + fetchEvaluations( + selectedEstablishmentId, + student.associated_class_id, + periodString + ) .then((data) => setEvaluations(data)) - .catch((error) => logger.error('Erreur lors du fetch des évaluations:', error)); - + .catch((error) => + logger.error('Erreur lors du fetch des évaluations:', error) + ); + // Load student's evaluation scores fetchStudentEvaluations(studentId, null, periodString, null) .then((data) => setStudentEvaluationsData(data)) - .catch((error) => logger.error('Erreur lors du fetch des notes:', error)); + .catch((error) => + logger.error('Erreur lors du fetch des notes:', error) + ); } }, [student, selectedPeriod, selectedEstablishmentId]); @@ -182,8 +195,12 @@ export default function StudentGradesPage() { const handleToggleJustify = (absence) => { const newReason = absence.type === 'Absence' - ? absence.justified ? 2 : 1 - : absence.justified ? 4 : 3; + ? absence.justified + ? 2 + : 1 + : absence.justified + ? 4 + : 3; editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken) .then(() => { @@ -193,7 +210,9 @@ export default function StudentGradesPage() { ) ); }) - .catch((e) => logger.error('Erreur lors du changement de justification', e)); + .catch((e) => + logger.error('Erreur lors du changement de justification', e) + ); }; const handleDeleteAbsence = (absence) => { @@ -210,8 +229,16 @@ export default function StudentGradesPage() { try { await updateStudentEvaluation(studentEvalId, data, csrfToken); // Reload student evaluations - const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency); - const updatedData = await fetchStudentEvaluations(studentId, null, periodString, null); + const periodString = getPeriodString( + selectedPeriod, + selectedEstablishmentEvaluationFrequency + ); + const updatedData = await fetchStudentEvaluations( + studentId, + null, + periodString, + null + ); setStudentEvaluationsData(updatedData); } catch (error) { logger.error('Erreur lors de la modification de la note:', error); @@ -237,7 +264,7 @@ export default function StudentGradesPage() {
{student.photo ? ( {`${student.first_name} diff --git a/Front-End/src/app/[locale]/admin/grades/page.js b/Front-End/src/app/[locale]/admin/grades/page.js index 3c5e632..906127c 100644 --- a/Front-End/src/app/[locale]/admin/grades/page.js +++ b/Front-End/src/app/[locale]/admin/grades/page.js @@ -1,21 +1,34 @@ 'use client'; import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { Award, Eye, Search, BarChart2, X, Pencil, Trash2, Save } from 'lucide-react'; +import { + Award, + Eye, + Search, + BarChart2, + X, + Pencil, + Trash2, + Save, +} from 'lucide-react'; import SectionHeader from '@/components/SectionHeader'; import Table from '@/components/Table'; import logger from '@/utils/logger'; import { - BASE_URL, FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL, } from '@/utils/Url'; +import { getSecureFileUrl } from '@/utils/fileUrl'; import { fetchStudents, fetchStudentCompetencies, fetchAbsences, } from '@/app/actions/subscriptionAction'; -import { fetchStudentEvaluations, updateStudentEvaluation, deleteStudentEvaluation } from '@/app/actions/schoolAction'; +import { + fetchStudentEvaluations, + updateStudentEvaluation, + deleteStudentEvaluation, +} from '@/app/actions/schoolAction'; import { useEstablishment } from '@/context/EstablishmentContext'; import { useClasses } from '@/context/ClassesContext'; import { useCsrfToken } from '@/context/CsrfContext'; @@ -42,9 +55,17 @@ function calcCompetencyStats(data) { const total = scores.length; return { acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100), - inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100), - notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100), - notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).length / total) * 100), + inProgress: Math.round( + (scores.filter((s) => s === 2).length / total) * 100 + ), + notAcquired: Math.round( + (scores.filter((s) => s === 1).length / total) * 100 + ), + notEvaluated: Math.round( + (scores.filter((s) => s === null || s === undefined || s === 0).length / + total) * + 100 + ), }; } @@ -65,10 +86,26 @@ function getPeriodColumns(frequency) { } const COMPETENCY_COLUMNS = [ - { key: 'acquired', label: 'Acquises', color: 'bg-emerald-100 text-emerald-700' }, - { key: 'inProgress', label: 'En cours', color: 'bg-yellow-100 text-yellow-700' }, - { key: 'notAcquired', label: 'Non acquises', color: 'bg-red-100 text-red-600' }, - { key: 'notEvaluated', label: 'Non évaluées', color: 'bg-gray-100 text-gray-600' }, + { + key: 'acquired', + label: 'Acquises', + color: 'bg-emerald-100 text-emerald-700', + }, + { + key: 'inProgress', + label: 'En cours', + color: 'bg-yellow-100 text-yellow-700', + }, + { + key: 'notAcquired', + label: 'Non acquises', + color: 'bg-red-100 text-red-600', + }, + { + key: 'notEvaluated', + label: 'Non évaluées', + color: 'bg-gray-100 text-gray-600', + }, ]; function getCurrentPeriodValue(frequency) { @@ -97,13 +134,13 @@ function getCurrentPeriodValue(frequency) { function PercentBadge({ value, loading, color }) { if (loading) return ; if (value === null) return ; - const badgeColor = color || ( - value >= 75 + const badgeColor = + color || + (value >= 75 ? 'bg-emerald-100 text-emerald-700' : value >= 50 ? 'bg-yellow-100 text-yellow-700' - : 'bg-red-100 text-red-600' - ); + : 'bg-red-100 text-red-600'); return ( d.categories.forEach((c) => - c.competences.forEach((comp) => studentScores[studentId].push(comp.score)) + c.competences.forEach((comp) => + studentScores[studentId].push(comp.score) + ) ) ); } @@ -197,10 +236,21 @@ export default function Page() { } else { const total = scores.length; map[studentId] = { - acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100), - inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100), - notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100), - notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).length / total) * 100), + acquired: Math.round( + (scores.filter((s) => s === 3).length / total) * 100 + ), + inProgress: Math.round( + (scores.filter((s) => s === 2).length / total) * 100 + ), + notAcquired: Math.round( + (scores.filter((s) => s === 1).length / total) * 100 + ), + notEvaluated: Math.round( + (scores.filter((s) => s === null || s === undefined || s === 0) + .length / + total) * + 100 + ), }; } }); @@ -263,15 +313,31 @@ export default function Page() { const handleSaveEval = async (evalItem) => { try { - await updateStudentEvaluation(evalItem.id, { - score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), - is_absent: editAbsent, - }, csrfToken); + await updateStudentEvaluation( + evalItem.id, + { + score: editAbsent + ? null + : editScore === '' + ? null + : parseFloat(editScore), + is_absent: editAbsent, + }, + csrfToken + ); // Update local state setStudentEvaluations((prev) => prev.map((e) => e.id === evalItem.id - ? { ...e, score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), is_absent: editAbsent } + ? { + ...e, + score: editAbsent + ? null + : editScore === '' + ? null + : parseFloat(editScore), + is_absent: editAbsent, + } : e ) ); @@ -318,7 +384,10 @@ export default function Page() { { name: 'Élève', transform: () => null }, { name: 'Niveau', transform: () => null }, { name: 'Classe', transform: () => null }, - ...COMPETENCY_COLUMNS.map(({ label }) => ({ name: label, transform: () => null })), + ...COMPETENCY_COLUMNS.map(({ label }) => ({ + name: label, + transform: () => null, + })), { name: 'Absences', transform: () => null }, { name: 'Actions', transform: () => null }, ]; @@ -331,13 +400,13 @@ export default function Page() {
{student.photo ? ( e.stopPropagation()} > {`${student.first_name} @@ -345,7 +414,8 @@ export default function Page() { ) : (
- {student.first_name?.[0]}{student.last_name?.[0]} + {student.first_name?.[0]} + {student.last_name?.[0]}
)} @@ -364,7 +434,9 @@ export default function Page() { + +
+ ) : ( +
+ + +
+ )} + + + ); + })} + + +
+ ); + } + )} )} diff --git a/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js b/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js index 929f518..cba992c 100644 --- a/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js +++ b/Front-End/src/app/[locale]/admin/subscriptions/createSubscription/page.js @@ -34,12 +34,13 @@ import { import { fetchRegistrationFileGroups, fetchRegistrationSchoolFileMasters, - fetchRegistrationParentFileMasters + fetchRegistrationParentFileMasters, } from '@/app/actions/registerFileGroupAction'; import { fetchProfiles } from '@/app/actions/authAction'; import { useClasses } from '@/context/ClassesContext'; import { useCsrfToken } from '@/context/CsrfContext'; -import { FE_ADMIN_SUBSCRIPTIONS_URL, BASE_URL } from '@/utils/Url'; +import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url'; +import { getSecureFileUrl } from '@/utils/fileUrl'; import { useNotification } from '@/context/NotificationContext'; export default function CreateSubscriptionPage() { @@ -181,7 +182,9 @@ export default function CreateSubscriptionPage() { formDataRef.current = formData; }, [formData]); - useEffect(() => { setStudentsPage(1); }, [students]); + useEffect(() => { + setStudentsPage(1); + }, [students]); useEffect(() => { if (!formData.guardianEmail) { @@ -530,7 +533,7 @@ export default function CreateSubscriptionPage() { 'Succès' ); router.push(FE_ADMIN_SUBSCRIPTIONS_URL); - }) + }) .catch((error) => { setIsLoading(false); logger.error('Erreur lors de la mise à jour du dossier:', error); @@ -714,7 +717,10 @@ export default function CreateSubscriptionPage() { }; const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE); - const pagedStudents = students.slice((studentsPage - 1) * ITEMS_PER_PAGE, studentsPage * ITEMS_PER_PAGE); + const pagedStudents = students.slice( + (studentsPage - 1) * ITEMS_PER_PAGE, + studentsPage * ITEMS_PER_PAGE + ); if (isLoading === true) { return ; // Affichez le composant Loader @@ -884,12 +890,12 @@ export default function CreateSubscriptionPage() {
{row.photo ? ( {`${row.first_name} diff --git a/Front-End/src/app/[locale]/admin/subscriptions/page.js b/Front-End/src/app/[locale]/admin/subscriptions/page.js index 5192317..2144ea1 100644 --- a/Front-End/src/app/[locale]/admin/subscriptions/page.js +++ b/Front-End/src/app/[locale]/admin/subscriptions/page.js @@ -36,8 +36,8 @@ import { FE_ADMIN_SUBSCRIPTIONS_EDIT_URL, FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL, FE_ADMIN_SUBSCRIPTIONS_CREATE_URL, - BASE_URL, } from '@/utils/Url'; +import { getSecureFileUrl } from '@/utils/fileUrl'; import DjangoCSRFToken from '@/components/DjangoCSRFToken'; import { useCsrfToken } from '@/context/CsrfContext'; @@ -112,15 +112,29 @@ export default function Page({ params: { locale } }) { // Valide le refus const handleRefuse = () => { if (!refuseReason.trim()) { - showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur'); + showNotification( + 'Merci de préciser la raison du refus.', + 'error', + 'Erreur' + ); return; } const formData = new FormData(); - formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason })); - + formData.append( + 'data', + JSON.stringify({ + status: RegistrationFormStatus.STATUS_ARCHIVED, + notes: refuseReason, + }) + ); + editRegisterForm(rowToRefuse.student.id, formData, csrfToken) .then(() => { - showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès'); + showNotification( + 'Le dossier a été refusé et archivé.', + 'success', + 'Succès' + ); setReloadFetch(true); setIsRefusePopupOpen(false); }) @@ -668,12 +682,12 @@ export default function Page({ params: { locale } }) {
{row.student.photo ? ( {`${row.student.first_name} @@ -898,7 +912,9 @@ export default function Page({ params: { locale } }) { isOpen={isRefusePopupOpen} message={
-
Veuillez indiquer la raison du refus :
+
+ Veuillez indiquer la raison du refus : +