From 0fe6c761892097d043902f4f051b9fdb5fef29d0 Mon Sep 17 00:00:00 2001 From: N3WT DE COMPET Date: Wed, 21 May 2025 20:44:37 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20G=C3=A9n=C3=A9ration=20du=20bilan=20de?= =?UTF-8?q?=20comp=C3=A9tence=20en=20PDF=20[#16]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Back-End/Subscriptions/models.py | 4 + Back-End/Subscriptions/serializers.py | 2 +- .../templates/pdfs/bilan_competences.html | 74 ++++++++++ Back-End/Subscriptions/util.py | 3 - .../views/student_competencies_views.py | 97 +++++++++++++- Back-End/Subscriptions/views/student_views.py | 1 - Back-End/start.py | 16 +-- .../src/app/[locale]/admin/grades/page.js | 126 ++++++++++++------ .../admin/grades/studentCompetencies/page.js | 11 +- Front-End/src/components/Button.js | 3 +- Front-End/src/components/Grades/Attendance.js | 2 +- .../components/Grades/GradesDomainBarChart.js | 65 +++++++++ .../components/Grades/GradesStatsCircle.js | 2 +- .../src/components/Grades/StudentInput.js | 18 ++- 14 files changed, 357 insertions(+), 67 deletions(-) create mode 100644 Back-End/Subscriptions/templates/pdfs/bilan_competences.html create mode 100644 Front-End/src/components/Grades/GradesDomainBarChart.js diff --git a/Back-End/Subscriptions/models.py b/Back-End/Subscriptions/models.py index d4e4f1f..8c5488d 100644 --- a/Back-End/Subscriptions/models.py +++ b/Back-End/Subscriptions/models.py @@ -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 d’inscription. @@ -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) diff --git a/Back-End/Subscriptions/serializers.py b/Back-End/Subscriptions/serializers.py index c3f6105..a886840 100644 --- a/Back-End/Subscriptions/serializers.py +++ b/Back-End/Subscriptions/serializers.py @@ -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) diff --git a/Back-End/Subscriptions/templates/pdfs/bilan_competences.html b/Back-End/Subscriptions/templates/pdfs/bilan_competences.html new file mode 100644 index 0000000..13478c0 --- /dev/null +++ b/Back-End/Subscriptions/templates/pdfs/bilan_competences.html @@ -0,0 +1,74 @@ + + + + + Bilan de compétences + + + +

Bilan de compétences

+
+ Élève : {{ student.last_name }} {{ student.first_name }}
+ Niveau : {{ student.level }}
+ Classe : {{ student.class_name }}
+ Date : {{ date }} +
+ + {% for domaine in domaines %} + + + + + + + + + + + + + + {% for categorie in domaine.categories %} + + + + {% for competence in categorie.competences %} + + + {% for note in "123" %} + + {% endfor %} + + {% endfor %} + {% endfor %} + +
{{ domaine.nom }}
Compétence123
{{ categorie.nom }}
{{ competence.nom }} + {% if competence.score|stringformat:"s" == note %} + + {% endif %} +
+ {% endfor %} + + \ No newline at end of file diff --git a/Back-End/Subscriptions/util.py b/Back-End/Subscriptions/util.py index b33f244..cc7f479 100644 --- a/Back-End/Subscriptions/util.py +++ b/Back-End/Subscriptions/util.py @@ -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. diff --git a/Back-End/Subscriptions/views/student_competencies_views.py b/Back-End/Subscriptions/views/student_competencies_views.py index d3f8ce4..fad8114 100644 --- a/Back-End/Subscriptions/views/student_competencies_views.py +++ b/Back-End/Subscriptions/views/student_competencies_views.py @@ -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') diff --git a/Back-End/Subscriptions/views/student_views.py b/Back-End/Subscriptions/views/student_views.py index fcacaf1..47ee3b9 100644 --- a/Back-End/Subscriptions/views/student_views.py +++ b/Back-End/Subscriptions/views/student_views.py @@ -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, diff --git a/Back-End/start.py b/Back-End/start.py index a6b9def..302da2b 100644 --- a/Back-End/start.py +++ b/Back-End/start.py @@ -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 = [ diff --git a/Front-End/src/app/[locale]/admin/grades/page.js b/Front-End/src/app/[locale]/admin/grades/page.js index b290ed7..293c80e 100644 --- a/Front-End/src/app/[locale]/admin/grades/page.js +++ b/Front-End/src/app/[locale]/admin/grades/page.js @@ -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 (
{/* Sélection de l'élève */} -
-

Sélectionner un élève

-
+ +
+ {/* Recherche élève + bouton + fiche élève */} +
- {/* 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 - /> */}
-
+ )}
- + {/* Partie basse : stats à gauche, présence à droite, sans bg */} {formData.selectedStudent && ( - <> - {/* */} - - - {/* - - - - */} - +
+
+
+ +
+
+ +
+
+
+ +
+
)}
); diff --git a/Front-End/src/app/[locale]/admin/grades/studentCompetencies/page.js b/Front-End/src/app/[locale]/admin/grades/studentCompetencies/page.js index 0bac7bc..ff75ab3 100644 --- a/Front-End/src/app/[locale]/admin/grades/studentCompetencies/page.js +++ b/Front-End/src/app/[locale]/admin/grades/studentCompetencies/page.js @@ -107,7 +107,16 @@ export default function StudentCompetenciesPage() { />
-
diff --git a/Front-End/src/components/Button.js b/Front-End/src/components/Button.js index df209fa..708377c 100644 --- a/Front-End/src/components/Button.js +++ b/Front-End/src/components/Button.js @@ -27,7 +27,8 @@ const Button = ({ return ( ); diff --git a/Front-End/src/components/Grades/Attendance.js b/Front-End/src/components/Grades/Attendance.js index d95f772..3781296 100644 --- a/Front-End/src/components/Grades/Attendance.js +++ b/Front-End/src/components/Grades/Attendance.js @@ -2,7 +2,7 @@ import React from 'react'; export default function Attendance({ absences }) { return ( -
+

Présence et assiduité

    {absences.map((absence, idx) => ( diff --git a/Front-End/src/components/Grades/GradesDomainBarChart.js b/Front-End/src/components/Grades/GradesDomainBarChart.js new file mode 100644 index 0000000..07f459f --- /dev/null +++ b/Front-End/src/components/Grades/GradesDomainBarChart.js @@ -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 ( +
    +

    Moyenne par domaine

    +
    + {domainStats.map((d) => ( +
    + + {d.name} + +
    +
    +
    +
    +
    + + {d.avg} + + + ({`compétences évaluées ${d.count}`}) + +
    + ))} +
    +
    + ); +} diff --git a/Front-End/src/components/Grades/GradesStatsCircle.js b/Front-End/src/components/Grades/GradesStatsCircle.js index 2e1abbe..e03d0fb 100644 --- a/Front-End/src/components/Grades/GradesStatsCircle.js +++ b/Front-End/src/components/Grades/GradesStatsCircle.js @@ -14,7 +14,7 @@ export default function GradesStatsCircle({ grades }) { const percent = total ? Math.round((acquired / total) * 100) : 0; return ( -
    +

    Statistiques globales

    { + 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([]); };