# -*- coding: UTF-8 -* """ScoDoc models: formsemestre """ import datetime from functools import cached_property import flask_sqlalchemy from sqlalchemy.sql import text from app import db from app import log from app.models import APO_CODE_STR_LEN from app.models import SHORT_STR_LEN from app.models import CODE_STR_LEN import app.scodoc.sco_utils as scu from app.models.but_refcomp import ApcParcours from app.models.but_refcomp import parcours_formsemestre from app.models.etudiants import Identite from app.models.modules import Module from app.models.moduleimpls import ModuleImpl from app.models.ues import UniteEns from app.scodoc import sco_codes_parcours from app.scodoc import sco_preferences from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import MONTH_NAMES_ABBREV class FormSemestre(db.Model): """Mise en oeuvre d'un semestre de formation""" __tablename__ = "notes_formsemestre" id = db.Column(db.Integer, primary_key=True) formsemestre_id = db.synonym("id") # dept_id est aussi dans la formation, ajouté ici pour # simplifier et accélérer les selects dans notesdb dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") titre = db.Column(db.Text()) date_debut = db.Column(db.Date()) date_fin = db.Column(db.Date()) etat = db.Column( db.Boolean(), nullable=False, default=True, server_default="true" ) # False si verrouillé modalite = db.Column( db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite") ) # "FI", "FAP", "FC", ... # gestion compensation sem DUT: gestion_compensation = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) # ne publie pas le bulletin XML ou JSON: bul_hide_xml = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) # Bloque le calcul des moyennes (générale et d'UE) block_moyennes = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) # semestres decales (pour gestion jurys): gestion_semestrielle = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) # couleur fond bulletins HTML: bul_bgcolor = db.Column( db.String(SHORT_STR_LEN), default="white", server_default="white" ) # autorise resp. a modifier semestre: resp_can_edit = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) # autorise resp. a modifier slt les enseignants: resp_can_change_ens = db.Column( db.Boolean(), nullable=False, default=True, server_default="true" ) # autorise les ens a creer des evals: ens_can_edit_eval = db.Column( db.Boolean(), nullable=False, default=False, server_default="False" ) # code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...' elt_sem_apo = db.Column(db.Text()) # peut être fort long ! # code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...' elt_annee_apo = db.Column(db.Text()) # Relations: etapes = db.relationship( "FormSemestreEtape", cascade="all,delete", backref="formsemestre" ) modimpls = db.relationship( "ModuleImpl", backref="formsemestre", lazy="dynamic", ) etuds = db.relationship( "Identite", secondary="notes_formsemestre_inscription", viewonly=True, lazy="dynamic", ) responsables = db.relationship( "User", secondary="notes_formsemestre_responsables", lazy=True, backref=db.backref("formsemestres", lazy=True), ) partitions = db.relationship( "Partition", backref=db.backref("formsemestre", lazy=True), lazy="dynamic", ) # 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) # BUT parcours = db.relationship( "ApcParcours", secondary=parcours_formsemestre, lazy="subquery", backref=db.backref("formsemestres", lazy=True), ) def __init__(self, **kwargs): super(FormSemestre, self).__init__(**kwargs) if self.modalite is None: self.modalite = FormationModalite.DEFAULT_MODALITE def __repr__(self): return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>" def to_dict(self): "dict (compatible ScoDoc7)" d = dict(self.__dict__) d.pop("_sa_instance_state", None) # ScoDoc7 output_formators: (backward compat) d["formsemestre_id"] = self.id d["titre_num"] = self.titre_num() if self.date_debut: d["date_debut"] = self.date_debut.strftime("%d/%m/%Y") d["date_debut_iso"] = self.date_debut.isoformat() else: d["date_debut"] = d["date_debut_iso"] = "" if self.date_fin: d["date_fin"] = self.date_fin.strftime("%d/%m/%Y") d["date_fin_iso"] = self.date_fin.isoformat() else: d["date_fin"] = d["date_fin_iso"] = "" d["responsables"] = [u.id for u in self.responsables] d["titre_formation"] = self.titre_formation() return d def get_infos_dict(self) -> dict: """Un dict avec des informations sur le semestre pour les bulletins et autres templates (contenu compatible scodoc7 / anciens templates) """ d = self.to_dict() d["anneescolaire"] = self.annee_scolaire_str() d["annee_debut"] = str(self.date_debut.year) d["annee"] = d["annee_debut"] d["annee_fin"] = str(self.date_fin.year) if d["annee_fin"] != d["annee_debut"]: d["annee"] += "-" + str(d["annee_fin"]) d["mois_debut_ord"] = self.date_debut.month d["mois_fin_ord"] = self.date_fin.month # La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre # devrait sans doute pouvoir etre changé... if self.date_debut.month >= 8 and self.date_debut.month <= 10: d["periode"] = 1 # typiquement, début en septembre: S1, S3... else: d["periode"] = 2 # typiquement, début en février: S2, S4... d["titreannee"] = self.titre_annee() d["mois_debut"] = self.mois_debut() d["mois_fin"] = self.mois_fin() d["titremois"] = "%s %s (%s - %s)" % ( d["titre_num"], self.modalite or "", d["mois_debut"], d["mois_fin"], ) d["session_id"] = self.session_id() d["etapes"] = self.etapes_apo_vdi() d["etapes_apo_str"] = self.etapes_apo_str() return d def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery: """UE des modules de ce semestre, triées par numéro. - Formations classiques: les UEs auxquelles appartiennent les modules mis en place dans ce semestre. - Formations APC / BUT: les UEs de la formation qui ont le même numéro de semestre que ce formsemestre. """ if self.formation.get_parcours().APC_SAE: sem_ues = UniteEns.query.filter_by( formation=self.formation, semestre_idx=self.semestre_id ) else: sem_ues = db.session.query(UniteEns).filter( ModuleImpl.formsemestre_id == self.id, Module.id == ModuleImpl.module_id, UniteEns.id == Module.ue_id, ) if not with_sport: sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT) return sem_ues.order_by(UniteEns.numero) @cached_property def modimpls_sorted(self) -> list[ModuleImpl]: """Liste des modimpls du semestre (y compris bonus) - triée par type/numéro/code en APC - triée par numéros d'UE/matières/modules pour les formations standard. """ modimpls = self.modimpls.all() if self.formation.is_apc(): modimpls.sort( key=lambda m: ( m.module.module_type or 0, m.module.numero or 0, m.module.code or 0, ) ) else: modimpls.sort( key=lambda m: ( m.module.ue.numero or 0, m.module.matiere.numero or 0, m.module.numero or 0, m.module.code or "", ) ) return modimpls def modimpls_parcours(self, parcours: ApcParcours) -> list[ModuleImpl]: """Liste des modimpls du semestre (sans les bonus (?)) dans le parcours donné. - triée par type/numéro/code ?? """ cursor = db.session.execute( text( """ SELECT modimpl.id FROM notes_moduleimpl modimpl, notes_modules mod, parcours_modules pm, parcours_formsemestre pf WHERE modimpl.formsemestre_id = :formsemestre_id AND modimpl.module_id = mod.id AND pm.module_id = mod.id AND pm.parcours_id = pf.parcours_id AND pf.parcours_id = :parcours_id AND pf.formsemestre_id = :formsemestre_id """ ), {"formsemestre_id": self.id, "parcours_id": parcours.id}, ) return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor] def can_be_edited_by(self, user): """Vrai si user peut modifier ce semestre""" if not user.has_permission(Permission.ScoImplement): # pas chef if not self.resp_can_edit or user.id not in [ resp.id for resp in self.responsables ]: return False return True def est_courant(self) -> bool: """Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses) """ today = datetime.date.today() return (self.date_debut <= today) and (today <= self.date_fin) def contient_periode(self, date_debut, date_fin) -> bool: """Vrai si l'intervalle [date_debut, date_fin] est inclus dans le semestre. (les dates de début et fin sont incluses) """ return (self.date_debut <= date_debut) and (date_fin <= self.date_fin) def est_sur_une_annee(self): """Test si sem est entièrement sur la même année scolaire. (ce n'est pas obligatoire mais si ce n'est pas le cas les exports Apogée risquent de mal fonctionner) Pivot au 1er août. """ if self.date_debut > self.date_fin: log(f"Warning: semestre {self.id} begins after ending !") annee_debut = self.date_debut.year if self.date_debut.month < 8: # août # considere que debut sur l'anne scolaire precedente annee_debut -= 1 annee_fin = self.date_fin.year if self.date_fin.month < 9: # 9 (sept) pour autoriser un début en sept et une fin en aout annee_fin -= 1 return annee_debut == annee_fin def est_decale(self): """Vrai si semestre "décalé" c'est à dire semestres impairs commençant entre janvier et juin et les pairs entre juillet et decembre """ if self.semestre_id <= 0: return False # formations sans semestres return (self.semestre_id % 2 and self.date_debut.month <= 6) or ( not self.semestre_id % 2 and self.date_debut.month > 6 ) def etapes_apo_vdi(self) -> list[ApoEtapeVDI]: "Liste des vdis" # was read_formsemestre_etapes return [e.as_apovdi() for e in self.etapes if e.etape_apo] def etapes_apo_str(self) -> str: """Chaine décrivant les étapes de ce semestre ex: "V1RT, V1RT3, V1RT4" """ if not self.etapes: return "" return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape])) def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]: """Calcule la liste des regroupements cohérents d'UE impliquant ce formsemestre. Pour une année donnée: l'étudiant est inscrit dans ScoDoc soit dans le semestre impair, soit pair, soit les deux (il est rare mais pas impossible d'avoir une inscription seulement en semestre pair, par exemple suite à un transfert ou un arrêt temporaire du cursus). 1. Déterminer l'*autre* formsemestre: semestre précédent ou suivant de la même année, formation compatible (même référentiel de compétence) dans lequel l'étudiant est inscrit. 2. Construire les couples d'UE (regroupements cohérents): apparier les UE qui ont le même `ApcParcoursNiveauCompetence`. """ if not self.formation.is_apc(): return [] raise NotImplementedError() # XXX def responsables_str(self, abbrev_prenom=True) -> str: """chaîne "J. Dupond, X. Martin" ou "Jacques Dupond, Xavier Martin" """ # was "nomcomplet" if not self.responsables: return "" if abbrev_prenom: return ", ".join([u.get_prenomnom() for u in self.responsables]) else: return ", ".join([u.get_nomcomplet() for u in self.responsables]) def est_responsable(self, user): "True si l'user est l'un des responsables du semestre" return user.id in [u.id for u in self.responsables] def annee_scolaire_str(self): "2021 - 2022" return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month) def mois_debut(self) -> str: "Oct 2021" return f"{MONTH_NAMES_ABBREV[self.date_debut.month - 1]} {self.date_debut.year}" def mois_fin(self) -> str: "Jul 2022" return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_fin.year}" def session_id(self) -> str: """identifiant externe de semestre de formation Exemple: RT-DUT-FI-S1-ANNEE DEPT-TYPE-MODALITE+-S?|SPECIALITE TYPE=DUT|LP*|M* MODALITE=FC|FI|FA (si plusieurs, en inverse alpha) SPECIALITE=[A-Z]+ EON,ASSUR, ... (si pas Sn ou SnD) ANNEE=annee universitaire de debut (exemple: un S2 de 2013-2014 sera S2-2013) """ prefs = sco_preferences.SemPreferences(dept_id=self.dept_id) imputation_dept = prefs["ImputationDept"] if not imputation_dept: imputation_dept = prefs["DeptName"] imputation_dept = imputation_dept.upper() parcours_name = self.formation.get_parcours().NAME modalite = self.modalite # exception pour code Apprentissage: modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA") if self.semestre_id > 0: decale = "D" if self.est_decale() else "" semestre_id = f"S{self.semestre_id}{decale}" else: semestre_id = self.formation.code_specialite or "" annee_sco = str( scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month) ) return scu.sanitize_string( f"{imputation_dept}-{parcours_name}-{modalite}-{semestre_id}-{annee_sco}" ) def titre_annee(self) -> str: """ """ titre_annee = ( f"{self.titre_num()} {self.modalite or ''} {self.date_debut.year}" ) if self.date_fin.year != self.date_debut.year: titre_annee += "-" + str(self.date_fin.year) return titre_annee def titre_formation(self): """Titre avec formation, court, pour passerelle: "BUT R&T" (méthode de formsemestre car on pourrait ajouter le semestre, ou d'autres infos, à voir) """ return self.formation.acronyme def titre_mois(self) -> str: """Le titre et les dates du semestre, pour affichage dans des listes Ex: "BUT QLIO (PN 2022) semestre 1 FI (Sept 2022 - Jan 2023)" """ return f"""{self.titre_num()} {self.modalite or ''} ({ scu.MONTH_NAMES_ABBREV[self.date_debut.month-1]} { self.date_debut.year} - { scu.MONTH_NAMES_ABBREV[self.date_fin.month -1]} { self.date_fin.year})""" def titre_num(self) -> str: """Le titre et le semestre, ex ""DUT Informatique semestre 2"" """ if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID: return self.titre return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}" def sem_modalite(self) -> str: """Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """ if self.semestre_id > 0: descr_sem = f"S{self.semestre_id}" else: descr_sem = "" if self.modalite: descr_sem += " " + self.modalite return descr_sem def get_abs_count(self, etudid): """Les comptes d'absences de cet étudiant dans ce semestre: tuple (nb abs, nb abs justifiées) Utilise un cache. """ from app.scodoc import sco_abs return sco_abs.get_abs_count_in_interval( etudid, self.date_debut.isoformat(), self.date_fin.isoformat() ) def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]: """Liste des étudiants inscrits à ce semestre Si include_demdef, tous les étudiants, avec les démissionnaires et défaillants. Si order, tri par clé sort_key """ if include_demdef: etuds = [ins.etud for ins in self.inscriptions] else: etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] if order: etuds.sort(key=lambda e: e.sort_key) return etuds @cached_property def etudids_actifs(self) -> set: "Set des etudids inscrits non démissionnaires et non défaillants" return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT} @cached_property def etuds_inscriptions(self) -> dict: """Map { etudid : inscription } (incluant DEM et DEF)""" return {ins.etud.id: ins for ins in self.inscriptions} # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre notes_formsemestre_responsables = db.Table( "notes_formsemestre_responsables", db.Column( "formsemestre_id", db.Integer, db.ForeignKey("notes_formsemestre.id"), ), db.Column("responsable_id", db.Integer, db.ForeignKey("user.id")), ) class FormSemestreEtape(db.Model): """Étape Apogée associée au semestre""" __tablename__ = "notes_formsemestre_etapes" id = db.Column(db.Integer, primary_key=True) formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), ) # etape_apo aurait du etre not null, mais oublié etape_apo = db.Column(db.String(APO_CODE_STR_LEN), index=True) def __bool__(self): "Etape False if code empty" return self.etape_apo is not None and (len(self.etape_apo) > 0) def __repr__(self): return f"" def as_apovdi(self): return ApoEtapeVDI(self.etape_apo) class FormationModalite(db.Model): """Modalités de formation, utilisées pour la présentation (grouper les semestres, générer des codes, etc.) """ __tablename__ = "notes_form_modalites" DEFAULT_MODALITE = "FI" id = db.Column(db.Integer, primary_key=True) modalite = db.Column( db.String(SHORT_STR_LEN), unique=True, index=True, default=DEFAULT_MODALITE, server_default=DEFAULT_MODALITE, ) # code titre = db.Column(db.Text()) # texte explicatif # numero = ordre de presentation) numero = db.Column(db.Integer) @staticmethod def insert_modalites(): """Create default modalities""" numero = 0 try: for (code, titre) in ( (FormationModalite.DEFAULT_MODALITE, "Formation Initiale"), ("FAP", "Apprentissage"), ("FC", "Formation Continue"), ("DEC", "Formation Décalées"), ("LIC", "Licence"), ("CPRO", "Contrats de Professionnalisation"), ("DIST", "À distance"), ("ETR", "À l'étranger"), ("EXT", "Extérieur"), ("OTHER", "Autres formations"), ): modalite = FormationModalite.query.filter_by(modalite=code).first() if modalite is None: modalite = FormationModalite( modalite=code, titre=titre, numero=numero ) db.session.add(modalite) numero += 1 db.session.commit() except: db.session.rollback() raise class FormSemestreUECoef(db.Model): """Coef des UE capitalisees arrivant dans ce semestre""" __tablename__ = "notes_formsemestre_uecoef" __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),) id = db.Column(db.Integer, primary_key=True) formsemestre_uecoef_id = db.synonym("id") formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, ) ue_id = db.Column( db.Integer, db.ForeignKey("notes_ue.id"), index=True, ) coefficient = db.Column(db.Float, nullable=False) class FormSemestreUEComputationExpr(db.Model): """Formules utilisateurs pour calcul moyenne UE (désactivées en 9.2+).""" __tablename__ = "notes_formsemestre_ue_computation_expr" __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),) id = db.Column(db.Integer, primary_key=True) notes_formsemestre_ue_computation_expr_id = db.synonym("id") formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), ) ue_id = db.Column( db.Integer, db.ForeignKey("notes_ue.id"), ) # formule de calcul moyenne computation_expr = db.Column(db.Text()) class FormSemestreCustomMenu(db.Model): """Menu custom associe au semestre""" __tablename__ = "notes_formsemestre_custommenu" id = db.Column(db.Integer, primary_key=True) custommenu_id = db.synonym("id") formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), ) title = db.Column(db.Text()) url = db.Column(db.Text()) idx = db.Column(db.Integer, default=0, server_default="0") # rang dans le menu class FormSemestreInscription(db.Model): """Inscription à un semestre de formation""" __tablename__ = "notes_formsemestre_inscription" __table_args__ = (db.UniqueConstraint("formsemestre_id", "etudid"),) id = db.Column(db.Integer, primary_key=True) formsemestre_inscription_id = db.synonym("id") etudid = db.Column( db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True ) formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, ) etud = db.relationship( Identite, backref=db.backref("formsemestre_inscriptions", cascade="all, delete-orphan"), ) formsemestre = db.relationship( FormSemestre, backref=db.backref( "inscriptions", cascade="all, delete-orphan", order_by="FormSemestreInscription.etudid", ), ) # I inscrit, D demission en cours de semestre, DEF si "defaillant" etat = db.Column(db.String(CODE_STR_LEN), index=True) # Etape Apogée d'inscription (ajout 2020) etape = db.Column(db.String(APO_CODE_STR_LEN)) # Parcours (pour les BUT) parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True) parcour = db.relationship(ApcParcours) def __repr__(self): return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={ self.formsemestre_id} etat={self.etat} { ('parcours='+str(self.parcour)) if self.parcour else ''}>""" class NotesSemSet(db.Model): """semsets: ensemble de formsemestres pour exports Apogée""" __tablename__ = "notes_semset" id = db.Column(db.Integer, primary_key=True) semset_id = db.synonym("id") dept_id = db.Column(db.Integer, db.ForeignKey("departement.id")) title = db.Column(db.Text) annee_scolaire = db.Column(db.Integer, nullable=True, default=None) # periode: 0 (année), 1 (Simpair), 2 (Spair) sem_id = db.Column(db.Integer, nullable=True, default=None) # Association: many to many notes_semset_formsemestre = db.Table( "notes_semset_formsemestre", db.Column("formsemestre_id", db.Integer, db.ForeignKey("notes_formsemestre.id")), db.Column( "semset_id", db.Integer, db.ForeignKey("notes_semset.id", ondelete="CASCADE"), nullable=False, ), db.UniqueConstraint("formsemestre_id", "semset_id"), )