# -*- coding: UTF-8 -* """ScoDoc models: moduleimpls """ import pandas as pd from flask import abort, g from flask_login import current_user from flask_sqlalchemy.query import Query import app from app import db, log from app.auth.models import User from app.comp import df_cache from app.models import APO_CODE_STR_LEN, ScoDocModel from app.models.etudiants import Identite from app.models.evaluations import Evaluation from app.models.modules import Module from app.scodoc import sco_cache from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError, ScoValueError from app.scodoc.sco_permissions import Permission from app.scodoc import sco_utils as scu class ModuleImpl(ScoDocModel): """Mise en oeuvre d'un module pour une annee/semestre""" __tablename__ = "notes_moduleimpl" __table_args__ = (db.UniqueConstraint("formsemestre_id", "module_id"),) id = db.Column(db.Integer, primary_key=True) code_apogee = db.Column(db.String(APO_CODE_STR_LEN), index=True, nullable=True) "id de l'element pedagogique Apogee correspondant" edt_id: str | None = db.Column(db.Text(), index=True, nullable=True) "identifiant emplois du temps (unicité non imposée)" moduleimpl_id = db.synonym("id") module_id = db.Column(db.Integer, db.ForeignKey("notes_modules.id"), nullable=False) formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=False, ) responsable_id = db.Column( "responsable_id", db.Integer, db.ForeignKey("user.id", ondelete="SET NULL") ) responsable = db.relationship("User", back_populates="modimpls") # formule de calcul moyenne: computation_expr = db.Column(db.Text()) evaluations = db.relationship( "Evaluation", lazy="dynamic", backref="moduleimpl", order_by=(Evaluation.numero, Evaluation.date_debut), ) "évaluations, triées par numéro et dates croissants, donc la plus ancienne d'abord." enseignants = db.relationship( "User", secondary="notes_modules_enseignants", lazy="dynamic", backref="moduleimpl", ) "enseignants du module (sans le responsable)" _sco_dept_relations = ("FormSemestre",) # accès au dept_id def __repr__(self): return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>" @classmethod def create_from_dict(cls, data: dict) -> "ModuleImpl": """Create modimpl from dict. Log, inval. cache. data must include valid formsemestre_id, module_id and responsable_id Commit session. """ from app.models import FormSemestre # check required args for required_arg in ("formsemestre_id", "module_id", "responsable_id"): if required_arg not in data: raise ScoValueError(f"missing argument: {required_arg}") _ = FormSemestre.get_formsemestre(data["formsemestre_id"]) _ = Module.get_instance(data["module_id"]) if not db.session.get(User, data["responsable_id"]): abort(404, "responsable_id invalide") modimpl = super().create_from_dict(data) db.session.commit() db.session.refresh(modimpl) log(f"ModuleImpl.create: created {modimpl.id} with {data}") sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id) return modimpl def get_codes_apogee(self) -> set[str]: """Les codes Apogée (codés en base comme "VRT1,VRT2"). (si non renseigné, ceux du module) """ if self.code_apogee: return {x.strip() for x in self.code_apogee.split(",") if x} return self.module.get_codes_apogee() def get_edt_ids(self) -> list[str]: "les ids pour l'emploi du temps: à défaut, les codes Apogée" return [ scu.normalize_edt_id(x) for x in scu.split_id(self.edt_id) or scu.split_id(self.code_apogee) ] or self.module.get_edt_ids() def get_evaluations_poids(self) -> pd.DataFrame: """Les poids des évaluations vers les UEs (accès via cache redis). Toutes les évaluations sont considérées (normales, bonus, rattr., etc.) """ evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id) if evaluations_poids is None: from app.comp import moy_mod evaluations_poids, _ = moy_mod.load_evaluations_poids(self.id) df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids) return evaluations_poids @classmethod def get_modimpl(cls, moduleimpl_id: int | str, dept_id: int = None) -> "ModuleImpl": """ModuleImpl ou 404, cherche uniquement dans le département spécifié ou le courant.""" from app.models.formsemestre import FormSemestre if not isinstance(moduleimpl_id, int): try: moduleimpl_id = int(moduleimpl_id) except (TypeError, ValueError): abort(404, "moduleimpl_id invalide") if g.scodoc_dept: dept_id = dept_id if dept_id is not None else g.scodoc_dept_id query = cls.query.filter_by(id=moduleimpl_id) if dept_id is not None: query = query.join(FormSemestre).filter_by(dept_id=dept_id) return query.first_or_404() def invalidate_evaluations_poids(self): """Invalide poids cachés""" df_cache.EvaluationsPoidsCache.delete(self.id) def check_apc_conformity( self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE ) -> bool: """true si les poids des évaluations du type indiqué (normales par défaut) du module permettent de satisfaire les coefficients du PN. """ # appelé par formsemestre_status, liste notes, et moduleimpl_status if not self.module.formation.get_cursus().APC_SAE or ( self.module.module_type not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE} ): return True # Non BUT, toujours conforme from app.comp import moy_mod mod_results = res.modimpls_results.get(self.id) if mod_results is None: app.critical_error("check_apc_conformity: err 1") selected_evaluations_ids = [ eval_id for eval_id, eval_type in mod_results.evals_type.items() if eval_type == evaluation_type ] if not selected_evaluations_ids: return True # conforme si pas d'évaluations selected_evaluations_poids = self.get_evaluations_poids().loc[ selected_evaluations_ids ] return moy_mod.moduleimpl_is_conforme( self, selected_evaluations_poids, res.modimpl_coefs_df, ) def to_dict(self, convert_objects=False, with_module=True): """as a dict, with the same conversions as in ScoDoc7, including module. If convert_objects, convert all attributes to native types (suitable jor json encoding). """ d = dict(self.__dict__) d.pop("_sa_instance_state", None) if convert_objects: # on n'exporte pas le formsemestre et les inscriptions d.pop("formsemestre", None) d.pop("inscriptions", None) # ScoDoc7 output_formators: (backward compat) d["moduleimpl_id"] = self.id d["ens"] = [ {"moduleimpl_id": self.id, "ens_id": e.id} for e in self.enseignants ] if with_module: d["module"] = self.module.to_dict(convert_objects=convert_objects) else: d.pop("module", None) d["code_apogee"] = d["code_apogee"] or "" # pas de None return d def can_edit_evaluation(self, user) -> bool: """True if this user can create, delete or edit and evaluation in this modimpl (nb: n'implique pas le droit de saisir ou modifier des notes) """ if user.passwd_must_be_changed: return False # acces pour resp. moduleimpl et resp. form semestre (dir etud) if ( user.has_permission(Permission.EditAllEvals) or user.id == self.responsable_id or user.id in (r.id for r in self.formsemestre.responsables) ): return True elif self.formsemestre.ens_can_edit_eval: if user.id in (e.id for e in self.enseignants): return True return False def can_edit_notes(self, user: "User", allow_ens=True) -> bool: """True if authuser can enter or edit notes in this module. If allow_ens, grant access to all ens in this module Si des décisions de jury ont déjà été saisies dans ce semestre, seul le directeur des études peut saisir des notes (et il ne devrait pas). """ # was sco_permissions_check.can_edit_notes from app.scodoc import sco_cursus_dut if user.passwd_must_be_changed: return False if not self.formsemestre.etat: return False # semestre verrouillé is_dir_etud = user.id in (u.id for u in self.formsemestre.responsables) can_edit_all_notes = user.has_permission(Permission.EditAllNotes) if sco_cursus_dut.formsemestre_has_decisions(self.formsemestre_id): # il y a des décisions de jury dans ce semestre ! return can_edit_all_notes or is_dir_etud if ( not can_edit_all_notes and user.id != self.responsable_id and not is_dir_etud ): # enseignant (chargé de TD) ? return allow_ens and user.id in (ens.id for ens in self.enseignants) return True def can_change_responsable(self, user: User, raise_exc=False) -> bool: """Check if user can modify module resp. If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not. = Admin, et dir des etud. (si option l'y autorise) """ if not self.formsemestre.etat: if raise_exc: raise ScoLockedSemError("Modification impossible: semestre verrouille") return False if user.passwd_must_be_changed: return False # -- check access # admin ou resp. semestre avec flag resp_can_change_resp if user.has_permission(Permission.EditFormSemestre): return True if ( user.id in [resp.id for resp in self.formsemestre.responsables] ) and self.formsemestre.resp_can_change_ens: return True if raise_exc: raise AccessDenied(f"Modification impossible pour {user}") return False def can_change_ens(self, user: User | None = None, raise_exc=True) -> bool: """check if user can modify ens list (raise exception if not)" if user is None, current user. """ user = current_user if user is None else user if user.passwd_must_be_changed: return False if not self.formsemestre.etat: if raise_exc: raise ScoLockedSemError("Modification impossible: semestre verrouille") return False # -- check access # admin, resp. module ou resp. semestre if ( user.id != self.responsable_id and not user.has_permission(Permission.EditFormSemestre) and user.id not in (u.id for u in self.formsemestre.responsables) ): if raise_exc: raise AccessDenied(f"Modification impossible pour {user}") return False return True def can_change_inscriptions(self, user: User | None = None, raise_exc=True) -> bool: """check si user peut inscrire/désinsincrire des étudiants à ce module. Autorise ScoEtudInscrit ou responsables semestre. """ user = current_user if user is None else user if user.passwd_must_be_changed: return False if not self.formsemestre.etat: if raise_exc: raise ScoLockedSemError("Modification impossible: semestre verrouille") return False # -- check access # resp. module ou ou perm. EtudInscrit ou resp. semestre if ( user.id != self.responsable_id and not user.has_permission(Permission.EtudInscrit) and user.id not in (u.id for u in self.formsemestre.responsables) ): if raise_exc: raise AccessDenied(f"Modification impossible pour {user}") return False return True def est_inscrit(self, etud: Identite): """ Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre). (lent, pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df). Retourne ModuleImplInscription si inscrit au module, False sinon. """ # vérifie inscrit au moduleimpl ET au formsemestre from app.models.formsemestre import FormSemestre, FormSemestreInscription inscription = ( ModuleImplInscription.query.filter_by(etudid=etud.id, moduleimpl_id=self.id) .join(ModuleImpl) .join(FormSemestre) .join(FormSemestreInscription) .filter_by(etudid=etud.id) .first() ) return inscription or False def query_inscriptions(self) -> Query: """Query ModuleImplInscription: inscrits au moduleimpl et au formsemestre (pas de cache: pour un accès rapide, utiliser nt.modimpl_inscr_df). """ from app.models.formsemestre import FormSemestre, FormSemestreInscription return ( ModuleImplInscription.query.filter_by(moduleimpl_id=self.id) .join(ModuleImpl) .join(FormSemestre) .join(FormSemestreInscription) .filter_by(etudid=ModuleImplInscription.etudid) ) # Enseignants (chargés de TD ou TP) d'un moduleimpl notes_modules_enseignants = db.Table( "notes_modules_enseignants", db.Column( "moduleimpl_id", db.Integer, db.ForeignKey("notes_moduleimpl.id", ondelete="CASCADE"), ), db.Column("ens_id", db.Integer, db.ForeignKey("user.id", ondelete="CASCADE")), # ? db.UniqueConstraint("moduleimpl_id", "ens_id"), ) # XXX il manque probablement une relation pour gérer cela class ModuleImplInscription(ScoDocModel): """Inscription à un module (etudiants,moduleimpl)""" __tablename__ = "notes_moduleimpl_inscription" __table_args__ = (db.UniqueConstraint("moduleimpl_id", "etudid"),) id = db.Column(db.Integer, primary_key=True) moduleimpl_inscription_id = db.synonym("id") moduleimpl_id = db.Column( db.Integer, db.ForeignKey("notes_moduleimpl.id"), index=True, ) etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), index=True) etud = db.relationship( Identite, backref=db.backref("moduleimpl_inscriptions", cascade="all, delete-orphan"), ) modimpl = db.relationship( ModuleImpl, backref=db.backref("inscriptions", cascade="all, delete-orphan"), ) def to_dict(self) -> dict: "dict repr." return { "id": self.id, "etudid": self.etudid, "moduleimpl_id": self.moduleimpl_id, } @classmethod def etud_modimpls_in_ue( cls, formsemestre_id: int, etudid: int, ue_id: int ) -> Query: """moduleimpls de l'UE auxquels l'étudiant est inscrit. (Attention: inutile en APC, il faut considérer les coefficients) """ return ModuleImplInscription.query.filter( ModuleImplInscription.etudid == etudid, ModuleImplInscription.moduleimpl_id == ModuleImpl.id, ModuleImpl.formsemestre_id == formsemestre_id, ModuleImpl.module_id == Module.id, Module.ue_id == ue_id, ) @classmethod def nb_inscriptions_dans_ue( cls, formsemestre_id: int, etudid: int, ue_id: int ) -> int: """Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" return cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id).count() @classmethod def sum_coefs_modimpl_ue( cls, formsemestre_id: int, etudid: int, ue_id: int ) -> float: """Somme des coefficients des modules auxquels l'étudiant est inscrit dans l'UE du semestre indiqué. N'utilise que les coefficients, donc inadapté aux formations APC. """ return sum( [ inscr.modimpl.module.coefficient for inscr in cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id) ] )