# -*- coding: UTF-8 -* """Définition d'un étudiant et données rattachées (adresses, annotations, ...) """ import datetime from functools import cached_property from operator import attrgetter from flask import abort, has_request_context, url_for from flask import g, request import sqlalchemy from sqlalchemy import desc, text from app import db, log from app import models from app.models.departements import Departement from app.models.scolar_event import ScolarEvent from app.scodoc import notesdb as ndb from app.scodoc.sco_bac import Baccalaureat from app.scodoc.sco_exceptions import ScoGenError, ScoInvalidParamError, ScoValueError import app.scodoc.sco_utils as scu class Identite(models.ScoDocModel): """étudiant""" __tablename__ = "identite" id = db.Column(db.Integer, primary_key=True) etudid = db.synonym("id") # ForeignKey ondelete set the cascade at the database level admission_id = db.Column( db.Integer, db.ForeignKey("admissions.id", ondelete="CASCADE"), nullable=True ) admission = db.relationship( "Admission", back_populates="etud", uselist=False, cascade="all,delete", # cascade also defined at ORM level single_parent=True, ) dept_id = db.Column( db.Integer, db.ForeignKey("departement.id"), index=True, nullable=False ) departement = db.relationship("Departement", back_populates="etudiants") nom = db.Column(db.Text()) prenom = db.Column(db.Text()) nom_usuel = db.Column(db.Text()) "optionnel (si present, affiché à la place du nom)" civilite = db.Column(db.String(1), nullable=False) # données d'état-civil. Si présent (non null) remplace les données d'usage dans les documents # officiels (bulletins, PV): voir nomprenom_etat_civil() civilite_etat_civil = db.Column(db.String(1), nullable=True) prenom_etat_civil = db.Column(db.Text(), nullable=True) date_naissance = db.Column(db.Date) lieu_naissance = db.Column(db.Text()) dept_naissance = db.Column(db.Text()) nationalite = db.Column(db.Text()) statut = db.Column(db.Text()) boursier = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) "True si boursier" photo_filename = db.Column(db.Text()) # Codes INE et NIP pas unique car le meme etud peut etre ds plusieurs dept code_nip = db.Column(db.Text(), index=True) code_ine = db.Column(db.Text(), index=True) # Ancien id ScoDoc7 pour les migrations de bases anciennes # ne pas utiliser après migrate_scodoc7_dept_archives scodoc7_id = db.Column(db.Text(), nullable=True) # ----- Contraintes __table_args__ = ( # Define a unique constraint on (dept_id, code_nip) when code_nip is not NULL db.UniqueConstraint("dept_id", "code_nip", name="unique_dept_nip_except_null"), db.Index( "unique_dept_nip_except_null", "dept_id", "code_nip", unique=True, postgresql_where=(code_nip.isnot(None)), ), # Define a unique constraint on (dept_id, code_ine) when code_ine is not NULL db.UniqueConstraint("dept_id", "code_ine", name="unique_dept_ine_except_null"), db.Index( "unique_dept_ine_except_null", "dept_id", "code_ine", unique=True, postgresql_where=(code_ine.isnot(None)), ), db.CheckConstraint("civilite IN ('M', 'F', 'X')"), # non nullable db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"), # nullable ) # ----- Relations adresses = db.relationship( "Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic" ) annotations = db.relationship( "EtudAnnotation", backref="etudiant", cascade="all, delete-orphan", lazy="dynamic", ) billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic") # dispense_ues = db.relationship( "DispenseUE", back_populates="etud", cascade="all, delete", passive_deletes=True, ) itemsuivis = db.relationship( "ItemSuivi", backref="etudiant", cascade="all, delete-orphan", lazy="dynamic", ) notes = db.relationship( "NotesNotes", backref="etudiant", cascade="all, delete-orphan", lazy="dynamic", ) # Relations avec les assiduites et les justificatifs assiduites = db.relationship( "Assiduite", back_populates="etudiant", lazy="dynamic", cascade="all, delete" ) justificatifs = db.relationship( "Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete" ) # Champs "protégés" par ViewEtudData (RGPD) protected_attrs = {"boursier", "nationalite"} def __repr__(self): return ( f"" ) def clone(self, not_copying=(), new_dept_id: int = None): """Clone, not copying the given attrs Clone aussi les adresses et infos d'admission. Si new_dept_id est None, le nouvel étudiant n'a pas de département. Attention: la copie n'a pas d'id avant le prochain flush ou commit. """ if new_dept_id == self.dept_id: raise ScoValueError( "clonage étudiant: le département destination est identique à celui de départ" ) # Vérifie les contraintes d'unicité # ("dept_id", "code_nip") et ("dept_id", "code_ine") if ( self.code_nip is not None and Identite.query.filter_by( dept_id=new_dept_id, code_nip=self.code_nip ).count() > 0 ) or ( self.code_ine is not None and Identite.query.filter_by( dept_id=new_dept_id, code_ine=self.code_ine ).count() > 0 ): raise ScoValueError( """clonage étudiant: un étudiant de même code existe déjà dans le département destination""" ) d = dict(self.__dict__) d.pop("id", None) # get rid of id d.pop("_sa_instance_state", None) # get rid of SQLAlchemy special attr d.pop("departement", None) # relationship d["dept_id"] = new_dept_id for k in not_copying: d.pop(k, None) copy = self.__class__(**d) db.session.add(copy) copy.adresses = [adr.clone() for adr in self.adresses] copy.admission = self.admission.clone() log( f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}" ) return copy def html_link_fiche(self) -> str: "lien vers la fiche" return ( f"""{self.nom_prenom()}""" ) def url_fiche(self) -> str: "url de la fiche étudiant" return url_for( "scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id ) @classmethod def from_request(cls, etudid=None, code_nip=None) -> "Identite": """Étudiant à partir de l'etudid ou du code_nip, soit passés en argument soit retrouvés directement dans la requête web. Erreur 404 si inexistant. """ args = make_etud_args(etudid=etudid, code_nip=code_nip) return cls.query.filter_by(**args).first_or_404() @classmethod def get_etud(cls, etudid: int, accept_none=False) -> "Identite": """Etudiant ou 404 (ou None si accept_none), cherche uniquement dans le département courant. Si accept_none, return None si l'id est invalide ou ne correspond pas à un étudiant. """ if not isinstance(etudid, int): try: etudid = int(etudid) except (TypeError, ValueError): if accept_none: return None abort(404, f"etudid invalide {request.url if request else ''}") query = ( cls.query.filter_by(id=etudid, dept_id=g.scodoc_dept_id) if g.scodoc_dept else cls.query.filter_by(id=etudid) ) if accept_none: return query.first() return query.first_or_404() @classmethod def create_etud(cls, **args) -> "Identite": """Crée un étudiant, avec admission et adresse vides. (added to session but not flushed nor commited) """ return cls.create_from_dict(args) @classmethod def create_from_dict(cls, args) -> "Identite": """Crée un étudiant à partir d'un dict, avec admission et adresse vides. If required dept_id or dept are not specified, set it to the current dept. args: dict with args in application. Les clés adresses et admission ne SONT PAS utilisées. (added to session but not flushed nor commited) """ check_etud_duplicate_code(args, "code_nip", dest_url=None) check_etud_duplicate_code(args, "code_ine", dest_url=None) if not "dept_id" in args: if "dept" in args: departement = Departement.query.filter_by(acronym=args["dept"]).first() if departement: args["dept_id"] = departement.id if not "dept_id" in args: args["dept_id"] = g.scodoc_dept_id etud: Identite = super().create_from_dict(args) if args.get("admission_id", None) is None: etud.admission = Admission() etud.adresses.append(Adresse(typeadresse="domicile")) try: db.session.flush() except sqlalchemy.exc.IntegrityError as e: db.session.rollback() if "unique_dept_nip_except_null" in str(e): raise ScoValueError( "Code NIP déjà utilisé pour un autre étudiant" ) from e if "unique_dept_ine_except_null" in str(e): raise ScoValueError( "Code INE déjà utilisé pour un autre étudiant" ) from e raise event = ScolarEvent(etud=etud, event_type="CREATION") db.session.add(event) log(f"Identite.create {etud}") return etud def from_dict(self, args, **kwargs) -> bool: """Check arguments, then modify. Add to session but don't commit. True if modification. """ check_etud_duplicate_code(args, "code_nip", etudid=self.id) check_etud_duplicate_code(args, "code_ine", etudid=self.id) return super().from_dict(args, **kwargs) @classmethod def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict: """Returns a copy of dict with only the keys belonging to the Model and not in excluded.""" return super().filter_model_attributes( data, excluded=(excluded or set()) | {"adresses", "admission", "departement"}, ) @property def civilite_str(self) -> str: """returns civilité usuelle: 'M.' ou 'Mme' ou '' (pour le genre neutre, personnes ne souhaitant pas d'affichage). """ return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] @property def civilite_etat_civil_str(self) -> str: """returns 'M.' ou 'Mme', selon état civil officiel. La France ne reconnait pas le genre neutre dans l'état civil: si cette donnée état civil est précisée, elle est utilisée, sinon on renvoie la civilité usuelle. """ return ( {"M": "M.", "F": "Mme"}.get(self.civilite_etat_civil, "") if self.civilite_etat_civil else self.civilite_str ) def sex_nom(self, no_accents=False) -> str: "'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'. Civilité usuelle." s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}" if no_accents: return scu.suppress_accents(s) return s @property def e(self) -> str: "terminaison en français: 'ne', '', 'ou '(e)', selon la civilité usuelle" return {"M": "", "F": "e"}.get(self.civilite, "(e)") def nom_disp(self) -> str: """Nom à afficher. Note: le nom est stocké en base en majuscules.""" if self.nom_usuel: return ( (self.nom_usuel + " (" + self.nom + ")") if self.nom else self.nom_usuel ) else: return self.nom @property def nomprenom(self, reverse=False) -> str: """DEPRECATED: préférer nom_prenom() Civilité/prénom/nom pour affichages: "M. Pierre Dupont" Si reverse, "Dupont Pierre", sans civilité. Prend l'identité courante et non celle de l'état civil si elles diffèrent. """ nom = self.nom_usuel or self.nom prenom = self.prenom_str if reverse: return f"{nom} {prenom}".strip() return f"{self.civilite_str} {prenom} {nom}".strip() def nom_prenom(self) -> str: """Civilite NOM Prénom Prend l'identité courante et non celle de l'état civil si elles diffèrent. """ return f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()} {self.prenom_str}" @property def prenom_str(self): """Prénom à afficher. Par exemple: "Jean-Christophe" """ if not self.prenom: return "" frags = self.prenom.split() r = [] for frag in frags: fields = frag.split("-") r.append("-".join([x.lower().capitalize() for x in fields])) return " ".join(r) @property def etat_civil(self) -> str: "M. PRÉNOM NOM, utilisant les données état civil si présentes, usuelles sinon." return f"""{self.civilite_etat_civil_str} { self.prenom_etat_civil or self.prenom or ''} { self.nom or ''}""".strip() @property def nom_short(self): "Nom et début du prénom pour table recap: 'DUPONT Pi.'" return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}." @cached_property def sort_key(self) -> str: "clé pour tris par ordre alphabétique" # Note: scodoc7 utilisait sco_etud.etud_sort_key, à mettre à jour # si on modifie cette méthode. return scu.sanitize_string( (self.nom_usuel or self.nom or "") + ";" + (self.prenom or ""), remove_spaces=False, ).lower() def get_first_email(self, field="email") -> str: "Le mail associé à la première adresse de l'étudiant, ou None" return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None def get_formsemestres(self, recent_first=True) -> list: """Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit, triée par date_debut, le plus récent d'abord (comme "sems" de scodoc7) (si recent_first=False, le plus ancien en tête) """ return sorted( [ins.formsemestre for ins in self.formsemestre_inscriptions], key=attrgetter("date_debut"), reverse=recent_first, ) def get_modimpls_by_formsemestre( self, annee_scolaire: int ) -> dict[int, list["ModuleImpl"]]: """Pour chaque semestre de l'année indiquée dans lequel l'étudiant est inscrit à des moduleimpls, liste ceux ci. { formsemestre_id : [ modimpl, ... ] } annee_scolaire est un nombre: eg 2023 """ date_debut_annee = scu.date_debut_annee_scolaire(annee_scolaire) date_fin_annee = scu.date_fin_annee_scolaire(annee_scolaire) modimpls = ( ModuleImpl.query.join(ModuleImplInscription) .join(FormSemestre) .filter( (FormSemestre.date_debut <= date_fin_annee) & (FormSemestre.date_fin >= date_debut_annee) ) .join(Identite) .filter_by(id=self.id) ) # Tri, par semestre puis par module, suivant le type de formation: formsemestres = sorted( {m.formsemestre for m in modimpls}, key=lambda s: s.sort_key() ) modimpls_by_formsemestre = {} for formsemestre in formsemestres: modimpls_sem = [m for m in modimpls if m.formsemestre_id == formsemestre.id] if formsemestre.formation.is_apc(): modimpls_sem.sort(key=lambda m: m.module.sort_key_apc()) else: modimpls_sem.sort( key=lambda m: (m.module.ue.numero or 0, m.module.numero or 0) ) modimpls_by_formsemestre[formsemestre.id] = modimpls_sem return modimpls_by_formsemestre def get_modimpls_from_formsemestre( self, formsemestre: "FormSemestre" ) -> list["ModuleImpl"]: """ Liste des ModuleImpl auxquels l'étudiant est inscrit dans le formsemestre. """ modimpls = ModuleImpl.query.join(ModuleImplInscription).filter( ModuleImplInscription.etudid == self.id, ModuleImpl.formsemestre_id == formsemestre.id, ) return modimpls.all() @classmethod def convert_dict_fields(cls, args: dict) -> dict: """Convert fields in the given dict. No other side effect. returns: dict to store in model's db. """ # Les champs qui sont toujours stockés en majuscules: fs_uppercase = {"nom", "nom_usuel", "prenom", "prenom_etat_civil"} fs_empty_stored_as_nulls = { "nom", "prenom", "nom_usuel", "date_naissance", "lieu_naissance", "dept_naissance", "nationalite", "statut", "photo_filename", "code_nip", "code_ine", } args_dict = {} for key, value in args.items(): if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property): # compat scodoc7 (mauvaise idée de l'époque) if key in fs_empty_stored_as_nulls and value == "": value = None if key in fs_uppercase and value: value = value.upper() if key == "civilite": # requis value = input_civilite(value) elif key == "civilite_etat_civil": value = input_civilite_etat_civil(value) elif key == "boursier": value = scu.to_bool(value) elif key == "date_naissance": value = ndb.DateDMYtoISO(value) args_dict[key] = value return args_dict def to_dict_short(self) -> dict: """Les champs essentiels (aucune donnée perso protégée)""" return { "id": self.id, "civilite": self.civilite, "code_nip": self.code_nip, "code_ine": self.code_ine, "dept_id": self.dept_id, "nom": self.nom, "nom_usuel": self.nom_usuel, "prenom": self.prenom, "sort_key": self.sort_key, "civilite_etat_civil": self.civilite_etat_civil, "prenom_etat_civil": self.prenom_etat_civil, } def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict: """Représentation dictionnaire, compatible ScoDoc7 mais sans infos admission. Si restrict, cache les infos "personnelles" si pas permission ViewEtudData Si with_inscriptions, inclut les champs "inscription" """ e_dict = self.__dict__.copy() # dict(self.__dict__) e_dict.pop("_sa_instance_state", None) # ScoDoc7 output_formators: (backward compat) e_dict["etudid"] = self.id e_dict["date_naissance"] = ndb.DateISOtoDMY(e_dict.get("date_naissance", "")) e_dict["ne"] = self.e e_dict["nomprenom"] = self.nomprenom adresse: Adresse = self.adresses.first() if adresse: e_dict.update(adresse.to_dict(restrict=restrict)) if with_inscriptions: e_dict.update(self.inscription_descr()) return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty def to_dict_bul(self, include_urls=True): """Infos exportées dans les bulletins L'étudiant, et sa première adresse. """ from app.scodoc import sco_photos d = { "boursier": self.boursier or "", "civilite_etat_civil": self.civilite_etat_civil, "civilite": self.civilite, "code_ine": self.code_ine or "", "code_nip": self.code_nip or "", "date_naissance": ( self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else "" ), "dept_acronym": self.departement.acronym, "dept_id": self.dept_id, "dept_naissance": self.dept_naissance or "", "email": self.get_first_email() or "", "emailperso": self.get_first_email("emailperso"), "etat_civil": self.etat_civil, "etudid": self.id, "lieu_naissance": self.lieu_naissance or "", "nationalite": self.nationalite or "", "nom": self.nom_disp(), "nomprenom": self.nomprenom or "", "prenom_etat_civil": self.prenom_etat_civil, "prenom": self.prenom or "", } if include_urls and has_request_context(): # test request context so we can use this func in tests under the flask shell d["fiche_url"] = url_for( "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id ) d["photo_url"] = sco_photos.get_etud_photo_url(self.id) adresse = self.adresses.first() if adresse: d.update(adresse.to_dict(convert_nulls_to_str=True)) d["id"] = self.id # a été écrasé par l'id de adresse return d def to_dict_api(self, restrict=False, with_annotations=False) -> dict: """Représentation dictionnaire pour export API, avec adresses et admission. Si restrict, supprime les infos "personnelles" (boursier) """ e = dict(self.__dict__) e.pop("_sa_instance_state", None) admission = self.admission e["admission"] = admission.to_dict() if admission is not None else None e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses] e["dept_acronym"] = self.departement.acronym e.pop("departement", None) e["sort_key"] = self.sort_key if with_annotations: e["annotations"] = ( [ annot.to_dict() for annot in EtudAnnotation.query.filter_by( etudid=self.id ).order_by(desc(EtudAnnotation.date)) ] if not restrict else [] ) if restrict: # Met à None les attributs protégés: for attr in self.protected_attrs: e[attr] = None return e def inscriptions(self) -> list["FormSemestreInscription"]: "Liste des inscriptions à des formsemestres, triée, la plus récente en tête" return ( FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) .filter( FormSemestreInscription.etudid == self.id, ) .order_by(desc(FormSemestre.date_debut)) .all() ) def inscription_courante(self) -> "FormSemestreInscription | None": """La première inscription à un formsemestre _actuellement_ en cours. None s'il n'y en a pas (ou plus, ou pas encore). """ r = [ ins for ins in self.formsemestre_inscriptions if ins.formsemestre.est_courant() ] return r[0] if r else None def inscriptions_courantes(self) -> list["FormSemestreInscription"]: """Liste des inscriptions à des semestres _courants_ (il est rare qu'il y en ai plus d'une, mais c'est possible). Triées par date de début de semestre décroissante (le plus récent en premier). """ return ( FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) .filter( FormSemestreInscription.etudid == self.id, text("date_debut < now() and date_fin > now()"), ) .order_by(desc(FormSemestre.date_debut)) .all() ) def inscription_courante_date(self, date_debut, date_fin): """La première inscription à un formsemestre incluant la période [date_debut, date_fin] """ r = [ ins for ins in self.formsemestre_inscriptions if ins.formsemestre.contient_periode(date_debut, date_fin) ] return r[0] if r else None def inscription_descr(self) -> dict: """Description de l'état d'inscription avec champs compatibles templates ScoDoc7 """ inscription_courante = self.inscription_courante() if inscription_courante: titre_sem = inscription_courante.formsemestre.titre_mois() if inscription_courante.etat == scu.DEMISSION: inscr_txt = "Démission de" elif inscription_courante.etat == scu.DEF: inscr_txt = "Défaillant dans" else: inscr_txt = "Inscrit en" result = { "etat_in_cursem": inscription_courante.etat, "inscription_courante": inscription_courante, "inscription": titre_sem, "inscription_str": inscr_txt + " " + titre_sem, "situation": self.descr_situation_etud(), } else: if self.formsemestre_inscriptions: # cherche l'inscription la plus récente: fin_dernier_sem = max( [ inscr.formsemestre.date_debut for inscr in self.formsemestre_inscriptions ] ) if fin_dernier_sem > datetime.date.today(): inscription = "futur" situation = "futur élève" else: inscription = "ancien" situation = "ancien élève" else: inscription = "non inscrit" situation = inscription result = { "etat_in_cursem": "?", "inscription_courante": None, "inscription": inscription, "inscription_str": inscription, "situation": situation, } # aliases pour compat templates ScoDoc7 result["etatincursem"] = result["etat_in_cursem"] result["inscriptionstr"] = result["inscription_str"] return result def inscription_etat(self, formsemestre_id: int) -> str: """État de l'inscription de cet étudiant au semestre: False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF """ # voir si ce n'est pas trop lent: ins = models.FormSemestreInscription.query.filter_by( etudid=self.id, formsemestre_id=formsemestre_id ).first() if ins: return ins.etat return False def descr_situation_etud(self) -> str: """Chaîne décrivant la situation _actuelle_ de l'étudiant. Exemple: "inscrit en BUT R&T semestre 2 FI (Jan 2022 - Jul 2022) le 16/01/2022" ou "non inscrit" """ inscriptions_courantes = self.inscriptions_courantes() if inscriptions_courantes: inscr = inscriptions_courantes[0] if inscr.etat == scu.INSCRIT: situation = f"inscrit{self.e} en {inscr.formsemestre.titre_mois()}" # Cherche la date d'inscription dans scolar_events: events = models.ScolarEvent.query.filter_by( etudid=self.id, formsemestre_id=inscr.formsemestre.id, event_type="INSCRIPTION", ).all() if not events: log( f"*** situation inconsistante pour {self} (inscrit mais pas d'event)" ) situation += " (inscription non enregistrée)" # ??? else: date_ins = events[0].event_date situation += date_ins.strftime(" le %d/%m/%Y") elif inscr.etat == scu.DEF: situation = f"défaillant en {inscr.formsemestre.titre_mois()}" event = ( models.ScolarEvent.query.filter_by( etudid=self.id, formsemestre_id=inscr.formsemestre.id, event_type="DEFAILLANCE", ) .order_by(models.ScolarEvent.event_date) .first() ) if not event: log( f"*** situation inconsistante pour {self} (def mais pas d'event)" ) situation += "???" # ??? else: date_def = event.event_date situation += date_def.strftime(" le %d/%m/%Y") else: situation = f"démission de {inscr.formsemestre.titre_mois()}" # Cherche la date de demission dans scolar_events: event = ( models.ScolarEvent.query.filter_by( etudid=self.id, formsemestre_id=inscr.formsemestre.id, event_type="DEMISSION", ) .order_by(models.ScolarEvent.event_date) .first() ) if not event: log( f"*** situation inconsistante pour {self} (demission mais pas d'event)" ) situation += "???" # ??? else: date_dem = event.event_date situation += date_dem.strftime(" le %d/%m/%Y") else: situation = "non inscrit" + self.e return situation def etat_civil_pv(self, with_paragraph=True, line_sep="\n") -> str: """Présentation, pour PV jury Si with_paragraph (défaut): M. Pierre Dupont n° 12345678 né(e) le 7/06/1974 à Paris Sinon: M. Pierre Dupont """ if with_paragraph: return f"""{self.etat_civil}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le { self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{ line_sep}à {self.lieu_naissance or ""}""" return self.etat_civil def photo_html(self, title=None, size="small") -> str: """HTML img tag for the photo, either in small size (h90) or original size (size=="orig") """ from app.scodoc import sco_photos # sco_photo traite des dicts: return sco_photos.etud_photo_html( etud=dict( etudid=self.id, code_nip=self.code_nip, nomprenom=self.nomprenom, nom_disp=self.nom_disp(), photo_filename=self.photo_filename, ), title=title, size=size, ) def check_etud_duplicate_code( args, code_name, edit=True, etudid: int | None = None, dest_url: str | None = "" ): """Vérifie que le code n'est pas dupliqué. Raises ScoGenError si problème. Si dest_url === None, pas de lien continuer/annuler. """ etudid = etudid or args.get("etudid", None) if not args.get(code_name, None): return etuds = Identite.query.filter_by( **{code_name: str(args[code_name]), "dept_id": g.scodoc_dept_id} ).all() duplicate = False if edit: duplicate = (len(etuds) > 1) or ((len(etuds) == 1) and etuds[0].id != etudid) else: duplicate = len(etuds) > 0 if duplicate: listh = [] # liste des doubles for etud in etuds: listh.append(f"Autre étudiant: {etud.html_link_fiche()}") if etudid: submit_label = "retour à la fiche étudiant" dest_endpoint = "scolar.fiche_etud" parameters = {"etudid": etudid} else: if "tf_submitted" in args: del args["tf_submitted"] submit_label = "Continuer" dest_endpoint = "scolar.etudident_create_form" parameters = args else: submit_label = "Annuler" dest_endpoint = "notes.index_html" parameters = {} err_page = f"""

Code étudiant ({code_name}) dupliqué !

Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur.

""" err_page += ( f"""

{submit_label}

""" if dest_url is not None else "" ) log(f"*** error: code {code_name} duplique: {args[code_name]}") raise ScoGenError(err_page, safe=True) def make_etud_args( etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True ) -> dict: """forme args dict pour requete recherche etudiant On peut specifier etudid ou bien (si use_request) cherche dans la requete http: etudid, code_nip, code_ine (dans cet ordre). Résultat: dict avec soit "etudid", soit "code_nip", soit "code_ine" """ args = None if etudid: try: args = {"etudid": int(etudid)} except ValueError as exc: raise ScoInvalidParamError() from exc elif code_nip: args = {"code_nip": code_nip} elif use_request: # use form from current request (Flask global) if request.method == "POST": vals = request.form elif request.method == "GET": vals = request.args else: vals = {} try: if "etudid" in vals: args = {"etudid": int(vals["etudid"])} elif "code_nip" in vals: args = {"code_nip": str(vals["code_nip"])} elif "code_ine" in vals: args = {"code_ine": str(vals["code_ine"])} except ValueError: args = {} if not args: if abort_404: abort(404, "pas d'étudiant sélectionné") elif raise_exc: raise ValueError("make_etud_args: pas d'étudiant sélectionné !") return args def input_civilite(s: str) -> str: """Converts external representation of civilite to internal: 'M', 'F', or 'X' (and nothing else). Raises ScoValueError if conversion fails. """ if not isinstance(s, str): raise ScoValueError("valeur invalide pour la civilité (chaine attendue)") s = s.upper().strip() if s in ("M", "M.", "MR", "H"): return "M" elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"): return "F" elif s == "X" or not s: return "X" raise ScoValueError(f"valeur invalide pour la civilité: {s}") def input_civilite_etat_civil(s: str) -> str | None: """Same as input_civilite, but empty gives None (null)""" return input_civilite(s) if s and s.strip() else None PIVOT_YEAR = 70 def pivot_year(y) -> int: "converti et calcule l'année si saisie à deux chiffres" if y == "" or y is None: return None y = int(round(float(y))) if y >= 0 and y < 100: if y < PIVOT_YEAR: y = y + 2000 else: y = y + 1900 return y class Adresse(models.ScoDocModel): """Adresse d'un étudiant (le modèle permet plusieurs adresses, mais l'UI n'en gère qu'une seule) """ __tablename__ = "adresse" id = db.Column(db.Integer, primary_key=True) adresse_id = db.synonym("id") etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), nullable=False) # Relationship to Identite etud = db.relationship("Identite", back_populates="adresses") email = db.Column(db.Text()) # mail institutionnel emailperso = db.Column(db.Text) # email personnel (exterieur) domicile = db.Column(db.Text) codepostaldomicile = db.Column(db.Text) villedomicile = db.Column(db.Text) paysdomicile = db.Column(db.Text) telephone = db.Column(db.Text) telephonemobile = db.Column(db.Text) fax = db.Column(db.Text) typeadresse = db.Column( db.Text, default="domicile", server_default="domicile", nullable=False ) description = db.Column(db.Text) # Champs "protégés" par ViewEtudData (RGPD) protected_attrs = { "emailperso", "domicile", "codepostaldomicile", "villedomicile", "telephone", "telephonemobile", "fax", } def to_dict(self, convert_nulls_to_str=False, restrict=False): """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) if convert_nulls_to_str: e = {k: v or "" for k, v in e.items()} if restrict: e = {k: v for (k, v) in e.items() if k not in self.protected_attrs} return e class Admission(models.ScoDocModel): """Informations liées à l'admission d'un étudiant""" __tablename__ = "admissions" id = db.Column(db.Integer, primary_key=True) adm_id = db.synonym("id") # obsoleted by migration 497ba81343f7_identite_admission.py: # etudid = db.Column( # db.Integer, # db.ForeignKey("identite.id", ondelete="CASCADE"), # ) etud = db.relationship("Identite", back_populates="admission", uselist=False) # Anciens champs de ScoDoc7, à revoir pour être plus générique et souple # notamment dans le cadre du bac 2021 # de plus, certaines informations liées à APB ne sont plus disponibles # avec Parcoursup annee = db.Column(db.Integer) bac = db.Column(db.Text) specialite = db.Column(db.Text) annee_bac = db.Column(db.Integer) math = db.Column(db.Text) physique = db.Column(db.Text) anglais = db.Column(db.Text) francais = db.Column(db.Text) # Rang dans les voeux du candidat (inconnu avec APB et PS) rang = db.Column(db.Integer) # Qualité et décision du jury d'admission (ou de l'examinateur) qualite = db.Column(db.Float) rapporteur = db.Column(db.Text) decision = db.Column(db.Text) score = db.Column(db.Float) commentaire = db.Column(db.Text) # Lycée d'origine: nomlycee = db.Column(db.Text) villelycee = db.Column(db.Text) codepostallycee = db.Column(db.Text) codelycee = db.Column(db.Text) # 'APB', 'APC-PC', 'CEF', 'Direct', '?' (autre) type_admission = db.Column(db.Text) # était boursier dans le cycle precedent (lycee) ? boursier_prec = db.Column(db.Boolean()) # classement par le jury d'admission (1 à N), # global (pas celui d'APB si il y a des groupes) classement = db.Column(db.Integer) # code du groupe APB apb_groupe = db.Column(db.Text) # classement (1..Ngr) par le jury dans le groupe APB apb_classement_gr = db.Column(db.Integer) # Tous les champs sont "protégés" par ViewEtudData (RGPD) # sauf: not_protected_attrs = {"bac", "specialite", "anne_bac"} def get_bac(self) -> Baccalaureat: "Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères." return Baccalaureat(self.bac, specialite=self.specialite) def to_dict(self, no_nulls=False, restrict=False): """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" d = dict(self.__dict__) d.pop("_sa_instance_state", None) if no_nulls: for key, value in d.items(): if value is None: col_type = getattr( sqlalchemy.inspect(models.Admission).columns, key ).expression.type if isinstance(col_type, sqlalchemy.Text): d[key] = "" elif isinstance(col_type, sqlalchemy.Integer): d[key] = 0 elif isinstance(col_type, sqlalchemy.Boolean): d[key] = False if restrict: d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs} return d @classmethod def convert_dict_fields(cls, args: dict) -> dict: """Convert fields in the given dict. No other side effect. args: dict with args in application. returns: dict to store in model's db. """ fs_uppercase = {"bac", "specialite"} args_dict = {} for key, value in args.items(): if hasattr(cls, key): if ( value == "" ): # les chaines vides donne des NULLS (scodoc7 convention) value = None if key in fs_uppercase and value: value = value.upper() elif key == "annee" or key == "annee_bac": value = pivot_year(value) elif key == "classement" or key == "apb_classement_gr": value = ndb.int_null_is_null(value) args_dict[key] = value return args_dict class ItemSuivi(models.ScoDocModel): """Suivi scolarité / débouchés""" __tablename__ = "itemsuivi" id = db.Column(db.Integer, primary_key=True) itemsuivi_id = db.synonym("id") etudid = db.Column( db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), ) item_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) situation = db.Column(db.Text) _sco_dept_relations = ("Identite",) # accès au dept_id tags = db.relationship( "ItemSuiviTag", secondary="itemsuivi_tags_assoc", lazy=True, backref=db.backref("items", lazy=True), ) def to_dict(self, merge_tags=False): """Représentation dictionnaire. Si merge_tags, regroupe les tags sur une seule chaine, valeurs séparées par des virgules """ d = super().to_dict() # Convertit les tags en liste de strings: if merge_tags: d["tags"] = ", ".join([tag.title for tag in self.tags]) else: d["tags"] = [tag.title for tag in self.tags] # Ajoute date locale d["item_date_dmy"] = ( self.item_date.strftime(scu.DATE_FMT) if self.item_date else "" ) return d def set_tags(self, tags: list[str]): """Définit les tags de l'itemsuivi""" self.tags = [] for tag in tags: tag_obj = ItemSuiviTag.query.filter_by(title=tag).first() if tag_obj is None: tag_obj = ItemSuiviTag(title=tag) self.tags.append(tag_obj) class ItemSuiviTag(models.ScoDocModel): "Tag sur un itemsuivi" __tablename__ = "itemsuivi_tags" id = db.Column(db.Integer, primary_key=True) dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) tag_id = db.synonym("id") title = db.Column(db.Text(), nullable=False, unique=True) # Association tag <-> itemsuivi itemsuivi_tags_assoc = db.Table( "itemsuivi_tags_assoc", db.Column( "tag_id", db.Integer, db.ForeignKey("itemsuivi_tags.id", ondelete="CASCADE") ), db.Column( "itemsuivi_id", db.Integer, db.ForeignKey("itemsuivi.id", ondelete="CASCADE") ), ) class EtudAnnotation(models.ScoDocModel): """Annotation sur un étudiant""" __tablename__ = "etud_annotations" id = db.Column(db.Integer, primary_key=True) date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) etudid = db.Column(db.Integer, db.ForeignKey(Identite.id)) author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user comment = db.Column(db.Text) _sco_dept_relations = ("Identite",) # accès au dept_id def to_dict(self): """Représentation dictionnaire.""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) return e from app.models.formsemestre import FormSemestre, FormSemestreInscription from app.models.moduleimpls import ModuleImpl, ModuleImplInscription