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):
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):
"""
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_postal_code = models.IntegerField(default=0, 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
profiles = models.ManyToManyField('Auth.Profile', blank=True)

View File

@ -422,7 +422,7 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
class Meta:
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):
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]
return resultat
from io import BytesIO
from PyPDF2 import PdfMerger
def merge_files_pdf(file_paths):
"""
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 import status
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.utils.decorators import method_decorator
from Subscriptions.models import StudentCompetency, Student
from Common.models import Domain
from School.models import Competency
from N3wtSchool.bdd import delete_object
from django.conf import settings
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(ensure_csrf_cookie, name='dispatch')
@ -107,6 +113,91 @@ class StudentCompetencyListCreateView(APIView):
updated.append(comp_id)
except StudentCompetency.DoesNotExist:
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)
@method_decorator(csrf_protect, name='dispatch')

View File

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

View File

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

View File

@ -11,7 +11,10 @@ import Orientation from '@/components/Grades/Orientation';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import Button from '@/components/Button';
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 {
fetchStudents,
@ -21,6 +24,9 @@ import {
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
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() {
const router = useRouter();
@ -145,9 +151,14 @@ export default function Page() {
return (
<div className="p-8 space-y-8">
{/* Sélection de l'élève */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-xl font-semibold mb-4">Sélectionner un élève</h2>
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
<SectionHeader
icon={BookOpen}
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">
<StudentInput
label="Recherche élève"
@ -161,49 +172,78 @@ export default function Page() {
establishmentId={selectedEstablishmentId}
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>
<Button
text="Réaliser le bilan de compétences"
primary
disabled={!formData.selectedStudent}
onClick={() => {
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`;
router.push(`${url}`);
}}
className={`px-6 py-2 rounded-md shadow ${
!formData.selectedStudent
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
/>
{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
primary
onClick={() => {
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`;
router.push(`${url}`);
}}
className="mt-4 px-6 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600"
icon={<Award className="w-6 h-6" />}
title="Réaliser le bilan de compétences"
/>
</>
);
})()}
</div>
)}
</div>
</div>
{/* Partie basse : stats à gauche, présence à droite, sans bg */}
{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 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>
);

View File

@ -107,7 +107,16 @@ export default function StudentCompetenciesPage() {
/>
</div>
<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>
</form>
</div>

View File

@ -27,7 +27,8 @@ const Button = ({
return (
<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}
</button>
);

View File

@ -2,7 +2,7 @@ import React from 'react';
export default function Attendance({ absences }) {
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>
<ol className="relative border-l border-emerald-200">
{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;
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>
<div style={{ width: 120, height: 120 }}>
<CircularProgressbar

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { BASE_URL } from '@/utils/Url';
export default function StudentInput({
@ -13,6 +13,18 @@ export default function StudentInput({
const [suggestions, setSuggestions] = useState([]);
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 value = e.target.value;
setInputValue(value);
@ -31,9 +43,7 @@ export default function StudentInput({
const handleSuggestionClick = (student) => {
setSelectedStudent(student);
setInputValue(
`${student.last_name} ${student.first_name} (${student.level}) - ${student.associated_class_name}`
);
setInputValue(`${student.last_name} ${student.first_name}`);
setSuggestions([]);
};