Files
n3wt-school/Back-End/Subscriptions/models.py
N3WT DE COMPET b4f70e6bad feat: Sauvegarde des formulaires d'école dans les bons dossiers /
utilisation des bons composants dans les modales [N3WTS-17]
2026-01-18 18:44:13 +01:00

605 lines
27 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from django.db import models
from django.utils.timezone import now
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from datetime import datetime
import os
import logging
logger = logging.getLogger("SubscriptionModels")
class Language(models.Model):
"""
Représente une langue parlée par lélève.
"""
id = models.AutoField(primary_key=True)
label = models.CharField(max_length=200, default="")
def __str__(self):
return "LANGUAGE"
class Guardian(models.Model):
"""
Représente un responsable légal (parent/tuteur) dun élève.
"""
last_name = models.CharField(max_length=200, null=True, blank=True)
first_name = models.CharField(max_length=200, null=True, blank=True)
birth_date = models.DateField(null=True, blank=True)
address = models.CharField(max_length=200, default="", blank=True)
phone = models.CharField(max_length=200, default="", blank=True)
profession = models.CharField(max_length=200, default="", blank=True)
profile_role = models.OneToOneField('Auth.ProfileRole', on_delete=models.CASCADE, related_name='guardian_profile', null=True, blank=True)
@property
def email(self):
"""
Retourne l'email du profil associé via le ProfileRole.
"""
if self.profile_role and self.profile_role.profile:
return self.profile_role.profile.email
return None
def __str__(self):
return self.last_name + "_" + self.first_name
class Sibling(models.Model):
"""
Représente un frère ou une sœur dun élève.
"""
last_name = models.CharField(max_length=200, null=True, blank=True)
first_name = models.CharField(max_length=200, null=True, blank=True)
birth_date = models.DateField(null=True, blank=True)
def __str__(self):
return "SIBLING"
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):
# 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):
"""
Représente lélève inscrit ou en cours dinscription.
"""
class StudentGender(models.IntegerChoices):
NONE = 0, _('Sélection du genre')
MALE = 1, _('Garçon')
FEMALE = 2, _('Fille')
class StudentLevel(models.IntegerChoices):
NONE = 0, _('Sélection du niveau')
TPS = 1, _('TPS - Très Petite Section')
PS = 2, _('PS - Petite Section')
MS = 3, _('MS - Moyenne Section')
GS = 4, _('GS - Grande Section')
CP = 5, _('CP')
CE1 = 6, _('CE1')
CE2 = 7, _('CE2')
CM1 = 8, _('CM1')
CM2 = 9, _('CM2')
photo = models.FileField(
upload_to=registration_photo_upload_to,
null=True,
blank=True
)
last_name = models.CharField(max_length=200, default="")
first_name = models.CharField(max_length=200, default="")
gender = models.IntegerField(choices=StudentGender, default=StudentGender.NONE, blank=True)
level = models.ForeignKey('Common.Level', on_delete=models.SET_NULL, null=True, blank=True, related_name='students')
nationality = models.CharField(max_length=200, default="", blank=True)
address = models.CharField(max_length=200, default="", blank=True)
birth_date = models.DateField(null=True, blank=True)
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)
# Many-to-Many Relationship
profiles = models.ManyToManyField('Auth.Profile', blank=True)
# Many-to-Many Relationship
guardians = models.ManyToManyField('Subscriptions.Guardian', blank=True)
# Many-to-Many Relationship
siblings = models.ManyToManyField('Subscriptions.Sibling', blank=True)
# Many-to-Many Relationship
registration_files = models.ManyToManyField('Subscriptions.RegistrationSchoolFileTemplate', blank=True, related_name='students')
# Many-to-Many Relationship
spoken_languages = models.ManyToManyField('Subscriptions.Language', blank=True)
# One-to-Many Relationship
associated_class = models.ForeignKey('School.SchoolClass', on_delete=models.SET_NULL, null=True, blank=True, related_name='students')
def __str__(self):
return self.last_name + "_" + self.first_name
def getSpokenLanguages(self):
"""
Retourne la liste des langues parlées par lélève.
"""
return self.spoken_languages.all()
def getMainGuardian(self):
"""
Retourne le responsable légal principal de lélève.
"""
return self.guardians.all()[0]
def getGuardians(self):
"""
Retourne tous les responsables légaux de lélève.
"""
return self.guardians.all()
def getProfiles(self):
"""
Retourne les profils utilisateurs liés à lélève.
"""
return self.profiles.all()
def getSiblings(self):
"""
Retourne les frères et sœurs de lélève.
"""
return self.siblings.all()
def getNumberOfSiblings(self):
"""
Retourne le nombre de frères et sœurs.
"""
return self.siblings.count()
def get_photo_url(self):
"""
Retourne le chemin correct de la photo pour le template HTML.
Si la photo n'existe pas, retourne le chemin de l'image par défaut.
"""
if self.photo and hasattr(self.photo, 'url'):
# Retourne l'URL complète de la photo
return self.photo.url
@property
def age(self):
if self.birth_date:
today = datetime.today()
years = today.year - self.birth_date.year
months = today.month - self.birth_date.month
if today.day < self.birth_date.day:
months -= 1
if months < 0:
years -= 1
months += 12
# Determine the age format
if 6 <= months <= 12:
return f"{years} years 1/2"
else:
return f"{years} years"
return None
@property
def formatted_birth_date(self):
if self.birth_date:
return self.birth_date.strftime('%d-%m-%Y')
return None
class RegistrationFileGroup(models.Model):
name = models.CharField(max_length=255, default="")
description = models.TextField(blank=True, null=True)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='file_group', null=True, blank=True)
def __str__(self):
return self.name
def __str__(self):
return f'{self.group.name} - {self.id}'
def registration_file_path(instance, filename):
# Génère le chemin : registration_files/dossier_rf_{student_id}/filename
return f'registration_files/dossier_rf_{instance.student_id}/{filename}'
class RegistrationForm(models.Model):
class RegistrationFormStatus(models.IntegerChoices):
RF_IDLE = 0, _('Pas de dossier d\'inscription')
RF_INITIALIZED = 1, _('Dossier d\'inscription initialisé')
RF_SENT = 2, _('Dossier d\'inscription envoyé')
RF_UNDER_REVIEW = 3, _('Dossier d\'inscription en cours de validation')
RF_TO_BE_FOLLOWED_UP = 4, _('Dossier d\'inscription à relancer')
RF_VALIDATED = 5, _('Dossier d\'inscription validé')
RF_ARCHIVED = 6, _('Dossier d\'inscription archivé')
RF_SEPA_SENT = 7, _('Mandat SEPA envoyé')
RF_SEPA_TO_SEND = 8, _('Mandat SEPA à envoyer')
# One-to-One Relationship
student = models.OneToOneField(Student, on_delete=models.CASCADE, primary_key=True)
status = models.IntegerField(choices=RegistrationFormStatus, default=RegistrationFormStatus.RF_IDLE)
last_update = models.DateTimeField(auto_now=True)
school_year = models.CharField(max_length=9, default="", blank=True)
notes = models.CharField(max_length=200, blank=True)
registration_link_code = models.CharField(max_length=200, default="", blank=True)
registration_file = models.FileField(
upload_to=registration_file_path,
null=True,
blank=True
)
sepa_file = models.FileField(
upload_to=registration_file_path,
null=True,
blank=True
)
fusion_file = models.FileField(
upload_to=registration_file_path,
null=True,
blank=True
)
associated_rf = models.CharField(max_length=200, default="", blank=True)
# Many-to-Many Relationship
fees = models.ManyToManyField('School.Fee', blank=True, related_name='register_forms')
# Many-to-Many Relationship
discounts = models.ManyToManyField('School.Discount', blank=True, related_name='register_forms')
fileGroup = models.ForeignKey(RegistrationFileGroup,
on_delete=models.SET_NULL,
related_name='register_forms',
null=True,
blank=True)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='register_forms')
registration_payment = models.ForeignKey('School.PaymentMode', on_delete=models.SET_NULL, null=True, blank=True, related_name='registration_payment_modes_forms')
tuition_payment = models.ForeignKey('School.PaymentMode', on_delete=models.SET_NULL, null=True, blank=True, related_name='tuition_payment_modes_forms')
registration_payment_plan = models.ForeignKey('School.PaymentPlan', on_delete=models.SET_NULL, null=True, blank=True, related_name='registration_payment_plans_forms')
tuition_payment_plan = models.ForeignKey('School.PaymentPlan', on_delete=models.SET_NULL, null=True, blank=True, related_name='tuition_payment_plans_forms')
def __str__(self):
return "RF_" + self.student.last_name + "_" + self.student.first_name
def save(self, *args, **kwargs):
# Préparer le flag de création / changement de fileGroup
was_new = self.pk is None
old_fileGroup = None
if not was_new:
try:
old_instance = RegistrationForm.objects.get(pk=self.pk)
old_fileGroup = old_instance.fileGroup
except RegistrationForm.DoesNotExist:
old_fileGroup = None
# Vérifier si un fichier existant doit être remplacé
if self.pk: # Si l'objet existe déjà dans la base de données
try:
old_instance = RegistrationForm.objects.get(pk=self.pk)
if old_instance.sepa_file and old_instance.sepa_file != self.sepa_file:
# Supprimer l'ancien fichier
old_instance.sepa_file.delete(save=False)
except RegistrationForm.DoesNotExist:
pass # L'objet n'existe pas encore, rien à supprimer
# Appeler la méthode save originale
super().save(*args, **kwargs)
# Après save : si nouveau ou changement de fileGroup -> créer les templates
fileGroup_changed = (self.fileGroup is not None) and (old_fileGroup is None or (old_fileGroup and old_fileGroup.id != self.fileGroup.id))
if was_new or fileGroup_changed:
try:
import Subscriptions.util as util
created = util.create_templates_for_registration_form(self)
if created:
logger.info("Created %d templates for RegistrationForm %s", len(created), self.pk)
except Exception as e:
logger.exception("Error creating templates for RegistrationForm %s: %s", self.pk, e)
#############################################################
####################### MASTER FILES ########################
#############################################################
####### Formulaires masters (documents école, à signer ou pas) #######
def registration_school_file_master_upload_to(instance, filename):
# Stocke les fichiers masters dans un dossier dédié
# Utilise l'ID si le nom n'est pas encore disponible
est_name = None
if instance.establishment and instance.establishment.name:
est_name = instance.establishment.name
else:
# fallback si pas d'établissement (devrait être rare)
est_name = "unknown_establishment"
return f"{est_name}/Formulaires/{filename}"
class RegistrationSchoolFileMaster(models.Model):
groups = models.ManyToManyField(RegistrationFileGroup, related_name='school_file_masters', blank=True)
name = models.CharField(max_length=255, default="")
is_required = models.BooleanField(default=False)
formMasterData = models.JSONField(default=list, blank=True, null=True)
file = models.FileField(
upload_to=registration_school_file_master_upload_to,
null=True,
blank=True,
help_text="Fichier du formulaire existant (PDF, DOC, etc.)"
)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='school_file_masters', null=True, blank=True)
def __str__(self):
return f'{self.name} - {self.id}'
@property
def file_url(self):
if self.file and hasattr(self.file, 'url'):
return self.file.url
return None
def save(self, *args, **kwargs):
import os
affected_rf_ids = set()
is_new = self.pk is None
super().save(*args, **kwargs)
# 2. Gestion des groupes pour la synchro des templates
if is_new:
from Subscriptions.models import RegistrationForm
new_groups = set(self.groups.values_list('id', flat=True))
affected_rf_ids.update(
RegistrationForm.objects.filter(fileGroup__in=list(new_groups)).values_list('pk', flat=True)
)
else:
try:
old = RegistrationSchoolFileMaster.objects.get(pk=self.pk)
old_groups = set(old.groups.values_list('id', flat=True))
new_groups = set(self.groups.values_list('id', flat=True))
from Subscriptions.models import RegistrationForm
affected_rf_ids.update(
RegistrationForm.objects.filter(fileGroup__in=list(old_groups | new_groups)).values_list('pk', flat=True)
)
form_data_changed = (
old.formMasterData != self.formMasterData
and self.formMasterData
and isinstance(self.formMasterData, dict)
and self.formMasterData.get("fields")
)
name_changed = old.name != self.name
# --- Correction spécifique pour les fichiers existants (PDF, DOC, etc.) ---
# Si le nom change et qu'on a un fichier existant, on doit renommer physiquement le fichier
if old.file and not self.file and name_changed:
old_file_path = old.file.path
ext = os.path.splitext(old_file_path)[1]
clean_name = (self.name or 'document').replace(' ', '_').replace('/', '_')
new_filename = f"{clean_name}{ext}"
new_rel_path = os.path.join(os.path.dirname(old.file.name), new_filename)
new_abs_path = os.path.join(os.path.dirname(old_file_path), new_filename)
logger.info(f"Renommage fichier: {old_file_path} -> {new_abs_path}")
try:
if not os.path.exists(new_abs_path):
os.rename(old_file_path, new_abs_path)
self.file.name = new_rel_path
logger.info(f"self.file.name après renommage: {self.file.name}")
super().save(update_fields=["file"])
else:
logger.info(f"Le fichier cible existe déjà: {new_abs_path}")
except Exception as e:
logger.error(f"Erreur lors du renommage du fichier master: {e}")
elif old.file and self.file and self.file != old.file:
old.file.delete(save=False)
elif old.file and form_data_changed:
old.file.delete(save=False)
self.file = None
except RegistrationSchoolFileMaster.DoesNotExist:
pass
# Harmonisation : gestion du fichier (dynamique ou existant) sans appeler registration_school_file_master_upload_to
# Pour les formulaires dynamiques (PDF à générer)
if not self.file and self.formMasterData and isinstance(self.formMasterData, dict) and self.formMasterData.get("fields"):
try:
from Subscriptions.util import generate_form_json_pdf
pdf_filename = f"{self.name or 'formulaire'}.pdf"
abs_path = os.path.join(settings.MEDIA_ROOT, self.file.field.upload_to(self, pdf_filename))
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
if os.path.exists(abs_path):
try:
os.remove(abs_path)
logger.info(f"Suppression fichier existant: {abs_path}")
except Exception as e:
logger.error(f"Erreur suppression fichier existant avant save: {e}")
pdf_file = generate_form_json_pdf(self, self.formMasterData)
logger.info(f"Sauvegarde PDF dynamique: {pdf_filename}")
self.file.save(pdf_filename, pdf_file, save=False)
logger.info(f"self.file.name après save PDF dynamique: {self.file.name}")
super().save(update_fields=["file"])
except Exception as e:
logger.error(f"Erreur lors de la génération automatique du PDF pour le master {self.pk}: {e}")
# Pour les fichiers existants uploadés
elif self.file and hasattr(self.file, 'name') and self.name:
# On force le nom du fichier (nom du master + extension) si besoin
ext = os.path.splitext(self.file.name)[1]
clean_name = (self.name or 'document').replace(' ', '_').replace('/', '_')
final_file_name = f"{clean_name}{ext}"
# Si le nom ne correspond pas, on renomme le fichier physique et le FileField
if os.path.basename(self.file.name) != final_file_name:
current_path = self.file.path
abs_path = os.path.join(settings.MEDIA_ROOT, self.file.field.upload_to(self, final_file_name))
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
if os.path.exists(abs_path):
try:
os.remove(abs_path)
logger.info(f"Suppression fichier existant: {abs_path}")
except Exception as e:
logger.error(f"Erreur suppression fichier existant avant renommage: {e}")
try:
os.rename(current_path, abs_path)
self.file.name = os.path.relpath(abs_path, settings.MEDIA_ROOT)
logger.info(f"Déplacement fichier existant: {current_path} -> {abs_path}")
super().save(update_fields=["file"])
except Exception as e:
logger.error(f"Erreur lors du déplacement du fichier existant: {e}")
# 4. Synchronisation des templates pour tous les dossiers d'inscription concernés (création ou modification)
try:
from Subscriptions.util import create_templates_for_registration_form
from Subscriptions.models import RegistrationForm
for rf_id in affected_rf_ids:
try:
rf = RegistrationForm.objects.get(pk=rf_id)
create_templates_for_registration_form(rf)
except Exception as e:
logger.error(f"Erreur lors de la synchronisation des templates pour RF {rf_id} après modification du master {self.pk}: {e}")
except Exception as e:
logger.error(f"Erreur globale lors de la synchronisation des templates après modification du master {self.pk}: {e}")
def delete(self, *args, **kwargs):
# Supprimer le fichier physique du master si présent
if self.file and hasattr(self.file, 'path') and os.path.exists(self.file.path):
try:
self.file.delete(save=False)
except Exception as e:
logger.error(f"Erreur lors de la suppression du fichier master: {e}")
super().delete(*args, **kwargs)
####### Parent files masters (documents à fournir par les parents) #######
class RegistrationParentFileMaster(models.Model):
groups = models.ManyToManyField(RegistrationFileGroup, related_name='parent_file_masters', blank=True)
name = models.CharField(max_length=255, default="")
description = models.CharField(max_length=500, blank=True, null=True)
is_required = models.BooleanField(default=False)
############################################################
####################### CLONE FILES ########################
############################################################
def registration_school_file_upload_to(instance, filename):
return f"{instance.registration_form.establishment.name}/dossier_{instance.registration_form.student.last_name}_{instance.registration_form.student.first_name}/{filename}"
def registration_parent_file_upload_to(instance, filename):
return f"registration_files/{instance.registration_form.establishment.name}/dossier_rf_{instance.registration_form.pk}/parent/{filename}"
####### Formulaires templates (par dossier d'inscription) #######
class RegistrationSchoolFileTemplate(models.Model):
master = models.ForeignKey(RegistrationSchoolFileMaster, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
slug = models.CharField(max_length=255, default="")
name = models.CharField(max_length=255, default="")
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
formTemplateData = models.JSONField(default=list, blank=True, null=True)
def __str__(self):
return self.name
@staticmethod
def get_files_from_rf(register_form_id):
"""
Récupère tous les fichiers liés à un dossier dinscription donné.
"""
registration_files = RegistrationSchoolFileTemplate.objects.filter(registration_form=register_form_id)
filenames = []
for reg_file in registration_files:
filenames.append(reg_file.file.path)
return filenames
class StudentCompetency(models.Model):
student = models.ForeignKey('Subscriptions.Student', on_delete=models.CASCADE, related_name='competency_scores')
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', 'period')
indexes = [
models.Index(fields=['student', 'establishment_competency', 'period']),
]
def __str__(self):
return f"{self.student} - {self.establishment_competency} - Score: {self.score} - Period: {self.period}"
####### Parent files templates (par dossier d'inscription) #######
class RegistrationParentFileTemplate(models.Model):
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if self.pk: # Si l'objet existe déjà dans la base de données
try:
old_instance = RegistrationParentFileTemplate.objects.get(pk=self.pk)
if old_instance.file and (not self.file or self.file.name == ''):
if os.path.exists(old_instance.file.path):
old_instance.file.delete(save=False)
self.file = None
else:
print(f"Le fichier {old_instance.file.path} n'existe pas.")
except RegistrationParentFileTemplate.DoesNotExist:
print("Ancienne instance introuvable.")
super().save(*args, **kwargs)
@staticmethod
def get_files_from_rf(register_form_id):
"""
Récupère tous les fichiers liés à un dossier dinscription donné.
"""
registration_files = RegistrationParentFileTemplate.objects.filter(registration_form=register_form_id)
filenames = []
for reg_file in registration_files:
if reg_file.file and hasattr(reg_file.file, 'path'):
filenames.append(reg_file.file.path)
return filenames
class AbsenceMoment(models.IntegerChoices):
MORNING = 1, 'Morning'
AFTERNOON = 2, 'Afternoon'
TOTAL = 3, 'Total'
class AbsenceReason(models.IntegerChoices):
JUSTIFIED_ABSENCE = 1, 'Justified Absence'
UNJUSTIFIED_ABSENCE = 2, 'Unjustified Absence'
JUSTIFIED_LATE = 3, 'Justified Late'
UNJUSTIFIED_LATE = 4, 'Unjustified Late'
class AbsenceManagement(models.Model):
day = models.DateField(blank=True, null=True)
moment = models.IntegerField(
choices=AbsenceMoment.choices,
default=AbsenceMoment.TOTAL
)
reason = models.IntegerField(
choices=AbsenceReason.choices,
default=AbsenceReason.UNJUSTIFIED_ABSENCE
)
student = models.ForeignKey(
Student,
on_delete=models.CASCADE,
related_name='absences',
blank=True, null=True
)
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='absences', blank=True, null=True)
commentaire = models.TextField(blank=True, null=True)
def __str__(self):
return f"{self.student} - {self.day} - {self.get_moment_display()} - {self.get_reason_display()}"