# -*- coding: UTF-8 -* ############################################################################## # ScoDoc # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## # pylint génère trop de faux positifs avec les colonnes date: # pylint: disable=no-member,not-an-iterable """ScoDoc models: formsemestre """ import datetime from functools import cached_property from operator import attrgetter from flask_login import current_user from flask import flash, g, url_for from sqlalchemy.sql import text import app.scodoc.sco_utils as scu from app import db, log from app.auth.models import User from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN from app.models.but_refcomp import ( ApcParcours, ApcReferentielCompetences, parcours_formsemestre, ) from app.models.config import ScoDocSiteConfig from app.models.etudiants import Identite from app.models.formations import Formation from app.models.groups import GroupDescr, Partition from app.models.moduleimpls import ModuleImpl, ModuleImplInscription from app.models.modules import Module from app.models.ues import UniteEns from app.models.validations import ScolarFormSemestreValidation from app.scodoc import codes_cursus, sco_preferences from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import MONTH_NAMES_ABBREV from app.scodoc.sco_vdi import ApoEtapeVDI GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes 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(), nullable=False) date_debut = db.Column(db.Date(), nullable=False) date_fin = db.Column(db.Date(), nullable=False) 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") ) "Modalité de formation: 'FI', 'FAP', 'FC', ..." gestion_compensation = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) "gestion compensation sem DUT (inutilisé en APC)" bul_hide_xml = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) "ne publie pas le bulletin XML ou JSON" block_moyennes = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) "Bloque le calcul des moyennes (générale et d'UE)" block_moyenne_generale = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) "Si vrai, la moyenne générale indicative BUT n'est pas calculée" gestion_semestrielle = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) "Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)" bul_bgcolor = db.Column( db.String(SHORT_STR_LEN), default="white", server_default="white", nullable=False, ) "couleur fond bulletins HTML" resp_can_edit = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) "autorise resp. à modifier le formsemestre" resp_can_change_ens = db.Column( db.Boolean(), nullable=False, default=True, server_default="true" ) "autorise resp. a modifier slt les enseignants" ens_can_edit_eval = db.Column( db.Boolean(), nullable=False, default=False, server_default="False" ) "autorise les enseignants à créer des évals dans leurs modimpls" elt_sem_apo = db.Column(db.Text()) # peut être fort long ! "code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'" elt_annee_apo = db.Column(db.Text()) "code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'" # Data pour groups_auto_assignment # (ce champ est utilisé uniquement via l'API par le front js) groups_auto_assignment_data = db.Column(db.LargeBinary(), nullable=True) # Relations: etapes = db.relationship( "FormSemestreEtape", cascade="all,delete", backref="formsemestre" ) modimpls = db.relationship( "ModuleImpl", backref="formsemestre", lazy="dynamic", cascade="all, delete-orphan", ) 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", order_by="Partition.numero", ) # 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), order_by=(ApcParcours.numero, ApcParcours.code), ) 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_annee()}>" def html_link_status(self, label=None, title=None) -> str: "html link to status page" return f"""{label or self.titre_mois()} """ @classmethod def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre": """ "FormSemestre ou 404, cherche uniquement dans le département courant""" if g.scodoc_dept: return cls.query.filter_by( id=formsemestre_id, dept_id=g.scodoc_dept_id ).first_or_404() return cls.query.filter_by(id=formsemestre_id).first_or_404() def sort_key(self) -> tuple: """clé pour tris par ordre alphabétique (pour avoir le plus récent d'abord, sort avec reverse=True)""" return (self.date_debut, self.semestre_id) def to_dict(self, convert_objects=False) -> dict: """dict (compatible ScoDoc7). If convert_objects, convert all attributes to native types (suitable jor json encoding). """ 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() if convert_objects: # pour API d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()] d["departement"] = self.departement.to_dict() d["formation"] = self.formation.to_dict() d["etape_apo"] = self.etapes_apo_str() else: # Converti les étapes Apogee sous forme d'ApoEtapeVDI (compat scodoc7) d["etapes"] = [e.as_apovdi() for e in self.etapes] return d def to_dict_api(self): """ Un dict avec les informations sur le semestre destiné à l'api """ d = dict(self.__dict__) d.pop("_sa_instance_state", None) d["annee_scolaire"] = self.annee_scolaire() 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["departement"] = self.departement.to_dict() d["etape_apo"] = self.etapes_apo_str() d["formsemestre_id"] = self.id d["formation"] = self.formation.to_dict() d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()] d["responsables"] = [u.id for u in self.responsables] d["titre_court"] = self.formation.acronyme d["titre_formation"] = self.titre_formation() d["titre_num"] = self.titre_num() d["session_id"] = self.session_id() 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é... XXX PIVOT d["periode"] = self.periode() 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 flip_lock(self): """Flip etat (lock)""" self.etat = not self.etat db.session.add(self) def get_parcours_apc(self) -> list[ApcParcours]: """Liste des parcours proposés par ce semestre. Si aucun n'est coché et qu'il y a un référentiel, tous ceux du référentiel. """ r = self.parcours or ( self.formation.referentiel_competence and self.formation.referentiel_competence.parcours ) return r or [] def get_ues(self, with_sport=False) -> list[UniteEns]: """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; - et sont associées à l'un des parcours de ce formsemestre (ou à aucun, donc tronc commun). """ formation: Formation = self.formation if formation.is_apc(): # UEs de tronc commun (sans parcours indiqué) sem_ues = { ue.id: ue for ue in formation.query_ues_parcour( None, with_sport=with_sport ).filter(UniteEns.semestre_idx == self.semestre_id) } # Ajoute les UE de parcours for parcour in self.parcours: sem_ues.update( { ue.id: ue for ue in formation.query_ues_parcour( parcour, with_sport=with_sport ).filter(UniteEns.semestre_idx == self.semestre_id) } ) ues = sem_ues.values() return sorted(ues, key=attrgetter("numero", "acronyme")) 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 != codes_cursus.UE_SPORT) return sem_ues.order_by(UniteEns.numero).all() @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, # ressources (2) avant SAEs (3) 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 (est chef ou l'un des responsables)""" 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 <= 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 par défaut. """ if self.date_debut > self.date_fin: flash(f"Dates début/fin inversées pour le semestre {self.titre_annee()}") log(f"Warning: semestre {self.id} begins after ending !") annee_debut = self.date_debut.year month_debut_annee = ScoDocSiteConfig.get_month_debut_annee_scolaire() if self.date_debut.month < month_debut_annee: # début sur l'année scolaire précédente (juillet inclus par défaut) annee_debut -= 1 annee_fin = self.date_fin.year if self.date_fin.month < (month_debut_annee + 1): # 9 (sept) pour autoriser un début en sept et une fin en août annee_fin -= 1 return annee_debut == annee_fin def est_decale(self): """Vrai si semestre "décalé" c'est à dire semestres impairs commençant (par défaut) entre janvier et juin et les pairs entre juillet et décembre. """ if self.semestre_id <= 0: return False # formations sans semestres return ( # impair ( self.semestre_id % 2 and self.date_debut.month < scu.MONTH_DEBUT_ANNEE_SCOLAIRE ) or # pair ( (not self.semestre_id % 2) and self.date_debut.month >= scu.MONTH_DEBUT_ANNEE_SCOLAIRE ) ) def est_terminal(self) -> bool: "Vrai si dernier semestre de son cursus (ou formation mono-semestre)" return (self.semestre_id < 0) or ( self.semestre_id == self.formation.get_cursus().NB_SEM ) @classmethod def comp_periode( cls, date_debut: datetime, mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE, mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2, jour_pivot_annee=1, jour_pivot_periode=1, ): """Calcule la session associée à un formsemestre commençant en date_debut sous la forme (année, période) année: première année de l'année scolaire période = 1 (première période de l'année scolaire, souvent automne) ou 2 (deuxième période de l'année scolaire, souvent printemps) Les quatre derniers paramètres forment les dates pivots pour l'année (1er août par défaut) et pour la période (1er décembre par défaut). Les calculs se font à partir de la date de début indiquée. Exemples dans tests/unit/test_periode Implémentation: Cas à considérer pour le calcul de la période pa < pp -----------------|-------------------|----------------> (A-1, P:2) pa (A, P:1) pp (A, P:2) pp < pa -----------------|-------------------|----------------> (A-1, P:1) pp (A-1, P:2) pa (A, P:1) """ pivot_annee = 100 * mois_pivot_annee + jour_pivot_annee pivot_periode = 100 * mois_pivot_periode + jour_pivot_periode pivot_sem = 100 * date_debut.month + date_debut.day if pivot_sem < pivot_annee: annee = date_debut.year - 1 else: annee = date_debut.year if pivot_annee < pivot_periode: if pivot_sem < pivot_annee or pivot_sem >= pivot_periode: periode = 2 else: periode = 1 else: if pivot_sem < pivot_periode or pivot_sem >= pivot_annee: periode = 1 else: periode = 2 return annee, periode def periode(self) -> int: """La période: * 1 : première période: automne à Paris * 2 : deuxième période, printemps à Paris """ return FormSemestre.comp_periode( self.date_debut, mois_pivot_annee=ScoDocSiteConfig.get_month_debut_annee_scolaire(), mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(), ) 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: User): "True si l'user est l'un des responsables du semestre" return user.id in [u.id for u in self.responsables] def est_chef_or_diretud(self, user: User = None): "Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre" user = user or current_user return user.has_permission(Permission.ScoImplement) or self.est_responsable( user ) def can_edit_jury(self, user: User = None): """Vrai si utilisateur (par def. current) peut saisir decision de jury dans ce semestre: vérifie permission et verrouillage. """ user = user or current_user return self.etat and self.est_chef_or_diretud(user) def can_edit_pv(self, user: User = None): "Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre" user = user or current_user # Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr return self.est_chef_or_diretud(user) or user.has_permission( Permission.ScoEtudChangeAdr ) def annee_scolaire(self) -> int: """L'année de début de l'année scolaire. Par exemple, 2022 si le semestre va de septembre 2022 à février 2023.""" return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month) 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() cursus_name = self.formation.get_cursus().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}-{cursus_name}-{modalite}-{semestre_id}-{annee_sco}" ) def titre_annee(self) -> str: """Le titre avec l'année 'DUT Réseaux et Télécommunications semestre 3 FAP 2020-2021' """ 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, with_sem_idx=False): """Titre avec formation, court, pour passerelle: "BUT R&T" (méthode de formsemestre car on pourrait ajouter le semestre, ou d'autres infos, à voir) """ if with_sem_idx and self.semestre_id > 0: return f"{self.formation.acronyme} S{self.semestre_id}" 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 == codes_cursus.NO_SEMESTRE_ID: return self.titre return f"{self.titre} {self.formation.get_cursus().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_codes_apogee(self, category=None) -> set[str]: """Les codes Apogée (codés en base comme "VRT1,VRT2") category: None: tous, "etapes": étapes associées, "sem: code semestre", "annee": code annuel """ codes = set() if category is None or category == "etapes": codes |= {e.etape_apo for e in self.etapes if e} if (category is None or category == "sem") and self.elt_sem_apo: codes |= {x.strip() for x in self.elt_sem_apo.split(",") if x} if (category is None or category == "annee") and self.elt_annee_apo: codes |= {x.strip() for x in self.elt_annee_apo.split(",") if x} return codes 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} def setup_parcours_groups(self) -> None: """Vérifie et créee si besoin la partition et les groupes de parcours BUT.""" if not self.formation.is_apc(): return partition = Partition.query.filter_by( formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS ).first() if partition is None: # Création de la partition de parcours partition = Partition( formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS, numero=-1, groups_editable=False, ) db.session.add(partition) db.session.flush() # pour avoir un id flash("Partition Parcours créée.") elif partition.groups_editable: # Il ne faut jamais laisser éditer cette partition de parcours partition.groups_editable = False db.session.add(partition) for parcour in self.get_parcours_apc(): if parcour.code: group = GroupDescr.query.filter_by( partition_id=partition.id, group_name=parcour.code ).first() if not group: partition.groups.append(GroupDescr(group_name=parcour.code)) db.session.flush() # S'il reste des groupes de parcours qui ne sont plus dans le semestre # - s'ils n'ont pas d'inscrits, supprime-les. # - s'ils ont des inscrits: avertissement for group in GroupDescr.query.filter_by(partition_id=partition.id): if group.group_name not in (p.code for p in self.get_parcours_apc()): if ( len( [ inscr for inscr in self.inscriptions if (inscr.parcour is not None) and inscr.parcour.code == group.group_name ] ) == 0 ): flash(f"Suppression du groupe de parcours vide {group.group_name}") db.session.delete(group) else: flash( f"""Attention: groupe de parcours {group.group_name} non vide: réaffectez ses étudiants dans des parcours du semestre""" ) db.session.commit() def update_inscriptions_parcours_from_groups(self) -> None: """Met à jour les inscriptions dans les parcours du semestres en fonction des groupes de parcours. Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS et leur nom est le code du parcours (eg "Cyber"). """ if self.formation.referentiel_competence_id is None: return # safety net partition = Partition.query.filter_by( formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS ).first() if partition is None: # pas de partition de parcours return # Efface les inscriptions aux parcours: db.session.execute( text( """UPDATE notes_formsemestre_inscription SET parcour_id=NULL WHERE formsemestre_id=:formsemestre_id """ ), { "formsemestre_id": self.id, }, ) # Inscrit les étudiants des groupes de parcours: for group in partition.groups: query = ( ApcParcours.query.filter_by(code=group.group_name) .join(ApcReferentielCompetences) .filter_by( dept_id=g.scodoc_dept_id, id=self.formation.referentiel_competence_id, ) ) if query.count() != 1: log( f"""update_inscriptions_parcours_from_groups: { query.count()} parcours with code {group.group_name}""" ) continue parcour = query.first() db.session.execute( text( """UPDATE notes_formsemestre_inscription ins SET parcour_id=:parcour_id FROM group_membership gm WHERE formsemestre_id=:formsemestre_id AND gm.etudid = ins.etudid AND gm.group_id = :group_id """ ), { "formsemestre_id": self.id, "parcour_id": parcour.id, "group_id": group.id, }, ) db.session.commit() def etud_validations_description_html(self, etudid: int) -> str: """Description textuelle des validations de jury de cet étudiant dans ce semestre""" from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE vals_sem = ScolarFormSemestreValidation.query.filter_by( etudid=etudid, formsemestre_id=self.id, ue_id=None ).all() vals_ues = ( ScolarFormSemestreValidation.query.filter_by( etudid=etudid, formsemestre_id=self.id ) .join(UniteEns) .order_by(UniteEns.numero) .all() ) # Validations BUT: vals_rcues = ( ApcValidationRCUE.query.filter_by(etudid=etudid, formsemestre_id=self.id) .join(UniteEns, ApcValidationRCUE.ue1) .order_by(UniteEns.numero) .all() ) vals_annee = ( # issues de cette année scolaire seulement ApcValidationAnnee.query.filter_by( etudid=etudid, annee_scolaire=self.annee_scolaire(), referentiel_competence_id=self.formation.referentiel_competence_id, ).all() ) H = [] for vals in (vals_sem, vals_ues, vals_rcues, vals_annee): if vals: H.append( f"""