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) d’un é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 d’un é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 d’inscription. """ 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 d’inscription 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 d’inscription 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()}"