feat: Génération du bilan de compétence en PDF [#16]

This commit is contained in:
N3WT DE COMPET
2025-05-21 20:44:37 +02:00
parent eb7805e54e
commit 0fe6c76189
14 changed files with 357 additions and 67 deletions

View File

@ -55,6 +55,9 @@ class Sibling(models.Model):
def registration_photo_upload_to(instance, filename): def registration_photo_upload_to(instance, filename):
return f"registration_files/dossier_rf_{instance.pk}/parent/{filename}" return f"registration_files/dossier_rf_{instance.pk}/parent/{filename}"
def registration_bilan_form_upload_to(instance, filename):
return f"registration_files/dossier_rf_{instance.pk}/bilan/{filename}"
class Student(models.Model): class Student(models.Model):
""" """
Représente lélève inscrit ou en cours dinscription. Représente lélève inscrit ou en cours dinscription.
@ -91,6 +94,7 @@ class Student(models.Model):
birth_place = models.CharField(max_length=200, default="", blank=True) birth_place = models.CharField(max_length=200, default="", blank=True)
birth_postal_code = models.IntegerField(default=0, blank=True) birth_postal_code = models.IntegerField(default=0, blank=True)
attending_physician = models.CharField(max_length=200, default="", blank=True) attending_physician = models.CharField(max_length=200, default="", blank=True)
bilan_form = models.FileField(null=True,blank=True, upload_to=registration_bilan_form_upload_to)
# Many-to-Many Relationship # Many-to-Many Relationship
profiles = models.ManyToManyField('Auth.Profile', blank=True) profiles = models.ManyToManyField('Auth.Profile', blank=True)

View File

@ -422,7 +422,7 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Student model = Student
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name'] fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs) super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)

View File

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Bilan de compétences</title>
<style>
body { font-family: Arial, sans-serif; margin: 2em; }
h1, h2 { color: #059669; }
.student-info { margin-bottom: 2em; }
.domain-table { width: 100%; border-collapse: collapse; margin-bottom: 2em; }
.domain-header th {
background: #d1fae5;
color: #065f46;
font-size: 1.1em;
padding: 0.7em;
text-align: left;
}
.category-row td {
background: #f0fdf4;
color: #059669;
font-weight: bold;
font-size: 1em;
padding: 0.6em;
}
th, td { border: 1px solid #e5e7eb; padding: 0.5em; }
th.competence-header { background: #d1fae5; }
td.competence-nom { word-break: break-word; max-width: 320px; }
</style>
</head>
<body>
<h1>Bilan de compétences</h1>
<div class="student-info">
<strong>Élève :</strong> {{ student.last_name }} {{ student.first_name }}<br>
<strong>Niveau :</strong> {{ student.level }}<br>
<strong>Classe :</strong> {{ student.class_name }}<br>
<strong>Date :</strong> {{ date }}
</div>
{% for domaine in domaines %}
<table class="domain-table" {% if not forloop.first %}style="page-break-before: always;"{% endif %}>
<thead>
<tr class="domain-header">
<th colspan="4" style="text-align:center">{{ domaine.nom }}</th>
</tr>
<tr>
<th class="competence-header" style="min-width: 120px;">Compétence</th>
<th class="competence-header" style="width: 28px; text-align:center;">1</th>
<th class="competence-header" style="width: 28px; text-align:center;">2</th>
<th class="competence-header" style="width: 28px; text-align:center;">3</th>
</tr>
</thead>
<tbody>
{% for categorie in domaine.categories %}
<tr class="category-row">
<td colspan="4">{{ categorie.nom }}</td>
</tr>
{% for competence in categorie.competences %}
<tr>
<td class="competence-nom">{{ competence.nom }}</td>
{% for note in "123" %}
<td style="text-align:center; width:28px;">
{% if competence.score|stringformat:"s" == note %}
<span style="color:#059669; font-weight:bold; font-size:1.1em;">&#10003;</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% endfor %}
</body>
</html>

View File

@ -96,9 +96,6 @@ def getArgFromRequest(_argument, _request):
resultat = data[_argument] resultat = data[_argument]
return resultat return resultat
from io import BytesIO
from PyPDF2 import PdfMerger
def merge_files_pdf(file_paths): def merge_files_pdf(file_paths):
""" """
Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire. Fusionne plusieurs fichiers PDF et retourne le contenu fusionné en mémoire.

View File

@ -2,13 +2,19 @@ from django.http.response import JsonResponse
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework import status from rest_framework import status
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from Subscriptions.models import StudentCompetency, Student from Subscriptions.models import StudentCompetency, Student
from Common.models import Domain from Common.models import Domain
from School.models import Competency from django.conf import settings
from N3wtSchool.bdd import delete_object from datetime import date
from N3wtSchool.renderers import render_to_pdf
from django.core.files import File
from io import BytesIO
import os
import logging
logger = logging.getLogger(__name__)
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')
@method_decorator(ensure_csrf_cookie, name='dispatch') @method_decorator(ensure_csrf_cookie, name='dispatch')
@ -107,6 +113,91 @@ class StudentCompetencyListCreateView(APIView):
updated.append(comp_id) updated.append(comp_id)
except StudentCompetency.DoesNotExist: except StudentCompetency.DoesNotExist:
errors.append({"competenceId": comp_id, "error": "not found"}) errors.append({"competenceId": comp_id, "error": "not found"})
# Génération du PDF si au moins une compétence a été mise à jour
if updated:
student = Student.objects.get(id=student_id)
# Reconstituer la structure "domaines" comme dans le GET
student_competencies = StudentCompetency.objects.filter(student=student).select_related(
'establishment_competency',
'establishment_competency__competency',
'establishment_competency__competency__category',
'establishment_competency__competency__category__domain',
'establishment_competency__custom_category',
'establishment_competency__custom_category__domain',
)
result = []
domaines = Domain.objects.all()
for domaine in domaines:
domaine_dict = {
"nom": domaine.name,
"categories": []
}
categories = domaine.categories.all()
for categorie in categories:
categorie_dict = {
"nom": categorie.name,
"competences": []
}
for sc in student_competencies:
ec = sc.establishment_competency
if ec.competency and ec.competency.category_id == categorie.id:
comp = ec.competency
categorie_dict["competences"].append({
"nom": comp.name,
"score": sc.score,
"comment": sc.comment or "",
})
elif ec.competency is None and ec.custom_category_id == categorie.id:
categorie_dict["competences"].append({
"nom": ec.custom_name,
"score": sc.score,
"comment": sc.comment or "",
})
if categorie_dict["competences"]:
domaine_dict["categories"].append(categorie_dict)
if domaine_dict["categories"]:
result.append(domaine_dict)
context = {
"student": {
"first_name": student.first_name,
"last_name": student.last_name,
"level": student.level,
"class_name": student.associated_class.atmosphere_name,
},
"date": date.today().strftime("%d/%m/%Y"),
"domaines": result,
}
print('génération du PDF...')
pdf_result = render_to_pdf('pdfs/bilan_competences.html', context)
# Vérifier si un fichier bilan_form existe déjà et le supprimer
if student.bilan_form and student.bilan_form.name:
if os.path.isabs(student.bilan_form.path):
existing_file_path = student.bilan_form.path
else:
existing_file_path = os.path.join(settings.MEDIA_ROOT, student.bilan_form.name.lstrip('/'))
if os.path.exists(existing_file_path):
os.remove(existing_file_path)
student.bilan_form.delete(save=False)
logger.info(f"Ancien PDF supprimé : {existing_file_path}")
else:
logger.info(f"File does not exist: {existing_file_path}")
try:
filename = f"bilan_competences_{student.last_name}_{student.first_name}.pdf"
student.bilan_form.save(
os.path.basename(filename), # Utiliser uniquement le nom de fichier
File(BytesIO(pdf_result.content)),
save=True
)
except Exception as e:
logger.error(f"Erreur lors de la sauvegarde du fichier PDF : {e}")
raise
# Exemple : retour du PDF dans la réponse HTTP (pour test)
return JsonResponse({"updated": updated, "errors": errors}, status=200) return JsonResponse({"updated": updated, "errors": errors}, status=200)
@method_decorator(csrf_protect, name='dispatch') @method_decorator(csrf_protect, name='dispatch')

View File

@ -136,7 +136,6 @@ def search_students(request):
registrationform__establishment_id=establishment_id registrationform__establishment_id=establishment_id
).distinct() ).distinct()
# Sérialisation simple (adapte selon ton besoin)
results = [ results = [
{ {
'id': student.id, 'id': student.id,

View File

@ -14,7 +14,7 @@ test_mode = os.getenv('TEST_MODE', 'False') == 'True'
commands = [ commands = [
["python", "manage.py", "collectstatic", "--noinput"], ["python", "manage.py", "collectstatic", "--noinput"],
["python", "manage.py", "flush", "--noinput"], # ["python", "manage.py", "flush", "--noinput"],
["python", "manage.py", "makemigrations", "Common", "--noinput"], ["python", "manage.py", "makemigrations", "Common", "--noinput"],
["python", "manage.py", "makemigrations", "Establishment", "--noinput"], ["python", "manage.py", "makemigrations", "Establishment", "--noinput"],
["python", "manage.py", "makemigrations", "Settings", "--noinput"], ["python", "manage.py", "makemigrations", "Settings", "--noinput"],
@ -27,18 +27,18 @@ commands = [
["python", "manage.py", "migrate", "--noinput"] ["python", "manage.py", "migrate", "--noinput"]
] ]
test_commands = [ # test_commands = [
["python", "manage.py", "init_mock_datas"] # ["python", "manage.py", "init_mock_datas"]
] # ]
for command in commands: for command in commands:
if run_command(command) != 0: if run_command(command) != 0:
exit(1) exit(1)
if test_mode: # if test_mode:
for test_command in test_commands: # for test_command in test_commands:
if run_command(test_command) != 0: # if run_command(test_command) != 0:
exit(1) # exit(1)
# Lancer les processus en parallèle # Lancer les processus en parallèle
processes = [ processes = [

View File

@ -11,7 +11,10 @@ import Orientation from '@/components/Grades/Orientation';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle'; import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import Button from '@/components/Button'; import Button from '@/components/Button';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL } from '@/utils/Url'; import {
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
BASE_URL,
} from '@/utils/Url';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { import {
fetchStudents, fetchStudents,
@ -21,6 +24,9 @@ import {
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext'; import { useClasses } from '@/context/ClassesContext';
import StudentInput from '@/components/Grades/StudentInput'; import StudentInput from '@/components/Grades/StudentInput';
import { Award, BookOpen } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
export default function Page() { export default function Page() {
const router = useRouter(); const router = useRouter();
@ -145,9 +151,14 @@ export default function Page() {
return ( return (
<div className="p-8 space-y-8"> <div className="p-8 space-y-8">
{/* Sélection de l'élève */} {/* Sélection de l'élève */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <SectionHeader
<h2 className="text-xl font-semibold mb-4">Sélectionner un élève</h2> icon={BookOpen}
<div className="flex flex-col sm:flex-row sm:items-end gap-4"> title="Suivi pédagogique"
description="Suivez le parcours d'un élève"
/>
<div className="flex flex-col md:flex-row md:items-start gap-4">
{/* Recherche élève + bouton + fiche élève */}
<div className="flex-1 flex flex-row gap-4 items-start">
<div className="flex-1"> <div className="flex-1">
<StudentInput <StudentInput
label="Recherche élève" label="Recherche élève"
@ -161,49 +172,78 @@ export default function Page() {
establishmentId={selectedEstablishmentId} establishmentId={selectedEstablishmentId}
required required
/> />
{/* <SelectChoice
name="selectedStudent"
label="Élève"
placeHolder="Sélectionnez un élève"
selected={formData.selectedStudent || ''}
callback={(e) => handleChange('selectedStudent', e.target.value)}
choices={students.map((student) => ({
value: student.id,
label: `${student.last_name} ${student.first_name} - ${getNiveauLabel(
student.level
)} (${student.associated_class_name})`,
}))}
required
/> */}
</div> </div>
{formData.selectedStudent && (
<div className="ml-4 flex flex-col items-center min-w-[220px] max-w-xs p-4 rounded-lg border border-emerald-100 shadow">
{(() => {
const student = students.find(
(s) => s.id === formData.selectedStudent
);
if (!student) return null;
return (
<>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-20 h-20 object-cover rounded-full border-4 border-emerald-200 mb-2 shadow"
/>
) : (
<div className="w-20 h-20 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl mb-2 border-4 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<div className="text-center">
<div className="text-base font-semibold text-emerald-800">
{student.last_name} {student.first_name}
</div>
<div className="text-xs text-gray-600 mt-1">
Niveau :{' '}
<span className="font-medium">
{getNiveauLabel(student.level)}
</span>
</div>
<div className="text-xs text-gray-600">
Classe :{' '}
<span className="font-medium">
{student.associated_class_name}
</span>
</div>
</div>
{/* Bouton bilan de compétences en dessous */}
<Button <Button
text="Réaliser le bilan de compétences"
primary primary
disabled={!formData.selectedStudent}
onClick={() => { onClick={() => {
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`; const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`;
router.push(`${url}`); router.push(`${url}`);
}} }}
className={`px-6 py-2 rounded-md shadow ${ className="mt-4 px-6 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600"
!formData.selectedStudent icon={<Award className="w-6 h-6" />}
? 'bg-gray-300 text-gray-500 cursor-not-allowed' title="Réaliser le bilan de compétences"
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
/> />
</div>
</div>
{formData.selectedStudent && (
<>
{/* <AcademicResults results={academicResults} /> */}
<Attendance absences={absences} />
<GradesStatsCircle grades={grades} />
{/* <Remarks remarks={remarks} />
<WorkPlan workPlan={workPlan} />
<Homeworks homeworks={homeworks} />
<SpecificEvaluations specificEvaluations={specificEvaluations} />
<Orientation orientation={orientation} /> */}
</> </>
);
})()}
</div>
)}
</div>
</div>
{/* Partie basse : stats à gauche, présence à droite, sans bg */}
{formData.selectedStudent && (
<div className="flex flex-col gap-8 w-full justify-center items-stretch mt-8">
<div className="w-3/4 flex flex-row items-stretch gap-4 mx-auto">
<div className="flex-1 flex items-center justify-center">
<Attendance absences={absences} />
</div>
<div className="flex-1 flex items-center justify-center">
<GradesStatsCircle grades={grades} />
</div>
</div>
<div className="flex items-center justify-center">
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
</div>
</div>
)} )}
</div> </div>
); );

View File

@ -107,7 +107,16 @@ export default function StudentCompetenciesPage() {
/> />
</div> </div>
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button text="Enregistrer le bilan" primary type="submit" /> <Button
text="Retour"
type="button"
onClick={(e) => {
e.preventDefault();
router.back();
}}
className="mr-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
/>
<Button text="Enregistrer" primary type="submit" />
</div> </div>
</form> </form>
</div> </div>

View File

@ -27,7 +27,8 @@ const Button = ({
return ( return (
<button className={buttonClass} onClick={handleClick} disabled={disabled}> <button className={buttonClass} onClick={handleClick} disabled={disabled}>
{icon && <span className="mr-2">{icon}</span>} {icon && text && <span className="mr-2">{icon}</span>}
{icon && !text && icon}
{text} {text}
</button> </button>
); );

View File

@ -2,7 +2,7 @@ import React from 'react';
export default function Attendance({ absences }) { export default function Attendance({ absences }) {
return ( return (
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <div className="w-full bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-xl font-semibold mb-4">Présence et assiduité</h2> <h2 className="text-xl font-semibold mb-4">Présence et assiduité</h2>
<ol className="relative border-l border-emerald-200"> <ol className="relative border-l border-emerald-200">
{absences.map((absence, idx) => ( {absences.map((absence, idx) => (

View File

@ -0,0 +1,65 @@
import React from 'react';
export default function GradesDomainBarChart({ studentCompetencies }) {
if (!studentCompetencies?.data) return null;
// Calcul du score moyen par domaine
const domainStats = studentCompetencies.data.map((domaine) => {
const allScores = domaine.categories.flatMap(
(cat) =>
cat.competences
.map((comp) => comp.score ?? 0)
.filter((score) => score > 0) // Ignorer les notes à 0
);
const avg =
allScores.length > 0
? (allScores.reduce((a, b) => a + b, 0) / allScores.length).toFixed(2)
: 0;
return {
name: domaine.domaine_nom,
avg: Number(avg),
count: allScores.length,
};
});
// Détermine la couleur de la jauge selon la moyenne
const getBarGradient = (avg) => {
if (avg > 0 && avg <= 1) return 'bg-gradient-to-r from-red-200 to-red-400';
if (avg > 1 && avg <= 2)
return 'bg-gradient-to-r from-yellow-200 to-yellow-400';
if (avg > 2) return 'bg-gradient-to-r from-emerald-200 to-emerald-500';
return 'bg-gray-200';
};
return (
<div className="w-3/4 flex flex-col items-center gap-4 bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-xl font-semibold mb-2">Moyenne par domaine</h2>
<div className="w-full flex flex-col gap-2">
{domainStats.map((d) => (
<div key={d.name} className="flex items-center w-full">
<span className="font-medium text-left" style={{ width: '30%' }}>
{d.name}
</span>
<div className="flex items-center" style={{ width: '40%' }}>
<div className="w-full bg-emerald-100 h-3 rounded overflow-hidden">
<div
className={`h-3 rounded ${getBarGradient(d.avg)}`}
style={{ width: `${d.avg * 33.33}%` }}
/>
</div>
</div>
<span
className="text-xs font-semibold text-emerald-700 text-left pl-2 flex-shrink-0"
style={{ width: '10%' }}
>
{d.avg}
</span>
<span className="text-gray-500 text-left" style={{ width: '20%' }}>
({`compétences évaluées ${d.count}`})
</span>
</div>
))}
</div>
</div>
);
}

View File

@ -14,7 +14,7 @@ export default function GradesStatsCircle({ grades }) {
const percent = total ? Math.round((acquired / total) * 100) : 0; const percent = total ? Math.round((acquired / total) * 100) : 0;
return ( return (
<div className="flex flex-col items-center gap-4 bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <div className="w-full flex flex-col items-center gap-4 bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-xl font-semibold mb-2">Statistiques globales</h2> <h2 className="text-xl font-semibold mb-2">Statistiques globales</h2>
<div style={{ width: 120, height: 120 }}> <div style={{ width: 120, height: 120 }}>
<CircularProgressbar <CircularProgressbar

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { BASE_URL } from '@/utils/Url'; import { BASE_URL } from '@/utils/Url';
export default function StudentInput({ export default function StudentInput({
@ -13,6 +13,18 @@ export default function StudentInput({
const [suggestions, setSuggestions] = useState([]); const [suggestions, setSuggestions] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
// Désélectionner si l'input ne correspond plus à l'élève sélectionné
useEffect(() => {
if (
selectedStudent &&
inputValue !==
`${selectedStudent.last_name} ${selectedStudent.first_name}`
) {
setSelectedStudent(null);
}
// eslint-disable-next-line
}, [inputValue]);
const handleInputChange = async (e) => { const handleInputChange = async (e) => {
const value = e.target.value; const value = e.target.value;
setInputValue(value); setInputValue(value);
@ -31,9 +43,7 @@ export default function StudentInput({
const handleSuggestionClick = (student) => { const handleSuggestionClick = (student) => {
setSelectedStudent(student); setSelectedStudent(student);
setInputValue( setInputValue(`${student.last_name} ${student.first_name}`);
`${student.last_name} ${student.first_name} (${student.level}) - ${student.associated_class_name}`
);
setSuggestions([]); setSuggestions([]);
}; };