feat: Rattachement d'un dossier de compétences à une période scolaire

(configuration dans l'établissement) [#16]
This commit is contained in:
N3WT DE COMPET
2025-05-22 01:25:34 +02:00
parent 0fe6c76189
commit 7de839ee5c
18 changed files with 450 additions and 274 deletions

View File

@ -223,7 +223,7 @@ def makeToken(user):
"""
try:
# Récupérer tous les rôles de l'utilisateur actifs
roles = ProfileRole.objects.filter(profile=user, is_active=True).values('role_type', 'establishment__id', 'establishment__name')
roles = ProfileRole.objects.filter(profile=user, is_active=True).values('role_type', 'establishment__id', 'establishment__name', 'establishment__evaluation_frequency')
# Générer le JWT avec la bonne syntaxe datetime
access_payload = {

View File

@ -7,11 +7,17 @@ class StructureType(models.IntegerChoices):
PRIMAIRE = 2, _('Primaire')
SECONDAIRE = 3, _('Secondaire')
class EvaluationFrequency(models.IntegerChoices):
TRIMESTER = 1, _("Trimestre")
SEMESTER = 2, _("Semestre")
YEAR = 3, _("Année")
class Establishment(models.Model):
name = models.CharField(max_length=255, unique=True)
address = models.CharField(max_length=255)
total_capacity = models.IntegerField()
establishment_type = ArrayField(models.IntegerField(choices=StructureType.choices))
evaluation_frequency = models.IntegerField(choices=EvaluationFrequency.choices, default=EvaluationFrequency.TRIMESTER)
licence_code = models.CharField(max_length=100, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@ -56,7 +56,23 @@ 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}"
# On récupère le RegistrationForm lié à l'élève
register_form = getattr(instance.student, 'registrationform', None)
if register_form:
pk = register_form.pk
else:
# fallback sur l'id de l'élève si pas de registrationform
pk = instance.student.pk
return f"registration_files/dossier_rf_{pk}/bilan/{filename}"
class BilanCompetence(models.Model):
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='bilans')
file = models.FileField(upload_to=registration_bilan_form_upload_to, null=True, blank=True)
period = models.CharField(max_length=20, help_text="Période ex: T1-2024_2025, S1-2024_2025, A-2024_2025")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.student} - {self.period}"
class Student(models.Model):
"""
@ -94,7 +110,6 @@ 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)
@ -187,6 +202,15 @@ class Student(models.Model):
return self.birth_date.strftime('%d-%m-%Y')
return None
class BilanCompetence(models.Model):
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='bilans')
file = models.FileField(upload_to=registration_bilan_form_upload_to, null=True, blank=True)
period = models.CharField(max_length=20, help_text="Période ex: T1-2024_2025, S1-2024_2025, A-2024_2025")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.student} - {self.period}"
class RegistrationFileGroup(models.Model):
name = models.CharField(max_length=255, default="")
description = models.TextField(blank=True, null=True)
@ -331,16 +355,22 @@ class StudentCompetency(models.Model):
establishment_competency = models.ForeignKey('School.EstablishmentCompetency', on_delete=models.CASCADE, related_name='student_scores')
score = models.IntegerField(null=True, blank=True)
comment = models.TextField(blank=True, null=True)
period = models.CharField(
max_length=20,
help_text="Période d'évaluation ex: T1-2024_2025, S1-2024_2025, A-2024_2025",
default="",
blank=True
)
class Meta:
unique_together = ('student', 'establishment_competency')
unique_together = ('student', 'establishment_competency', 'period')
indexes = [
models.Index(fields=['student', 'establishment_competency']),
models.Index(fields=['student', 'establishment_competency', 'period']),
]
def __str__(self):
return f"{self.student} - {self.establishment_competency} - Score: {self.score}"
return f"{self.student} - {self.establishment_competency} - Score: {self.score} - Period: {self.period}"
####### Parent files templates (par dossier d'inscription) #######
class RegistrationParentFileTemplate(models.Model):

View File

@ -10,18 +10,16 @@ from .models import (
RegistrationSchoolFileTemplate,
RegistrationParentFileMaster,
RegistrationParentFileTemplate,
AbsenceManagement
AbsenceManagement,
BilanCompetence
)
from School.models import SchoolClass, Fee, Discount, FeeType
from School.serializers import FeeSerializer, DiscountSerializer
from Auth.models import ProfileRole, Profile
from Auth.serializers import ProfileSerializer, ProfileRoleSerializer
from GestionMessagerie.models import Messagerie
from GestionNotification.models import Notification
from N3wtSchool import settings
from django.utils import timezone
import pytz
from datetime import datetime
import Subscriptions.util as util
class AbsenceManagementSerializer(serializers.ModelSerializer):
@ -415,20 +413,26 @@ class GuardianByDICreationSerializer(serializers.ModelSerializer):
return obj.profile_role.profile.id # Retourne l'ID du profil associé
return None
class BilanCompetenceSerializer(serializers.ModelSerializer):
class Meta:
model = BilanCompetence
fields = ['id', 'file', 'period', 'created_at']
class StudentByRFCreationSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False)
guardians = GuardianByDICreationSerializer(many=True, required=False)
associated_class_name = serializers.SerializerMethodField()
bilans = BilanCompetenceSerializer(many=True, read_only=True)
class Meta:
model = Student
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo']
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo', 'bilans']
def __init__(self, *args, **kwargs):
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
for field in self.fields:
self.fields[field].required = False
def get_associated_class_name(self, obj):
return obj.associated_class.atmosphere_name if obj.associated_class else None

View File

@ -33,6 +33,7 @@
<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>Période :</strong> {{ period }}<br>
<strong>Date :</strong> {{ date }}
</div>
@ -70,5 +71,42 @@
</tbody>
</table>
{% endfor %}
<div style="margin-top: 60px; padding: 0; max-width: 700px;">
<div style="
min-height: 180px;
background: #fff;
border: 1.5px dashed #a7f3d0;
border-radius: 12px;
padding: 24px 24px 18px 24px;
display: flex;
flex-direction: column;
justify-content: flex-start;
position: relative;
margin-bottom: 64px; /* Augmente l'espace après l'encadré */
">
<div style="font-weight: bold; color: #059669; font-size: 1.25em; display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span>Appréciation générale / Commentaire : </span>
</div>
<!-- Espace vide pour écrire -->
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
<div style="flex:1;"></div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 48px; margin-top: 32px;">
<div>
<span style="font-weight: bold; color: #059669;font-size: 1.25em;">Date :</span>
<span style="display: inline-block; min-width: 120px; border-bottom: 1.5px solid #a7f3d0; margin-left: 8px;">&nbsp;</span>
</div>
<div>
<span style="font-weight: bold; color: #059669;font-size: 1.25em;">Signature :</span>
<span style="display: inline-block; min-width: 180px; border-bottom: 2px solid #059669; margin-left: 8px;">&nbsp;</span>
</div>
</div>
</div>
</body>
</html>

View File

@ -26,6 +26,7 @@ from Subscriptions.models import (
)
from Subscriptions.automate import updateStateMachine
from School.models import EstablishmentCompetency
from Establishment.models import Establishment
from N3wtSchool import settings, bdd
from django.db.models import Q
@ -392,11 +393,28 @@ class RegisterFormWithIdView(APIView):
competency__category__domain__cycle=cycle
)
establishment_competencies = establishment_competencies.distinct()
establishment = registerForm.establishment
evaluation_frequency = establishment.evaluation_frequency # 1=Trimestre, 2=Semestre, 3=Année
school_year = registerForm.school_year # ex: "2024_2025"
establishment_competencies = establishment_competencies.distinct()
periods = []
if evaluation_frequency == 1: # Trimestre
periods = [f"T{i+1}_{school_year}" for i in range(3)]
elif evaluation_frequency == 2: # Semestre
periods = [f"S{i+1}_{school_year}" for i in range(2)]
elif evaluation_frequency == 3: # Année
periods = [f"A_{school_year}"]
for ec in establishment_competencies:
StudentCompetency.objects.get_or_create(
student=student,
establishment_competency=ec
)
for period in periods:
StudentCompetency.objects.get_or_create(
student=student,
establishment_competency=ec,
period=period
)
except Exception as e:
logger.error(f"Erreur lors de la valorisation des StudentCompetency: {e}")

View File

@ -6,13 +6,14 @@ 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 django.conf import settings
from Subscriptions.models import BilanCompetence
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
from django.conf import settings
logger = logging.getLogger(__name__)
@ -21,6 +22,7 @@ logger = logging.getLogger(__name__)
class StudentCompetencyListCreateView(APIView):
def get(self, request):
student_id = request.GET.get('student_id')
period = request.GET.get('period')
if not student_id:
return JsonResponse({'error': 'student_id requis'}, status=400)
try:
@ -28,7 +30,12 @@ class StudentCompetencyListCreateView(APIView):
except Student.DoesNotExist:
return JsonResponse({'error': 'Élève introuvable'}, status=404)
student_competencies = StudentCompetency.objects.filter(student=student).select_related(
# Filtrer par student ET period si period est fourni
filter_kwargs = {'student': student}
if period:
filter_kwargs['period'] = period
student_competencies = StudentCompetency.objects.filter(**filter_kwargs).select_related(
'establishment_competency',
'establishment_competency__competency',
'establishment_competency__competency__category',
@ -64,6 +71,7 @@ class StudentCompetencyListCreateView(APIView):
"nom": comp.name,
"score": sc.score,
"comment": sc.comment or "",
"period":sc.period or ""
})
total_competencies += 1
# Cas compétence custom
@ -73,6 +81,7 @@ class StudentCompetencyListCreateView(APIView):
"nom": ec.custom_name,
"score": sc.score,
"comment": sc.comment or "",
"period":sc.period or ""
})
total_competencies += 1
if categorie_dict["competences"]:
@ -99,6 +108,7 @@ class StudentCompetencyListCreateView(APIView):
comp_id = item.get("competenceId")
grade = item.get("grade")
student_id = item.get('studentId')
period = item.get('period')
if comp_id is None or grade is None:
errors.append({"competenceId": comp_id, "error": "champ manquant"})
continue
@ -106,7 +116,8 @@ class StudentCompetencyListCreateView(APIView):
# Ajoute le filtre student_id
sc = StudentCompetency.objects.get(
establishment_competency_id=comp_id,
student_id=student_id
student_id=student_id,
period=period
)
sc.score = grade
sc.save()
@ -117,8 +128,8 @@ class StudentCompetencyListCreateView(APIView):
# 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(
# Reconstituer la structure "domaines" pour la période concernée uniquement
student_competencies = StudentCompetency.objects.filter(student=student, period=period).select_related(
'establishment_competency',
'establishment_competency__competency',
'establishment_competency__competency__category',
@ -158,7 +169,7 @@ class StudentCompetencyListCreateView(APIView):
domaine_dict["categories"].append(categorie_dict)
if domaine_dict["categories"]:
result.append(domaine_dict)
context = {
"student": {
"first_name": student.first_name,
@ -166,37 +177,38 @@ class StudentCompetencyListCreateView(APIView):
"level": student.level,
"class_name": student.associated_class.atmosphere_name,
},
"period": period,
"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
filename = f"bilan_competences_{student.last_name}_{student.first_name}_{period}.pdf"
# Vérifier si un bilan existe déjà pour cet élève et cette période
existing_bilan = BilanCompetence.objects.filter(student=student, period=period).first()
if existing_bilan:
# Supprimer le fichier physique si présent
if existing_bilan.file and existing_bilan.file.name:
file_path = existing_bilan.file.path
if os.path.exists(file_path):
os.remove(file_path)
existing_bilan.delete()
bilan = BilanCompetence.objects.create(
student=student,
period=period
)
bilan.file.save(
os.path.basename(filename),
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)
@ -204,18 +216,4 @@ class StudentCompetencyListCreateView(APIView):
@method_decorator(ensure_csrf_cookie, name='dispatch')
class StudentCompetencySimpleView(APIView):
def get(self, request, id):
return JsonResponse("ok", safe=False, status=status.HTTP_200_OK)
# def put(self, request, id):
# try:
# absence = AbsenceManagement.objects.get(id=id)
# serializer = AbsenceManagementSerializer(absence, data=request.data)
# if serializer.is_valid():
# serializer.save()
# return JsonResponse(serializer.data, safe=False, status=status.HTTP_200_OK)
# return JsonResponse(serializer.errors, safe=False, status=status.HTTP_400_BAD_REQUEST)
# except AbsenceManagement.DoesNotExist:
# return JsonResponse({"error": "Absence not found"}, safe=False, status=status.HTTP_404_NOT_FOUND)
# def delete(self, request, id):
# return delete_object(AbsenceManagement, id)
return JsonResponse("ok", safe=False, status=status.HTTP_200_OK)

View File

@ -143,7 +143,7 @@ def search_students(request):
'last_name': student.last_name,
'level': getattr(student.level, 'name', ''),
'associated_class_name': student.associated_class.atmosphere_name if student.associated_class else '',
'photo': student.photo.url if student.photo else None,
'photo': student.photo.url if student.photo else None
}
for student in students
]