feat: Securisation du téléchargement de fichier

This commit is contained in:
Luc SORIGNET
2026-04-04 13:44:57 +02:00
parent 5f6c015d02
commit a3291262d8
17 changed files with 1176 additions and 566 deletions

View File

@ -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<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
path('serve-file/', ServeFileView.as_view(), name="serve_file"),
]

View File

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

View File

@ -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() {
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4">
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
src={getSecureFileUrl(student.photo)}
alt={`${student.first_name} ${student.last_name}`}
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
/>

View File

@ -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 <span className="text-gray-300 text-xs"></span>;
if (value === null) return <span className="text-gray-400 text-xs"></span>;
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 (
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
@ -184,7 +221,9 @@ export default function Page() {
if (data?.data) {
data.data.forEach((d) =>
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() {
<div className="flex justify-center items-center">
{student.photo ? (
<a
href={`${BASE_URL}${student.photo}`}
href={getSecureFileUrl(student.photo)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<img
src={`${BASE_URL}${student.photo}`}
src={getSecureFileUrl(student.photo)}
alt={`${student.first_name} ${student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
@ -345,7 +414,8 @@ export default function Page() {
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
<span className="text-gray-500 text-sm font-semibold">
{student.first_name?.[0]}{student.last_name?.[0]}
{student.first_name?.[0]}
{student.last_name?.[0]}
</span>
</div>
)}
@ -364,7 +434,9 @@ export default function Page() {
<button
onClick={(e) => {
e.stopPropagation();
router.push(`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`);
router.push(
`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`
);
}}
className="text-emerald-700 hover:underline font-medium"
>
@ -385,7 +457,10 @@ export default function Page() {
return (
<div className="flex items-center justify-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); router.push(`/admin/grades/${student.id}`); }}
onClick={(e) => {
e.stopPropagation();
router.push(`/admin/grades/${student.id}`);
}}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition whitespace-nowrap"
title="Voir la fiche"
>
@ -469,10 +544,12 @@ export default function Page() {
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
<div>
<h2 className="text-lg font-semibold text-gray-800">
Notes de {gradesModalStudent.first_name} {gradesModalStudent.last_name}
Notes de {gradesModalStudent.first_name}{' '}
{gradesModalStudent.last_name}
</h2>
<p className="text-sm text-gray-500">
{gradesModalStudent.associated_class_name || 'Classe non assignée'}
{gradesModalStudent.associated_class_name ||
'Classe non assignée'}
</p>
</div>
<button
@ -497,25 +574,38 @@ export default function Page() {
<div className="space-y-6">
{/* Résumé des moyennes */}
{(() => {
const subjectAverages = Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
.map(e => parseFloat(e.score))
.filter(s => !isNaN(s));
const avg = scores.length
? scores.reduce((sum, s) => sum + s, 0) / scores.length
: null;
return { subject, color, avg };
}).filter(s => s.avg !== null && !isNaN(s.avg));
const subjectAverages = Object.entries(groupedBySubject)
.map(([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(
(e) =>
e.score !== null &&
e.score !== undefined &&
!e.is_absent
)
.map((e) => parseFloat(e.score))
.filter((s) => !isNaN(s));
const avg = scores.length
? scores.reduce((sum, s) => sum + s, 0) /
scores.length
: null;
return { subject, color, avg };
})
.filter((s) => s.avg !== null && !isNaN(s.avg));
const overallAvg = subjectAverages.length
? (subjectAverages.reduce((sum, s) => sum + s.avg, 0) / subjectAverages.length).toFixed(1)
? (
subjectAverages.reduce((sum, s) => sum + s.avg, 0) /
subjectAverages.length
).toFixed(1)
: null;
return (
<div className="bg-gradient-to-r from-emerald-50 to-blue-50 rounded-lg p-4 border border-emerald-100">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-600">Résumé</span>
<span className="text-sm font-medium text-gray-600">
Résumé
</span>
{overallAvg !== null && (
<span className="text-lg font-bold text-emerald-700">
Moyenne générale : {overallAvg}/20
@ -532,7 +622,9 @@ export default function Page() {
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="text-sm text-gray-700">{subject}</span>
<span className="text-sm text-gray-700">
{subject}
</span>
<span className="text-sm font-semibold text-gray-800">
{avg.toFixed(1)}
</span>
@ -543,134 +635,175 @@ export default function Page() {
);
})()}
{Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
.map(e => parseFloat(e.score))
.filter(s => !isNaN(s));
const avg = scores.length
? (scores.reduce((sum, s) => sum + s, 0) / scores.length).toFixed(1)
: null;
return (
<div key={subject} className="border rounded-lg overflow-hidden">
{Object.entries(groupedBySubject).map(
([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(
(e) =>
e.score !== null &&
e.score !== undefined &&
!e.is_absent
)
.map((e) => parseFloat(e.score))
.filter((s) => !isNaN(s));
const avg = scores.length
? (
scores.reduce((sum, s) => sum + s, 0) /
scores.length
).toFixed(1)
: null;
return (
<div
className="flex items-center justify-between px-4 py-3"
style={{ backgroundColor: `${color}20` }}
key={subject}
className="border rounded-lg overflow-hidden"
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="font-semibold text-gray-800">{subject}</span>
<div
className="flex items-center justify-between px-4 py-3"
style={{ backgroundColor: `${color}20` }}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="font-semibold text-gray-800">
{subject}
</span>
</div>
{avg !== null && (
<span className="text-sm font-bold text-gray-700">
Moyenne : {avg}
</span>
)}
</div>
{avg !== null && (
<span className="text-sm font-bold text-gray-700">
Moyenne : {avg}
</span>
)}
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 font-medium text-gray-600">Évaluation</th>
<th className="text-left px-4 py-2 font-medium text-gray-600">Période</th>
<th className="text-right px-4 py-2 font-medium text-gray-600">Note</th>
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">Actions</th>
</tr>
</thead>
<tbody>
{evaluations.map((evalItem) => {
const isEditing = editingEvalId === evalItem.id;
return (
<tr key={evalItem.id} className="border-t hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">
{evalItem.evaluation_name || 'Évaluation'}
</td>
<td className="px-4 py-2 text-gray-500">
{evalItem.period || '—'}
</td>
<td className="px-4 py-2 text-right">
{isEditing ? (
<div className="flex items-center justify-end gap-2">
<label className="flex items-center gap-1 text-xs text-gray-600">
<input
type="checkbox"
checked={editAbsent}
onChange={(e) => {
setEditAbsent(e.target.checked);
if (e.target.checked) setEditScore('');
}}
/>
Abs
</label>
{!editAbsent && (
<input
type="number"
value={editScore}
onChange={(e) => setEditScore(e.target.value)}
min="0"
max={evalItem.max_score || 20}
step="0.5"
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
/>
)}
<span className="text-gray-500">/{evalItem.max_score || 20}</span>
</div>
) : evalItem.is_absent ? (
<span className="text-orange-500 font-medium">Absent</span>
) : evalItem.score !== null ? (
<span className="font-semibold text-gray-800">
{evalItem.score}/{evalItem.max_score || 20}
</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-2 text-center">
{isEditing ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleSaveEval(evalItem)}
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
title="Enregistrer"
>
<Save size={14} />
</button>
<button
onClick={cancelEditingEval}
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
title="Annuler"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => startEditingEval(evalItem)}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDeleteEval(evalItem)}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</div>
)}
</td>
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 font-medium text-gray-600">
Évaluation
</th>
<th className="text-left px-4 py-2 font-medium text-gray-600">
Période
</th>
<th className="text-right px-4 py-2 font-medium text-gray-600">
Note
</th>
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">
Actions
</th>
</tr>
);})}
</tbody>
</table>
</div>
);
})}
</thead>
<tbody>
{evaluations.map((evalItem) => {
const isEditing = editingEvalId === evalItem.id;
return (
<tr
key={evalItem.id}
className="border-t hover:bg-gray-50"
>
<td className="px-4 py-2 text-gray-700">
{evalItem.evaluation_name || 'Évaluation'}
</td>
<td className="px-4 py-2 text-gray-500">
{evalItem.period || '—'}
</td>
<td className="px-4 py-2 text-right">
{isEditing ? (
<div className="flex items-center justify-end gap-2">
<label className="flex items-center gap-1 text-xs text-gray-600">
<input
type="checkbox"
checked={editAbsent}
onChange={(e) => {
setEditAbsent(e.target.checked);
if (e.target.checked)
setEditScore('');
}}
/>
Abs
</label>
{!editAbsent && (
<input
type="number"
value={editScore}
onChange={(e) =>
setEditScore(e.target.value)
}
min="0"
max={evalItem.max_score || 20}
step="0.5"
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
/>
)}
<span className="text-gray-500">
/{evalItem.max_score || 20}
</span>
</div>
) : evalItem.is_absent ? (
<span className="text-orange-500 font-medium">
Absent
</span>
) : evalItem.score !== null ? (
<span className="font-semibold text-gray-800">
{evalItem.score}/
{evalItem.max_score || 20}
</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-2 text-center">
{isEditing ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() =>
handleSaveEval(evalItem)
}
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
title="Enregistrer"
>
<Save size={14} />
</button>
<button
onClick={cancelEditingEval}
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
title="Annuler"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center justify-center gap-1">
<button
onClick={() =>
startEditingEval(evalItem)
}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() =>
handleDeleteEval(evalItem)
}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
)}
</div>
)}
</div>

View File

@ -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 <Loader />; // Affichez le composant Loader
@ -884,12 +890,12 @@ export default function CreateSubscriptionPage() {
<div className="flex justify-center items-center">
{row.photo ? (
<a
href={`${BASE_URL}${row.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.photo}`}
src={getSecureFileUrl(row.photo)}
alt={`${row.first_name} ${row.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>

View File

@ -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 } }) {
<div className="flex justify-center items-center">
{row.student.photo ? (
<a
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.student.photo}`}
src={getSecureFileUrl(row.student.photo)}
alt={`${row.student.first_name} ${row.student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
@ -898,7 +912,9 @@ export default function Page({ params: { locale } }) {
isOpen={isRefusePopupOpen}
message={
<div>
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
<div className="mb-2 font-semibold">
Veuillez indiquer la raison du refus :
</div>
<Textarea
value={refuseReason}
onChange={(e) => setRefuseReason(e.target.value)}

View File

@ -19,7 +19,7 @@ import {
} from '@/app/actions/subscriptionAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext';
import { useClasses } from '@/context/ClassesContext';
@ -139,12 +139,12 @@ export default function ParentHomePage() {
<div className="flex justify-center items-center">
{row.student.photo ? (
<a
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.student.photo}`}
src={getSecureFileUrl(row.student.photo)}
alt={`${row.student.first_name} ${row.student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
@ -225,7 +225,7 @@ export default function ParentHomePage() {
<Eye className="h-5 w-5" />
</button>
<a
href={`${BASE_URL}${row.sepa_file}`}
href={getSecureFileUrl(row.sepa_file)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"

View File

@ -8,6 +8,7 @@ import {
Archive,
AlertCircle,
} from 'lucide-react';
import { getSecureFileUrl } from '@/utils/fileUrl';
const FileAttachment = ({
fileName,
@ -16,6 +17,7 @@ const FileAttachment = ({
fileUrl,
onDownload = null,
}) => {
const secureUrl = getSecureFileUrl(fileUrl);
// Obtenir l'icône en fonction du type de fichier
const getFileIcon = (type) => {
if (type.startsWith('image/')) {
@ -49,9 +51,9 @@ const FileAttachment = ({
const handleDownload = () => {
if (onDownload) {
onDownload();
} else if (fileUrl) {
} else if (secureUrl) {
const link = document.createElement('a');
link.href = fileUrl;
link.href = secureUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
@ -64,14 +66,14 @@ const FileAttachment = ({
return (
<div className="max-w-sm">
{isImage && fileUrl ? (
{isImage && secureUrl ? (
// Affichage pour les images
<div className="relative group">
<img
src={fileUrl}
src={secureUrl}
alt={fileName}
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(fileUrl, '_blank')}
onClick={() => window.open(secureUrl, '_blank')}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
<button

View File

@ -2,9 +2,16 @@
import React, { useState, useEffect } from 'react';
import FormRenderer from '@/components/Form/FormRenderer';
import FileUpload from '@/components/Form/FileUpload';
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react';
import {
CheckCircle,
Hourglass,
FileText,
Download,
Upload,
XCircle,
} from 'lucide-react';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
/**
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
@ -36,8 +43,12 @@ export default function DynamicFormsList({
const dataState = { ...prevData };
schoolFileTemplates.forEach((tpl) => {
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
const hasLocalData =
prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
const hasServerData =
existingResponses &&
existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0;
if (!hasLocalData && hasServerData) {
// Pas de données locales mais données serveur : utiliser les données serveur
@ -56,7 +67,10 @@ export default function DynamicFormsList({
const validationState = { ...prevValidation };
schoolFileTemplates.forEach((tpl) => {
const hasLocalValidation = prevValidation[tpl.id] === true;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
const hasServerData =
existingResponses &&
existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0;
if (!hasLocalValidation && hasServerData) {
// Pas validé localement mais données serveur : marquer comme validé
@ -76,13 +90,21 @@ export default function DynamicFormsList({
useEffect(() => {
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
const allFormsValid = schoolFileTemplates.every(
tpl => tpl.isValidated === true ||
(tpl) =>
tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
onValidationChange(allFormsValid);
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
}, [
formsData,
formsValidation,
existingResponses,
schoolFileTemplates,
onValidationChange,
]);
/**
* Gère la soumission d'un formulaire individuel
@ -177,9 +199,9 @@ export default function DynamicFormsList({
});
}
} catch (error) {
logger.error('Erreur lors de l\'upload du fichier :', error);
logger.error("Erreur lors de l'upload du fichier :", error);
}
};
};
const isDynamicForm = (template) =>
template.formTemplateData &&
@ -205,11 +227,15 @@ export default function DynamicFormsList({
<div className="text-sm text-gray-600 mb-4">
{/* Compteur x/y : inclut les documents validés */}
{
schoolFileTemplates.filter(tpl => {
schoolFileTemplates.filter((tpl) => {
// Validé ou complété localement
return tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
return (
tpl.isValidated === true ||
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
}).length
}
{' / '}
@ -219,11 +245,13 @@ export default function DynamicFormsList({
{/* Tri des templates par état */}
{(() => {
// Helper pour état
const getState = tpl => {
const getState = (tpl) => {
if (tpl.isValidated === true) return 0; // validé
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1; // complété/en attente
return 2; // à compléter/refusé
@ -234,11 +262,17 @@ export default function DynamicFormsList({
return (
<ul className="space-y-2">
{sortedTemplates.map((tpl, index) => {
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
const isActive =
schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated =
typeof tpl.isValidated === 'boolean'
? tpl.isValidated
: undefined;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
// Statut d'affichage
@ -258,8 +292,12 @@ export default function DynamicFormsList({
borderClass = 'border border-emerald-200';
textClass = 'text-emerald-700';
bgClass = isActive ? 'bg-emerald-200' : bgClass;
borderClass = isActive ? 'border border-emerald-300' : borderClass;
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
borderClass = isActive
? 'border border-emerald-300'
: borderClass;
textClass = isActive
? 'text-emerald-900 font-semibold'
: textClass;
canEdit = false;
} else if (isValidated === false) {
if (isCompletedLocally) {
@ -267,16 +305,24 @@ export default function DynamicFormsList({
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
borderClass = isActive
? 'border border-orange-300'
: 'border border-orange-200';
textClass = isActive
? 'text-orange-900 font-semibold'
: 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'Refusé';
statusColor = 'red';
icon = <XCircle className="w-5 h-5 text-red-500" />;
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
borderClass = isActive
? 'border border-red-300'
: 'border border-red-200';
textClass = isActive
? 'text-red-900 font-semibold'
: 'text-red-700';
canEdit = true;
}
} else {
@ -285,8 +331,12 @@ export default function DynamicFormsList({
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
borderClass = isActive
? 'border border-orange-300'
: 'border border-orange-200';
textClass = isActive
? 'text-orange-900 font-semibold'
: 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'À compléter';
@ -294,7 +344,9 @@ export default function DynamicFormsList({
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
bgClass = isActive ? 'bg-gray-200' : '';
borderClass = isActive ? 'border border-gray-300' : '';
textClass = isActive ? 'text-gray-900 font-semibold' : 'text-gray-600';
textClass = isActive
? 'text-gray-900 font-semibold'
: 'text-gray-600';
canEdit = true;
}
}
@ -307,13 +359,22 @@ export default function DynamicFormsList({
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
: `${bgClass} ${borderClass} ${textClass}`
}`}
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
onClick={() =>
setCurrentTemplateIndex(
schoolFileTemplates.findIndex((t) => t.id === tpl.id)
)
}
>
<span className="mr-3">{icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm truncate flex items-center gap-2">
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
{tpl.formMasterData?.title ||
tpl.title ||
tpl.name ||
'Formulaire sans nom'}
<span
className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}
>
{statusLabel}
</span>
</div>
@ -337,38 +398,56 @@ export default function DynamicFormsList({
<div className="mb-6">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold text-gray-800">
{currentTemplate.name}
{currentTemplate.name}
</h3>
{/* Label d'état */}
{currentTemplate.isValidated === true ? (
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span>
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
Validé
</span>
) : (formsData[currentTemplate.id] &&
Object.keys(formsData[currentTemplate.id]).length > 0) ||
(existingResponses[currentTemplate.id] &&
Object.keys(existingResponses[currentTemplate.id]).length >
0) ? (
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
Complété
</span>
) : (
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span>
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
Refusé
</span>
)}
</div>
<p className="text-sm text-gray-600">
{currentTemplate.formTemplateData?.description ||
currentTemplate.description || ''}
currentTemplate.description ||
''}
</p>
<div className="text-xs text-gray-500 mt-1">
Formulaire {(() => {
Formulaire{' '}
{(() => {
// Trouver l'index du template courant dans la liste triée
const getState = tpl => {
const getState = (tpl) => {
if (tpl.isValidated === true) return 0;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
if (isCompletedLocally) return 1;
return 2;
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
const sortedTemplates = [...schoolFileTemplates].sort(
(a, b) => getState(a) - getState(b)
);
const idx = sortedTemplates.findIndex(
(tpl) => tpl.id === currentTemplate.id
);
return idx + 1;
})()} sur {schoolFileTemplates.length}
})()}{' '}
sur {schoolFileTemplates.length}
</div>
</div>
@ -405,14 +484,15 @@ export default function DynamicFormsList({
// Formulaire existant (PDF, image, etc.)
<div className="flex flex-col items-center gap-6">
{/* Cas validé : affichage en iframe */}
{currentTemplate.isValidated === true && currentTemplate.file && (
<iframe
src={`${BASE_URL}${currentTemplate.file}`}
title={currentTemplate.name}
className="w-full"
style={{ height: '600px', border: 'none' }}
/>
)}
{currentTemplate.isValidated === true &&
currentTemplate.file && (
<iframe
src={getSecureFileUrl(currentTemplate.file)}
title={currentTemplate.name}
className="w-full"
style={{ height: '600px', border: 'none' }}
/>
)}
{/* Cas non validé : bouton télécharger + upload */}
{currentTemplate.isValidated !== true && (
@ -420,9 +500,7 @@ export default function DynamicFormsList({
{/* Bouton télécharger le document source */}
{currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
target="_blank"
rel="noopener noreferrer"
href={getSecureFileUrl(currentTemplate.file)}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
download
>
@ -436,7 +514,9 @@ export default function DynamicFormsList({
<FileUpload
key={currentTemplate.id}
selectionMessage={'Sélectionnez le fichier du document'}
onFileSelect={(file) => handleUpload(file, currentTemplate)}
onFileSelect={(file) =>
handleUpload(file, currentTemplate)
}
required
enable={true}
/>

View File

@ -5,7 +5,7 @@ import {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger';
const FilesModal = ({
@ -56,27 +56,27 @@ const FilesModal = ({
registrationFile: selectedRegisterForm.registration_file
? {
name: 'Fiche élève',
url: `${BASE_URL}${selectedRegisterForm.registration_file}`,
url: getSecureFileUrl(selectedRegisterForm.registration_file),
}
: null,
fusionFile: selectedRegisterForm.fusion_file
? {
name: 'Documents fusionnés',
url: `${BASE_URL}${selectedRegisterForm.fusion_file}`,
url: getSecureFileUrl(selectedRegisterForm.fusion_file),
}
: null,
schoolFiles: fetchedSchoolFiles.map((file) => ({
name: file.name || 'Document scolaire',
url: file.file ? `${BASE_URL}${file.file}` : null,
url: file.file ? getSecureFileUrl(file.file) : null,
})),
parentFiles: parentFiles.map((file) => ({
name: file.master_name || 'Document parent',
url: file.file ? `${BASE_URL}${file.file}` : null,
url: file.file ? getSecureFileUrl(file.file) : null,
})),
sepaFile: selectedRegisterForm.sepa_file
? {
name: 'Mandat SEPA',
url: `${BASE_URL}${selectedRegisterForm.sepa_file}`,
url: getSecureFileUrl(selectedRegisterForm.sepa_file),
}
: null,
};

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import Table from '@/components/Table';
import FileUpload from '@/components/Form/FileUpload';
import { Upload, Eye, Trash2, FileText } from 'lucide-react';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import Popup from '@/components/Popup';
import logger from '@/utils/logger';
@ -230,7 +230,7 @@ export default function FilesToUpload({
<div className="mt-4">
{actionType === 'view' && selectedFile.fileName ? (
<iframe
src={`${BASE_URL}${selectedFile.fileName}`}
src={getSecureFileUrl(selectedFile.fileName)}
title="Document Viewer"
className="w-full"
style={{

View File

@ -7,7 +7,7 @@ import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader';
import { User } from 'lucide-react';
import FileUpload from '@/components/Form/FileUpload';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { levels, genders } from '@/utils/constants';
export default function StudentInfoForm({
@ -57,7 +57,7 @@ export default function StudentInfoForm({
// Convertir la photo en fichier binaire si elle est un chemin ou une URL
if (photoPath && typeof photoPath === 'string') {
fetch(`${BASE_URL}${photoPath}`)
fetch(getSecureFileUrl(photoPath))
.then((response) => {
if (!response.ok) {
throw new Error('Erreur lors de la récupération de la photo.');

View File

@ -3,11 +3,11 @@ import React, { useState, useEffect } from 'react';
import Popup from '@/components/Popup';
import ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/Form/SelectChoice';
import { BASE_URL } from '@/utils/Url';
import {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction';
import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger';
import { School, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
@ -49,15 +49,18 @@ export default function ValidateSubscription({
// Parent templates
parentFileTemplates.forEach((tpl, i) => {
if (typeof tpl.isValidated === 'boolean') {
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated ? 'accepted' : 'refused';
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated
? 'accepted'
: 'refused';
}
});
setDocStatuses(s => ({ ...s, ...newStatuses }));
setDocStatuses((s) => ({ ...s, ...newStatuses }));
}, [schoolFileTemplates, parentFileTemplates]);
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
const [showFinalValidationPopup, setShowFinalValidationPopup] = useState(false);
const [showFinalValidationPopup, setShowFinalValidationPopup] =
useState(false);
const [formData, setFormData] = useState({
associated_class: null,
@ -131,7 +134,7 @@ export default function ValidateSubscription({
const handleRefuseDossier = () => {
// Message clair avec la liste des documents refusés
let notes = 'Dossier non validé pour les raisons suivantes :\n';
notes += refusedDocs.map(doc => `- ${doc.name}`).join('\n');
notes += refusedDocs.map((doc) => `- ${doc.name}`).join('\n');
const data = {
status: 2,
notes,
@ -177,10 +180,18 @@ export default function ValidateSubscription({
.filter((doc, idx) => docStatuses[idx] === 'refused');
// Récupère la liste des documents à cocher (hors fiche élève)
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused');
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted');
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused');
const docIndexes = allTemplates
.map((_, idx) => idx)
.filter((idx) => idx !== 0);
const allChecked =
docIndexes.length > 0 &&
docIndexes.every(
(idx) => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused'
);
const allValidated =
docIndexes.length > 0 &&
docIndexes.every((idx) => docStatuses[idx] === 'accepted');
const hasRefused = docIndexes.some((idx) => docStatuses[idx] === 'refused');
logger.debug(allTemplates);
return (
@ -202,7 +213,7 @@ export default function ValidateSubscription({
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
</h3>
<iframe
src={`${BASE_URL}${allTemplates[currentTemplateIndex].file}`}
src={getSecureFileUrl(allTemplates[currentTemplateIndex].file)}
title={
allTemplates[currentTemplateIndex].type === 'main'
? 'Document Principal'
@ -252,18 +263,32 @@ export default function ValidateSubscription({
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
aria-pressed={docStatuses[index] === 'accepted'}
onClick={e => {
onClick={(e) => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
setDocStatuses((s) => ({
...s,
[index]: 'accepted',
}));
// Appel API pour valider le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
if (
index > 0 &&
index <= schoolFileTemplates.length
) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
} else if (
index > schoolFileTemplates.length &&
index <=
schoolFileTemplates.length +
parentFileTemplates.length
) {
template =
parentFileTemplates[
index - 1 - schoolFileTemplates.length
];
type = 'parent';
}
if (template && template.id) {
@ -284,18 +309,29 @@ export default function ValidateSubscription({
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
aria-pressed={docStatuses[index] === 'refused'}
onClick={e => {
onClick={(e) => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
setDocStatuses((s) => ({ ...s, [index]: 'refused' }));
// Appel API pour refuser le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
if (
index > 0 &&
index <= schoolFileTemplates.length
) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
} else if (
index > schoolFileTemplates.length &&
index <=
schoolFileTemplates.length +
parentFileTemplates.length
) {
template =
parentFileTemplates[
index - 1 - schoolFileTemplates.length
];
type = 'parent';
}
if (template && template.id) {
@ -351,7 +387,7 @@ export default function ValidateSubscription({
<div className="mt-auto py-4">
<Button
text="Soumettre"
onClick={e => {
onClick={(e) => {
e.preventDefault();
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
// 2. Si tous cochés et au moins un refusé : popup refus
@ -367,12 +403,14 @@ export default function ValidateSubscription({
}}
primary
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
!allChecked || (allChecked && allValidated && !formData.associated_class)
!allChecked ||
(allChecked && allValidated && !formData.associated_class)
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
disabled={
!allChecked || (allChecked && allValidated && !formData.associated_class)
!allChecked ||
(allChecked && allValidated && !formData.associated_class)
}
/>
</div>
@ -391,7 +429,7 @@ export default function ValidateSubscription({
<span className="font-semibold text-blue-700">{email}</span>
{' avec la liste des documents non validés :'}
<ul className="list-disc ml-6 mt-2">
{refusedDocs.map(doc => (
{refusedDocs.map((doc) => (
<li key={doc.idx}>{doc.name}</li>
))}
</ul>

View File

@ -9,9 +9,7 @@ import { usePopup } from '@/context/PopupContext';
import { getRightStr } from '@/utils/rights';
import { ChevronDown } from 'lucide-react'; // Import de l'icône
import Image from 'next/image'; // Import du composant Image
import {
BASE_URL,
} from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
const {
@ -24,7 +22,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
setSelectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentTotalCapacity,
selectedEstablishmentLogo,
setSelectedEstablishmentLogo
setSelectedEstablishmentLogo,
} = useEstablishment();
const { isConnected, connectionStatus } = useChatConnection();
const [dropdownOpen, setDropdownOpen] = useState(false);
@ -38,8 +36,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
user.roles[roleId].establishment__evaluation_frequency;
const establishmentTotalCapacity =
user.roles[roleId].establishment__total_capacity;
const establishmentLogo =
user.roles[roleId].establishment__logo;
const establishmentLogo = user.roles[roleId].establishment__logo;
setProfileRole(role);
setSelectedEstablishmentId(establishmentId);
setSelectedEstablishmentEvaluationFrequency(
@ -108,7 +105,11 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
<div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
<div className="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
src={
selectedEstablishmentLogo
? getSecureFileUrl(selectedEstablishmentLogo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md"
width={32}
@ -128,7 +129,11 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
<div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
<div className="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
src={
selectedEstablishmentLogo
? getSecureFileUrl(selectedEstablishmentLogo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-16 h-16 rounded-full object-cover shadow-md"
width={64}
@ -185,15 +190,23 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
label: (
<div className="flex items-center text-left">
<Image
src={establishment.logo ? `${BASE_URL}${establishment.logo}` : getGravatarUrl(user?.email)}
src={
establishment.logo
? getSecureFileUrl(establishment.logo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md mr-3"
width={32}
height={32}
/>
<div>
<div className="font-bold ext-sm text-gray-500">{establishment.name}</div>
<div className="italic text-sm text-gray-500">{getRightStr(establishment.role_type)}</div>
<div className="font-bold ext-sm text-gray-500">
{establishment.name}
</div>
<div className="italic text-sm text-gray-500">
{getRightStr(establishment.role_type)}
</div>
</div>
</div>
),
@ -212,9 +225,10 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
]
}
buttonClassName="w-full"
menuClassName={compact
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
menuClassName={
compact
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
}
dropdownOpen={dropdownOpen}
setDropdownOpen={setDropdownOpen}

View File

@ -0,0 +1,54 @@
import { getToken } from 'next-auth/jwt';
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const token = await getToken({
req,
secret: process.env.AUTH_SECRET,
cookieName: 'n3wtschool_session_token',
});
if (!token?.token) {
return res.status(401).json({ error: 'Non authentifié' });
}
const { path } = req.query;
if (!path) {
return res.status(400).json({ error: 'Le paramètre "path" est requis' });
}
try {
const backendUrl = `${BACKEND_URL}/Common/serve-file/?path=${encodeURIComponent(path)}`;
const backendRes = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token.token}`,
Connection: 'close',
},
});
if (!backendRes.ok) {
return res.status(backendRes.status).json({
error: `Erreur backend: ${backendRes.status}`,
});
}
const contentType =
backendRes.headers.get('content-type') || 'application/octet-stream';
const contentDisposition = backendRes.headers.get('content-disposition');
res.setHeader('Content-Type', contentType);
if (contentDisposition) {
res.setHeader('Content-Disposition', contentDisposition);
}
const buffer = Buffer.from(await backendRes.arrayBuffer());
return res.send(buffer);
} catch {
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
}
}

View File

@ -0,0 +1,25 @@
/**
* Construit l'URL sécurisée pour accéder à un fichier media via le proxy Next.js.
* Le proxy `/api/download` injecte le JWT côté serveur avant de transmettre au backend Django.
*
* Gère les chemins relatifs ("/data/some/file.pdf") et les URLs absolues du backend
* ("http://backend:8000/data/some/file.pdf").
*
* @param {string} filePath - Chemin ou URL complète du fichier
* @returns {string|null} URL vers /api/download?path=... ou null si pas de chemin
*/
export const getSecureFileUrl = (filePath) => {
if (!filePath) return null;
// Si c'est une URL absolue, extraire le chemin /data/...
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
try {
const url = new URL(filePath);
filePath = url.pathname;
} catch {
return null;
}
}
return `/api/download?path=${encodeURIComponent(filePath)}`;
};