# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Emmanuel Viennet emmanuel.viennet@viennet.net # ############################################################################## """Ajout/Modification/Suppression modules (portage from DTML) """ import flask from flask import flash, url_for, render_template from flask import g, request from flask_login import current_user from app import db from app import models from app.models import APO_CODE_STR_LEN from app.models import Formation, Matiere, Module, UniteEns from app.models import FormSemestre, ModuleImpl from app.models.but_refcomp import ApcAppCritique, ApcParcours import app.scodoc.sco_utils as scu from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( ScoValueError, ScoNonEmptyFormationObject, ) from app.scodoc import codes_cursus def module_delete(module_id=None): """Formulaire suppression d'un module""" module = Module.query.get_or_404(module_id) if not module.can_be_deleted(): raise ScoNonEmptyFormationObject( "Module", msg=module.titre, dest_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=module.formation_id, semestre_idx=module.ue.semestre_idx, ), ) H = [ f"""

Suppression du module {module.titre or "sans titre"} ({module.code})

""", ] dest_url = url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=module.formation_id, semestre_idx=module.ue.semestre_idx, ) tf = TrivialFormulator( request.base_url, scu.get_request_args(), (("module_id", {"input_type": "hidden"}),), initvalues=module.to_dict(), submitlabel="Confirmer la suppression", cancelbutton="Annuler", ) if tf[0] == 0: return render_template( "sco_page_dept.j2", title="Suppression d'un module", content="\n".join(H) + tf[1], ) if tf[0] == -1: # cancel return flask.redirect(dest_url) module.delete() return flask.redirect(dest_url) def do_module_edit(vals: dict) -> None: "edit a module" # check module = Module.get_instance(vals["module_id"]) # edit modif = module.from_dict(vals) if modif: module.formation.invalidate_cached_sems() def module_create( matiere_id=None, module_type=None, semestre_id=None, formation_id=None ): """Formulaire de création d'un module Si matiere_id est spécifié, le module sera créé dans cette matière (cas normal). Sinon, donne le choix de l'UE de rattachement et utilise la première matière de cette UE (si elle n'existe pas, la crée). """ return module_edit( create=True, matiere_id=matiere_id, module_type=module_type, semestre_id=semestre_id, formation_id=formation_id, ) def module_edit( module_id=None, create=False, matiere_id=None, module_type=None, semestre_id=None, formation_id=None, ): """Formulaire édition ou création module. Si create, création nouveau module. Si matiere_id est spécifié, le module sera créé dans cette matière (cas normal). Sinon, donne le choix de l'UE de rattachement et utilise la première matière de cette UE (si elle n'existe pas, la crée). """ # --- Détermination de la formation orig_semestre_idx = semestre_id ue = None if create: if matiere_id: matiere = Matiere.get_instance(matiere_id) ue = matiere.ue formation = ue.formation orig_semestre_idx = ue.semestre_idx if semestre_id is None else semestre_id else: formation = Formation.query.get_or_404(formation_id) module = None unlocked = True else: if not module_id: raise ValueError("missing module_id !") module = models.Module.query.get_or_404(module_id) ue = module.ue module_dict = module.to_dict() formation = module.formation unlocked = not module.is_locked() parcours = codes_cursus.get_cursus_from_code(formation.type_parcours) is_apc = parcours.APC_SAE # BUT if not create: orig_semestre_idx = module.ue.semestre_idx if is_apc else module.semestre_id if orig_semestre_idx is None: orig_semestre_idx = 1 # il y a-t-il des modimpls ? in_use = (module is not None) and (len(module.modimpls.all()) > 0) matieres = Matiere.query.filter( Matiere.ue_id == UniteEns.id, UniteEns.formation_id == formation.id ).order_by(UniteEns.semestre_idx, UniteEns.numero, Matiere.numero) if in_use: # restreint aux matières du même semestre matieres = matieres.filter(UniteEns.semestre_idx == module.ue.semestre_idx) if is_apc: # ne conserve que la 1ere matière de chaque UE, # et celle à laquelle ce module est rattaché matieres = [ mat for mat in matieres if (module and module.matiere and (module.matiere.id == mat.id)) or (mat.id == mat.ue.matieres.first().id) ] mat_names = [f"S{mat.ue.semestre_idx} / {mat.ue.acronyme}" for mat in matieres] else: mat_names = ["{mat.ue.acronyme} / {mat.titre or ''}" for mat in matieres] if module: # edition ue_mat_ids = [f"{mat.ue.id}!{mat.id}" for mat in matieres] module_dict["ue_matiere_id"] = ( f"{module_dict['ue_id']}!{module_dict['matiere_id']}" ) semestres_indices = list(range(1, parcours.NB_SEM + 1)) # Toutes les UEs de la formation (tout parcours): ues = formation.ues.order_by( UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme ) if is_apc and create and semestre_id is not None: ues = ues.filter_by(semestre_idx=semestre_id) # L'UE de rattachement par défaut: 1ere du semestre ue_default = ( formation.ues.filter_by(semestre_idx=orig_semestre_idx) .order_by(UniteEns.numero, UniteEns.acronyme) .first() ) # --- Titre de la page if create: if is_apc and module_type is not None: object_name = scu.MODULE_TYPE_NAMES[module_type] else: object_name = "Module" page_title = f"Création {object_name}" if matiere_id: title = f"""Création {object_name} dans la matière {matiere.titre}, (UE {ue.acronyme}), semestre {ue.semestre_idx} """ else: title = f"""Création {object_name} dans la formation {formation.acronyme}""" else: page_title = f"Modification du module {module.code or module.titre or ''}" title = f"""Modification du module {module.code or ''} {module.titre or ''} (formation {formation.acronyme}, version {formation.version}) """ H = [ f"""

{title}

""", render_template( "scodoc/help/modules.j2", is_apc=is_apc, semestre_id=semestre_id, formsemestres=( FormSemestre.query.filter( ModuleImpl.formsemestre_id == FormSemestre.id, ModuleImpl.module_id == module_id, ) .order_by(FormSemestre.date_debut) .all() if not create else None ), create=create, ), ] if not unlocked: H.append( """
Formation verrouillée, seuls certains éléments peuvent être modifiés
""" ) if is_apc: module_types = scu.ModuleType # tous les types else: # ne propose pas SAE et Ressources, sauf si déjà de ce type... module_types = set(scu.ModuleType) - { scu.ModuleType.RESSOURCE, scu.ModuleType.SAE, } if module: module_types |= { ( scu.ModuleType(module.module_type) if module.module_type else scu.ModuleType.STANDARD ) } # Numéro du module # cherche le numero adéquat (pour placer le module en fin de liste) if module: default_num = module.numero else: modules = formation.modules.all() if modules: default_num = max(m.numero or 0 for m in modules) + 10 else: default_num = 10 descr = [ ( "code", { "size": 10, "explanation": """code du module (issu du programme, exemple M1203, R2.01, ou SAÉ 3.4. Doit être unique dans la formation)""", "allow_null": False, "validator": lambda val, _, formation_id=formation.id: Module.check_module_code_unicity( val, formation_id, module_id=module.id if module else None ), }, ), ( "titre", { "size": 64, "explanation": """nom du module. Exemple: Introduction à la démarche ergonomique""", }, ), ( "abbrev", { "size": 32, "explanation": """(optionnel) nom abrégé pour bulletins. Exemple: Intro. à l'ergonomie""", }, ), ( "module_type", { "input_type": "menu", "title": "Type", "explanation": "", "labels": [x.name.capitalize() for x in module_types], "allowed_values": [str(int(x)) for x in module_types], "enabled": unlocked, }, ), ( "heures_cours", { "title": "Heures cours :", "size": 4, "type": "float", "min_value": 0, "explanation": "nombre d'heures de cours (optionnel)", }, ), ( "heures_td", { "title": "Heures de TD :", "size": 4, "type": "float", "min_value": 0, "explanation": "nombre d'heures de Travaux Dirigés (optionnel)", }, ), ( "heures_tp", { "title": "Heures de TP :", "size": 4, "type": "float", "min_value": 0, "explanation": "nombre d'heures de Travaux Pratiques (optionnel)", }, ), ] if is_apc: if module: coefs_lst = module.ue_coefs_list() if coefs_lst: coefs_descr_txt = ", ".join( [f"{ue.acronyme}: {c}" for (ue, c) in coefs_lst] ) else: coefs_descr_txt = """non définis""" descr += [ ( "ue_coefs", { "readonly": True, "title": "Coefficients vers les UE ", "default": coefs_descr_txt, "explanation": """
(passer par la page d'édition de la formation pour modifier les coefficients)""", }, ) ] else: descr += [ ( "sep_ue_coefs", { "input_type": "separator", "title": """
(les coefficients vers les UE se fixent sur la page dédiée)
""", }, ), ] else: # Module classique avec coef scalaire: descr += [ ( "coefficient", { "size": 4, "type": "float", "explanation": "coefficient dans la formation (PPN)", "allow_null": False, "enabled": unlocked, }, ), ] descr += [ ( "formation_id", { "input_type": "hidden", "default": formation.id, }, ), ( "semestre_id", { "input_type": "hidden", "default": orig_semestre_idx, }, ), ] if module: descr += [ ("ue_id", {"input_type": "hidden"}), ("module_id", {"input_type": "hidden"}), ( "ue_matiere_id", { "input_type": "menu", "title": "Rattachement :" if is_apc else "Matière :", "explanation": ( ( "UE de rattachement, utilisée notamment pour les malus" + ( " (module utilisé, ne peut pas être changé de semestre)" if in_use else "" ) ) if is_apc else "un module appartient à une seule matière." ), "labels": mat_names, "allowed_values": ue_mat_ids, "enabled": unlocked, }, ), ] else: # Création if matiere_id: descr += [ ("ue_id", {"default": ue.id, "input_type": "hidden"}), ("matiere_id", {"default": matiere_id, "input_type": "hidden"}), ] else: # choix de l'UE de rattachement descr += [ ( "ue_id", { "input_type": "menu", "type": "int", "title": "UE de rattachement", "explanation": "utilisée notamment pour les malus", "labels": [ f"""S{u.semestre_idx if u.semestre_idx is not None else '.' } / {u.acronyme} {u.titre}""" for u in ues ], "allowed_values": [u.id for u in ues], "default": ue_default.id if ue_default is not None else "", }, ), ] if is_apc: # le semestre du module est toujours celui de son UE descr += [ ( "semestre_id", { "input_type": "hidden", "type": "int", "readonly": True, }, ) ] else: descr += [ ( "semestre_id", { "input_type": "menu", "type": "int", "title": parcours.SESSION_NAME.capitalize(), "explanation": f"""{parcours.SESSION_NAME } de début du module dans la formation standard""", "labels": [str(x) for x in semestres_indices], "allowed_values": semestres_indices, "enabled": unlocked, }, ) ] descr += [ ( "code_apogee", { "title": "Code Apogée", "size": 25, "explanation": """(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules (ce code est propre à chaque établissement, se rapprocher du référent Apogée). """, "validator": lambda val, _: len(val) < APO_CODE_STR_LEN, }, ), ( "numero", { "title": "Numéro", "size": 4, "explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage", "type": "int", "default": default_num, "allow_null": True, }, ), ] if is_apc: # Choix des parcours ref_comp = formation.referentiel_competence if ref_comp: descr += [ ( "parcours", { "input_type": "checkbox", "vertical": True, "dom_id": "tf_module_parcours", "labels": [ f"  {parcour.libelle} ({parcour.code})" for parcour in ref_comp.parcours ] + ["  Tous (tronc commun)"], "allowed_values": [ str(parcour.id) for parcour in ref_comp.parcours ] + ["-1"], "explanation": """Parcours dans lesquels est utilisé ce module (inutile hors BUT, pour les modules standards et dans les UEs de bonus).
Attention: si le module ne doit pas avoir les mêmes coefficients suivant le parcours, il faut en créer plusieurs versions, car dans ScoDoc chaque module a ses coefficients. """, }, ) ] if module: module_dict["parcours"] = [ str(parcour.id) for parcour in module.parcours ] module_dict["app_critiques"] = [ str(app_crit.id) for app_crit in module.app_critiques ] # Choix des Apprentissages Critiques if ue is not None: annee = f"BUT{(orig_semestre_idx+1)//2}" app_critiques = ApcAppCritique.app_critiques_ref_comp(ref_comp, annee) if ue.niveau_competence is not None: descr += [ ( "app_critiques", { "title": "Apprentissages Critiques", "input_type": "checkbox", "vertical": True, "dom_id": "tf_module_app_critiques", "labels": [ f"{app_crit.code}  {app_crit.libelle}" for app_crit in app_critiques ], "allowed_values": [ str(app_crit.id) for app_crit in app_critiques ], "html_data": [], "explanation": """Apprentissages Critiques liés à ce module. (si vous changez le semestre, revenez ensuite sur cette page pour associer les AC.) """, }, ) ] else: if module.ue.type == codes_cursus.UE_STANDARD: descr += [ ( "app_critiques", { "input_type": "separator", "title": f"""{scu.EMO_WARNING } L'UE {ue.acronyme} {ue.titre or ''} n'est pas associée à un niveau de compétences """, }, ) ] else: descr += [ ( "parcours", { "input_type": "separator", "title": f"""{scu.EMO_WARNING } Pas de parcours: associer un référentiel de compétence """, }, ) ] # force module semestre_idx to its UE if module: if (not module.ue) or (module.ue.semestre_idx is None): # Filet de sécurité si jamais l'UE n'a pas non plus de semestre: module_dict["semestre_id"] = 1 else: module_dict["semestre_id"] = module.ue.semestre_idx tags = module.tags if module else [] tf = TrivialFormulator( request.base_url, scu.get_request_args(), descr, html_foot_markup=( f"""
""" if not create else "" ), initvalues=module_dict if module else {}, submitlabel="Modifier ce module" if module else "Créer ce module", cancelbutton="Annuler", ) # if tf[0] == 0: return render_template( "sco_page_dept.j2", title=page_title, cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"], javascripts=[ "libjs/jQuery-tagEditor/jquery.tag-editor.min.js", "libjs/jQuery-tagEditor/jquery.caret.min.js", "js/module_tag_editor.js", "js/module_edit.js", ], content="\n".join(H) + tf[1] + ( f"""
""" if not create else "" ), ) if tf[0] == -1: return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id, semestre_idx=orig_semestre_idx, ) ) if isinstance(tf[2]["numero"], str): tf[2]["numero"] = tf[2]["numero"].strip() if not isinstance(tf[2]["numero"], int) and not tf[2]["numero"]: tf[2]["numero"] = tf[2]["numero"] or default_num # Les parcours sont affectés ensuite form_parcours = tf[2].pop("parcours", []) if create: if not matiere_id: # formulaire avec choix UE de rattachement ue = db.session.get(UniteEns, tf[2]["ue_id"]) if ue is None: raise ValueError("UE invalide") matiere = ue.matieres.first() if matiere: tf[2]["matiere_id"] = matiere.id else: matiere = Matiere.create_from_dict( { "ue_id": ue.id, "titre": ue.titre or "", "numero": 1, } ) tf[2]["matiere_id"] = matiere.id tf[2]["semestre_id"] = ue.semestre_idx module = Module.create_from_dict(tf[2], news=True) else: # EDITION MODULE # l'UE de rattachement peut changer tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!") x, y = tf[2]["ue_matiere_id"].split("!") tf[2]["ue_id"] = int(x) tf[2]["matiere_id"] = int(y) old_ue_id = module.ue.id new_ue_id = tf[2]["ue_id"] if (old_ue_id != new_ue_id) and in_use: new_ue = UniteEns.query.get_or_404(new_ue_id) if new_ue.semestre_idx != module.ue.semestre_idx: # pas changer de semestre un module utilisé ! raise ScoValueError( "Module utilisé: il ne peut pas être changé de semestre !" ) if not tf[2].get("code"): raise ScoValueError("Le code du module doit être spécifié.") if is_apc: # En APC, force le semestre égal à celui de l'UE selected_ue = db.session.get(UniteEns, tf[2]["ue_id"]) if selected_ue is None: raise ValueError("UE invalide") tf[2]["semestre_id"] = selected_ue.semestre_idx # Et vérifie que les AC sont bien dans ce ref. comp. if "app_critiques" in tf[2]: tf[2]["app_critiques"] = Module.convert_app_critiques( tf[2]["app_critiques"], formation.referentiel_competence ) # Check unicité code module dans la formation # ??? TODO # do_module_edit(tf[2]) # Modifie les parcours if form_parcours is not None and formation.referentiel_competence: if "-1" in form_parcours: # "tous" module.parcours = formation.referentiel_competence.parcours.all() else: module.parcours = [ db.session.get(ApcParcours, int(parcour_id_str)) for parcour_id_str in form_parcours ] db.session.add(module) db.session.commit() module.formation.invalidate_cached_sems() return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id, semestre_idx=tf[2]["semestre_id"] if is_apc else 1, ) ) def module_table(formation_id): """Liste des modules de la formation (affichage debug) """ formation = Formation.get_formation(formation_id) editable = current_user.has_permission(Permission.EditFormation) H = [ f"""

Listes des modules dans la formation {formation.titre} ({formation.acronyme} (debug)

") return render_template( "sco_page_dept.j2", title=f"Liste des modules de {formation.titre}", content="\n".join(H), ) def formation_add_malus_modules( formation_id: int, semestre_id: int = None, titre=None, redirect=True ): """Création d'un module de "malus" dans chaque UE d'une formation""" formation = Formation.query.get_or_404(formation_id) nb = 0 ues = formation.ues if semestre_id is not None: ues = ues.filter_by(semestre_idx=semestre_id) for ue in ues: if ue.type == codes_cursus.UE_STANDARD: if ue_add_malus_module(ue, titre=titre) is not None: nb += 1 flash(f"Modules de malus ajoutés dans {nb} UEs du S{semestre_id}") formation.invalidate_cached_sems() if redirect: return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation_id ) ) def ue_add_malus_module(ue: UniteEns, titre=None, code=None) -> int: """Add a malus module in this ue. If already exists, do nothing. Returns id of malus module. """ modules_malus = [m for m in ue.modules if m.module_type == scu.ModuleType.MALUS] if len(modules_malus) > 0: return None # déjà existant titre = titre or f"Malus {ue.acronyme}" code = code or f"MALUS{ue.numero}" # Tout module doit avoir un semestre_id (indice 1, 2, ...) if ue.semestre_idx is None: semestre_ids = sorted(list(set([m.semestre_id for m in ue.modules]))) if len(semestre_ids) > 0: semestre_id = semestre_ids[0] else: # c'est ennuyeux: dans ce cas, on pourrait demander à indiquer explicitement # le semestre ? ou affecter le malus au semestre 1 ??? raise ScoValueError( """Impossible d'ajouter un malus si l'UE n'a pas de numéro de semestre et ne comporte pas d'autres modules""" ) else: semestre_id = ue.semestre_idx # Matiere pour placer le module malus titre_matiere_malus = "Malus" matieres_malus = [mat for mat in ue.matieres if mat.titre == titre_matiere_malus] if len(matieres_malus) > 0: # matière Malus déjà existante, l'utilise matiere = matieres_malus[0] else: if ue.matieres.count() > 0: numero = max([(mat.numero or 0) for mat in ue.matieres]) + 10 else: numero = 0 matiere = Matiere(ue_id=ue.id, titre=titre_matiere_malus, numero=numero) db.session.add(matiere) module = Module( titre=titre, code=code, coefficient=0.0, ue=ue, matiere=matiere, formation=ue.formation, semestre_id=semestre_id, module_type=scu.ModuleType.MALUS, ) db.session.add(module) db.session.commit() return module.id