# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2023 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 UE """ import re import sqlalchemy as sa import flask from flask import flash, render_template, url_for from flask import g, request from flask_login import current_user from app import db, log from app.but import apc_edit_ue from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN from app.models import ( Formation, FormSemestre, FormSemestreUEComputationExpr, FormSemestreUECoef, Matiere, UniteEns, ) from app.models import ApcValidationRCUE, ScolarFormSemestreValidation, ScolarEvent from app.models import ScolarNews import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( ScoValueError, ScoLockedFormError, ScoNonEmptyFormationObject, ) from app.scodoc import html_sco_header from app.scodoc import codes_cursus from app.scodoc import sco_edit_apc from app.scodoc import sco_edit_matiere from app.scodoc import sco_edit_module from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl from app.scodoc import sco_tag_module _ueEditor = ndb.EditableTable( "notes_ue", "ue_id", ( "ue_id", "formation_id", "acronyme", "numero", "titre", "semestre_idx", "type", "ue_code", "ects", "is_external", "code_apogee", "coefficient", "coef_rcue", "color", "niveau_competence_id", ), sortkey="numero", input_formators={ "type": ndb.int_null_is_zero, "is_external": scu.to_bool, "ects": ndb.float_null_is_null, }, output_formators={ "numero": ndb.int_null_is_zero, "ects": ndb.float_null_is_null, "coefficient": ndb.float_null_is_zero, "semestre_idx": ndb.int_null_is_null, }, ) def ue_list(*args, **kw): "list UEs" cnx = ndb.GetDBConnexion() return _ueEditor.list(cnx, *args, **kw) def do_ue_create(args): "create an ue" cnx = ndb.GetDBConnexion() # check duplicates ues = ue_list({"formation_id": args["formation_id"], "acronyme": args["acronyme"]}) if ues: raise ScoValueError( f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé ! (chaque UE doit avoir un acronyme unique dans la formation)""" ) if ( (not "ue_code" in args) or (args["ue_code"] is None) or (not args["ue_code"].strip()) ): # évite les conflits de code while True: cursor = db.session.execute(sa.text("select notes_newid_ucod();")) code = cursor.fetchone()[0] if UniteEns.query.filter_by(ue_code=code).count() == 0: break args["ue_code"] = code # create ue_id = _ueEditor.create(cnx, args) log(f"do_ue_create: created {ue_id} with {args}") formation: Formation = db.session.get(Formation, args["formation_id"]) formation.invalidate_module_coefs() # news formation = db.session.get(Formation, args["formation_id"]) ScolarNews.add( typ=ScolarNews.NEWS_FORM, obj=args["formation_id"], text=f"Modification de la formation {formation.acronyme}", ) formation.invalidate_cached_sems() return ue_id def do_ue_delete(ue: UniteEns, delete_validations=False, force=False): """delete UE and attached matieres (but not modules). Si force, pas de confirmation dialog et pas de redirect """ formation: Formation = ue.formation semestre_idx = ue.semestre_idx if not ue.can_be_deleted(): raise ScoNonEmptyFormationObject( f"UE (id={ue.id}, dud)", msg=ue.titre, dest_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id, semestre_idx=semestre_idx, ), ) log(f"do_ue_delete: ue_id={ue.id}, delete_validations={delete_validations}") # Il y a-t-il des etudiants ayant validé cette UE ? # si oui, propose de supprimer les validations validations_ue = ScolarFormSemestreValidation.query.filter_by(ue_id=ue.id).all() validations_rcue = ApcValidationRCUE.query.filter( (ApcValidationRCUE.ue1_id == ue.id) | (ApcValidationRCUE.ue2_id == ue.id) ).all() if ( (len(validations_ue) > 0 or len(validations_rcue) > 0) and not delete_validations and not force ): return scu.confirm_dialog( f"""<p>Des étudiants ont une décision de jury sur l'UE {ue.acronyme} ({ue.titre})</p> <p>Si vous supprimez cette UE, ces décisions vont être supprimées !</p>""", dest_url="", target_variable="delete_validations", cancel_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id, semestre_idx=semestre_idx, ), parameters={"ue_id": ue.id, "dialog_confirmed": 1}, ) if delete_validations: log(f"deleting all validations of UE {ue.id}") for v in validations_ue: db.session.delete(v) for v in validations_rcue: db.session.delete(v) # delete old formulas formulas = FormSemestreUEComputationExpr.query.filter_by(ue_id=ue.id).all() for formula in formulas: db.session.delete(formula) # delete all matieres in this UE for mat in Matiere.query.filter_by(ue_id=ue.id): db.session.delete(mat) # delete uecoefs for uecoef in FormSemestreUECoef.query.filter_by(ue_id=ue.id): db.session.delete(uecoef) # delete events for event in ScolarEvent.query.filter_by(ue_id=ue.id): db.session.delete(event) db.session.flush() db.session.delete(ue) db.session.commit() # cas compliqué, mais rarement utilisé: acceptable de tout invalider formation.invalidate_module_coefs() # -> invalide aussi les formsemestres # news ScolarNews.add( typ=ScolarNews.NEWS_FORM, obj=formation.id, text=f"Modification de la formation {formation.acronyme}", ) # if not force: return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation.id, semestre_idx=semestre_idx, ) ) return None def ue_create(formation_id=None, default_semestre_idx=None): """Formulaire création d'une UE""" return ue_edit( create=True, formation_id=formation_id, default_semestre_idx=default_semestre_idx, ) def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=None): """Formulaire modification ou création d'une UE""" create = int(create) if not create: ue: UniteEns = UniteEns.query.get_or_404(ue_id) ue_dict = ue.to_dict() formation_id = ue.formation_id title = f"Modification de l'UE {ue.acronyme} {ue.titre}" initvalues = ue_dict submitlabel = "Modifier les valeurs" can_change_semestre_id = ( (ue.modules.count() == 0) or (ue.semestre_idx is None) ) and ue.niveau_competence is None else: ue = None title = "Création d'une UE" exp = re.compile(r"UCOD(\d+)$") matches = {exp.match(u.ue_code) for u in UniteEns.query if exp.match(u.ue_code)} max_code = ( max(int(match.group(1)) for match in matches if match) if matches else 0 ) proposed_code = f"UCOD{max_code+1}" initvalues = { "semestre_idx": default_semestre_idx, "color": ue_guess_color_default(formation_id, default_semestre_idx), "coef_rcue": 1.0, "ue_code": proposed_code, } submitlabel = "Créer cette UE" can_change_semestre_id = True formation = db.session.get(Formation, formation_id) if not formation: raise ScoValueError(f"Formation inexistante ! (id={formation_id})") cursus = formation.get_cursus() is_apc = cursus.APC_SAE semestres_indices = list(range(1, cursus.NB_SEM + 1)) H = [ html_sco_header.sco_header(page_title=title, javascripts=["js/edit_ue.js"]), "<h2>" + title, f" (formation {formation.acronyme}, version {formation.version})</h2>", """ <p class="help">Les UE sont des groupes de modules dans une formation donnée, utilisés pour la validation (on calcule des moyennes par UE et applique des seuils ("barres")). </p> <p class="help">Note: sauf exception, l'UE n'a pas de coefficient associé. Seuls les <em>modules</em> ont des coefficients. </p>""", f""" <h4>UE du semestre S{ue.semestre_idx}</h4> """ if is_apc and ue else "", ] ue_types = cursus.ALLOWED_UE_TYPES ue_types.sort() ue_types_names = [codes_cursus.UE_TYPE_NAME[k] for k in ue_types] ue_types = [str(x) for x in ue_types] form_descr = [ ("ue_id", {"input_type": "hidden"}), ("create", {"input_type": "hidden", "default": create}), ("formation_id", {"input_type": "hidden", "default": formation_id}), ("titre", {"size": 48, "explanation": "nom de l'UE"}), ("acronyme", {"size": 12, "explanation": "abbréviation", "allow_null": False}), ( "numero", { "size": 4, "explanation": "numéro (1,2,3,4) de l'UE pour l'ordre d'affichage", "type": "int", }, ), ] if can_change_semestre_id: form_descr += [ ( "semestre_idx", { "input_type": "menu", "type": "int", "allow_null": False, "title": cursus.SESSION_NAME.capitalize(), "explanation": f"{cursus.SESSION_NAME} de l'UE dans la formation", "labels": ["non spécifié"] + [str(x) for x in semestres_indices], "allowed_values": [""] + semestres_indices, }, ), ] else: form_descr += [ ("semestre_idx", {"default": ue.semestre_idx, "input_type": "hidden"}), ] form_descr += [ ( "type", { "explanation": "type d'UE", "input_type": "menu", "allowed_values": ue_types, "labels": ue_types_names, }, ), ( "ects", { "size": 4, "type": "float", "min_value": 0, "max_value": 1000, "title": "ECTS", "explanation": "nombre de crédits ECTS (indiquer 0 si UE bonus)" + ( ". (si les ECTS dépendent du parcours, voir plus bas.)" if is_apc else "" ), "allow_null": not is_apc, # ects requis en APC }, ), ] if is_apc: # coef pour la moyenne RCUE form_descr.append( ( "coef_rcue", { "size": 4, "type": "float", "min_value": 0, "title": "Coef. RCUE", "explanation": """pondération utilisée pour le calcul de la moyenne du RCUE. Laisser à 1, sauf si votre établissement a explicitement décidé de pondérations. """, "defaut": 1.0, "allow_null": False, "enabled": is_apc, }, ) ) else: # non APC, coef d'UE form_descr.append( ( "coefficient", { "size": 4, "type": "float", "min_value": 0, "title": "Coefficient", "explanation": """les coefficients d'UE ne sont utilisés que lorsque l'option <em>Utiliser les coefficients d'UE pour calculer la moyenne générale</em> est activée. Par défaut, le coefficient d'une UE est simplement la somme des coefficients des modules dans lesquels l'étudiant a des notes. Jamais utilisé en BUT. """, "enabled": not is_apc, }, ) ) form_descr += [ ( "ue_code", { "size": 12, "title": "Code UE", "max_length": SHORT_STR_LEN, "explanation": """code interne (non vide). Toutes les UE partageant le même code (et le même code de formation) sont compatibles (compensation de semestres, capitalisation d'UE). Voir liste ci-dessous.""", "allow_null": False, }, ), ( "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", "max_length": APO_CODE_STR_LEN, }, ), ( "is_external", { "input_type": "boolcheckbox", "title": "UE externe", "readonly": not create, # ne permet pas de transformer une UE existante en externe "explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement", }, ), ( "color", { "input_type": "color", "title": "Couleur", "explanation": "pour affichages", }, ), ] if create and not cursus.UE_IS_MODULE and not is_apc: form_descr.append( ( "create_matiere", { "input_type": "boolcheckbox", "default": True, "title": "Créer matière identique", "explanation": "créer immédiatement une matière dans cette UE (utile si on n'utilise pas de matières)", }, ) ) tf = TrivialFormulator( request.base_url, scu.get_request_args(), form_descr, initvalues=initvalues, submitlabel=submitlabel, cancelbutton="Revenir à la formation", ) if tf[0] == 0: ue_parcours_div = "" if ue and is_apc: ue_parcours_div = apc_edit_ue.form_ue_choix_parcours(ue) if ue and ue.modules.count() and ue.semestre_idx is not None: modules_div = f"""<div id="ue_list_modules"> <div><b>{ue.modules.count()} modules sont rattachés à cette UE</b> du semestre S{ue.semestre_idx}, elle ne peut donc pas être changée de semestre.</div> <ul>""" for m in ue.modules: modules_div += f"""<li><a class="stdlink" href="{url_for( "notes.module_edit", scodoc_dept=g.scodoc_dept, module_id=m.id) }">{m.code} {m.titre or "sans titre"}</a></li>""" modules_div += """</ul></div>""" else: modules_div = "" if ue: clone_form = f""" <form action="ue_clone" class="clone_form" method="post"> <input type="hidden" name="ue_id" value="{ue.id}"> <button type="submit">Créer une copie de cette UE</button> </form> """ else: clone_form = "" bonus_div = """<div id="bonus_description"></div>""" ue_div = """<div id="ue_list_code" class="sco_box sco_green_bg"></div>""" return ( "\n".join(H) + tf[1] + clone_form + ue_parcours_div + modules_div + bonus_div + ue_div + html_sco_header.sco_footer() ) elif tf[0] == 1: if create: if not tf[2]["ue_code"]: del tf[2]["ue_code"] if not tf[2]["numero"]: # numero regroupant par semestre ou année: tf[2]["numero"] = next_ue_numero( formation_id, int(tf[2]["semestre_idx"]) ) ue_id = do_ue_create(tf[2]) if is_apc or cursus.UE_IS_MODULE or tf[2]["create_matiere"]: # rappel: en APC, toutes les UE ont une matière, créée ici # (inutilisée mais à laquelle les modules sont rattachés) matiere_id = sco_edit_matiere.do_matiere_create( {"ue_id": ue_id, "titre": tf[2]["titre"], "numero": 1}, ) if cursus.UE_IS_MODULE: # dans ce mode, crée un (unique) module dans l'UE: _ = sco_edit_module.do_module_create( { "titre": tf[2]["titre"], "code": tf[2]["acronyme"], # tous les modules auront coef 1, et on utilisera les ECTS: "coefficient": 1.0, "ue_id": ue_id, "matiere_id": matiere_id, "formation_id": formation_id, "semestre_id": tf[2]["semestre_idx"], }, ) ue = db.session.get(UniteEns, ue_id) flash(f"UE créée (code {ue.ue_code})") else: if not tf[2]["numero"]: tf[2]["numero"] = 0 do_ue_edit(tf[2]) flash("UE modifiée") if tf[2]: dest_semestre_idx = tf[2]["semestre_idx"] elif ue: dest_semestre_idx = ue.semestre_idx elif default_semestre_idx: dest_semestre_idx = default_semestre_idx elif "semestre_idx" in request.form: dest_semestre_idx = request.form["semestre_idx"] else: dest_semestre_idx = 1 return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=dest_semestre_idx, ) ) def _add_ue_semestre_id(ues: list[dict], is_apc): """ajoute semestre_id dans les ue, en regardant semestre_idx ou à défaut, pour les formations non APC, le premier module de chacune. Les UE sans modules se voient attribuer le numero UE_SEM_DEFAULT (1000000), qui les place à la fin de la liste. """ for ue in ues: if ue["semestre_idx"] is not None: ue["semestre_id"] = ue["semestre_idx"] elif is_apc: ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT else: # était le comportement ScoDoc7 modules = sco_edit_module.module_list(args={"ue_id": ue["ue_id"]}) if modules: ue["semestre_id"] = modules[0]["semestre_id"] else: ue["semestre_id"] = codes_cursus.UE_SEM_DEFAULT def next_ue_numero(formation_id, semestre_id=None): """Numero d'une nouvelle UE dans cette formation. Si le semestre est specifie, cherche les UE ayant des modules de ce semestre """ formation = db.session.get(Formation, formation_id) ues = ue_list(args={"formation_id": formation_id}) if not ues: return 0 if semestre_id is None: return ues[-1]["numero"] + 1000 else: # Avec semestre: (prend le semestre du 1er module de l'UE) _add_ue_semestre_id(ues, formation.get_cursus().APC_SAE) ue_list_semestre = [ue for ue in ues if ue["semestre_id"] == semestre_id] if ue_list_semestre: return ue_list_semestre[-1]["numero"] + 10 else: return ues[-1]["numero"] + 1000 def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False): """Delete an UE""" ue = UniteEns.query.get_or_404(ue_id) if ue.modules.all(): raise ScoValueError( f"""Suppression de l'UE {ue.titre} impossible car des modules (ou SAÉ ou ressources) lui sont rattachés.""", dest_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id, semestre_idx=ue.semestre_idx, ), ) if not ue.can_be_deleted(): raise ScoNonEmptyFormationObject( f"UE", msg=ue.titre, dest_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation_id, semestre_idx=ue.semestre_idx, ), ) if not dialog_confirmed: return scu.confirm_dialog( f"<h2>Suppression de l'UE {ue.titre} ({ue.acronyme})</h2>", dest_url="", parameters={"ue_id": ue.id}, cancel_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation_id, semestre_idx=ue.semestre_idx, ), ) return do_ue_delete(ue, delete_validations=delete_validations) def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list """Liste des matières et modules d'une formation, avec liens pour éditer (si non verrouillée). """ from app.scodoc import sco_formsemestre_validation formation: Formation = db.session.get(Formation, formation_id) if not formation: raise ScoValueError("invalid formation_id") parcours = formation.get_cursus() is_apc = parcours.APC_SAE if semestre_idx == "all" or semestre_idx == "": semestre_idx = None else: semestre_idx = int(semestre_idx) locked = formation.has_locked_sems(semestre_idx) semestre_ids = range(1, parcours.NB_SEM + 1) # transition: on requete ici via l'ORM mais on utilise les fonctions ScoDoc7 # basées sur des dicts ues_obj = UniteEns.query.filter_by( formation_id=formation_id, is_external=False ).order_by(UniteEns.semestre_idx, UniteEns.numero) # safety check: renumérote les ue s'il en manque ou s'il y a des ex-aequo. # cela facilite le travail de la passerelle ! numeros = {ue.numero for ue in ues_obj} if (None in numeros) or len(numeros) < ues_obj.count(): scu.objects_renumber(db, ues_obj) ues_externes_obj = UniteEns.query.filter_by( formation_id=formation_id, is_external=True ) # liste ordonnée des formsemestres de cette formation: formsemestres = sorted( FormSemestre.query.filter_by(formation_id=formation_id).all(), key=lambda s: s.sort_key(), reverse=True, ) if is_apc: # Pour faciliter la transition des anciens programmes non APC for ue in ues_obj: ue.guess_semestre_idx() # vérifie qu'on a bien au moins une matière dans chaque UE if ue.matieres.count() < 1: mat = Matiere(ue_id=ue.id) db.session.add(mat) # donne des couleurs aux UEs crées avant colorie_anciennes_ues(ues_obj) db.session.commit() ues = [ue.to_dict() for ue in ues_obj] ues_externes = [ue.to_dict() for ue in ues_externes_obj] # tri par semestre et numero: _add_ue_semestre_id(ues, is_apc) _add_ue_semestre_id(ues_externes, is_apc) ues.sort(key=lambda u: (u["semestre_id"], u["numero"])) ues_externes.sort(key=lambda u: (u["semestre_id"], u["numero"])) # Codes dupliqués (pour aider l'utilisateur) seen = set() duplicated_codes = { ue["ue_code"] for ue in ues if ue["ue_code"] in seen or seen.add(ue["ue_code"]) } ues_with_duplicated_code = [ue for ue in ues if ue["ue_code"] in duplicated_codes] has_perm_change = current_user.has_permission(Permission.EditFormation) # editable = (not locked) and has_perm_change # On autorise maintenant la modification des formations qui ont # des semestres verrouillés, sauf si cela affect les notes passées # (verrouillées): # - pas de modif des modules utilisés dans des semestres verrouillés # - pas de changement des codes d'UE utilisés dans des semestres verrouillés editable = has_perm_change tag_editable = ( current_user.has_permission(Permission.EditFormationTags) or has_perm_change ) if locked: lockicon = scu.icontag("lock32_img", title="verrouillé") else: lockicon = "" arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags() delete_icon = scu.icontag( "delete_small_img", title="Supprimer (module inutilisé)", alt="supprimer" ) delete_disabled_icon = scu.icontag( "delete_small_dis_img", title="Suppression impossible (module utilisé)" ) H = [ html_sco_header.sco_header( cssstyles=html_sco_header.BOOTSTRAP_MULTISELECT_CSS + ["libjs/jQuery-tagEditor/jquery.tag-editor.css"], javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ "libjs/jinplace-1.2.1.min.js", "js/ue_list.js", "js/edit_ue.js", "libjs/jQuery-tagEditor/jquery.tag-editor.min.js", "libjs/jQuery-tagEditor/jquery.caret.min.js", "js/module_tag_editor.js", ], page_title=f"Programme {formation.acronyme} v{formation.version}", ), f"""<h2>{formation.html()} {lockicon} </h2> """, ] if locked: H.append( """<p class="help">Cette formation est verrouillée car des semestres verrouillés s'y réferent. Si vous souhaitez modifier cette formation (par exemple pour y ajouter un module), vous devez: </p> <ul class="help"> <li>soit créer une nouvelle version de cette formation pour pouvoir l'éditer librement (vous pouvez passer par la fonction "Associer à une nouvelle version du programme" (menu "Semestre") si vous avez un semestre en cours); </li> <li>soit déverrouiller le ou les semestres qui s'y réfèrent (attention, en principe ces semestres sont archivés et ne devraient pas être modifiés). </li> </ul>""" ) if msg: H.append('<p class="msg">' + msg + "</p>") if ues_with_duplicated_code: H.append( f"""<div class="ue_warning"><span>Attention: plusieurs UEs de cette formation ont le même code : <tt>{ ', '.join([ '<a class="stdlink" href="' + url_for( "notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"] ) + '">' + ue["acronyme"] + " (code " + ue["ue_code"] + ")</a>" for ue in ues_with_duplicated_code ]) }</tt>. Il faut corriger cela, sinon les capitalisations et ECTS seront erronés !</span></div>""" ) # Description de la formation H.append( render_template( "pn/form_descr.j2", formation=formation, parcours=parcours, editable=editable, ) ) # Formation APC (BUT) ? if is_apc: lock_info = ( """<span class="lock_info">verrouillé (voir liste des semestres utilisateurs en bas de page)</span> """ if locked else "" ) H.append( f"""<div class="formation_apc_infos"> <div class="ue_list_tit">Formation par compétences (BUT) - {_html_select_semestre_idx(formation_id, semestre_ids, semestre_idx)} </form> {lock_info} </div> """ ) if formation.referentiel_competence is None: descr_refcomp = "" msg_refcomp = "associer à un référentiel de compétences" else: descr_refcomp = f"""Référentiel de compétences: <a href="{url_for('notes.refcomp_show', scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}" class="stdlink"> {formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long} </a> """ msg_refcomp = "changer" H.append(f"""<ul><li>{descr_refcomp}""") if current_user.has_permission(Permission.EditFormation): if ( formation.referentiel_competence is None or formation.formsemestres.count() == 0 ): H.append( f"""<a class="stdlink" href="{url_for('notes.refcomp_assoc_formation', scodoc_dept=g.scodoc_dept, formation_id=formation_id) }">{msg_refcomp}</a>""" ) elif formation.referentiel_competence is not None: H.append("""(non modifiable car utilisé par des semestres)""") H.append("</li>") if formation.referentiel_competence is not None: H.append( """<li>Parcours, compétences et UEs : <div class="formation_parcs"> """ ) for parc in formation.referentiel_competence.parcours: H.append( f"""<div><a href="{url_for("notes.parcour_formation", scodoc_dept=g.scodoc_dept, formation_id=formation.id, parcour_id=parc.id ) }">{parc.code}</a></div>""" ) H.append("""</div></li>""") H.append( f""" <li> <a class="stdlink" href="{ url_for('notes.edit_modules_ue_coefs', scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=semestre_idx) }">{'Visualiser' if locked else 'Éditer'} les coefficients des ressources et SAÉs</a> </li> </ul> """ ) # Description des UE/matières/modules H.append( """ <div class="formation_ue_list"> <div class="ue_list_tit">Programme pédagogique:</div> <form> <input type="checkbox" class="sco_tag_checkbox">montrer les tags des modules</input> </form> """ ) if is_apc: H.append( sco_edit_apc.html_edit_formation_apc( formation, semestre_idx=semestre_idx, editable=editable, tag_editable=tag_editable, ) ) else: H.append('<div class="formation_classic_infos">') H.append( _ue_table_ues( parcours, ues, editable, tag_editable, has_perm_change, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, ) ) if editable: H.append( f"""<ul> <li><a class="stdlink" href="{ url_for('notes.ue_create', scodoc_dept=g.scodoc_dept, formation_id=formation_id) }">Ajouter une UE</a> </li> <li><a href="{ url_for('notes.formation_add_malus_modules', scodoc_dept=g.scodoc_dept, formation_id=formation_id) }" class="stdlink">Ajouter des modules de malus dans chaque UE</a> </li> </ul> """ ) H.append("</div>") H.append("</div>") # formation_ue_list if ues_externes: H.append( f""" <div class="formation_ue_list formation_ue_list_externes"> <div class="ue_list_tit">UE externes déclarées (pour information): </div> {_ue_table_ues( parcours, ues_externes, editable, tag_editable, has_perm_change, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, )} </div> """ ) H.append("<p><ul>") if has_perm_change: H.append( f""" <li><a class="stdlink" href="{ url_for('notes.formsemestre_associate_new_version', scodoc_dept=g.scodoc_dept, formation_id=formation_id ) }">Créer une nouvelle version de la formation</a> (copie non verrouillée) </li> """ ) if not len(formsemestres): H.append( f""" <li><a class="stdlink" href="{ url_for('notes.formation_delete', scodoc_dept=g.scodoc_dept, formation_id=formation_id ) }">Supprimer cette formation</a> (pas encore utilisée par des semestres) </li> """ ) H.append( f""" <li><a class="stdlink" href="{ url_for('notes.formation_table_recap', scodoc_dept=g.scodoc_dept, formation_id=formation_id) }">Table récapitulative de la formation</a> </li> <li><a class="stdlink" href="{ url_for('notes.formation_export', scodoc_dept=g.scodoc_dept, formation_id=formation_id, fmt='xml') }">Export XML de la formation</a> ou <a class="stdlink" href="{ url_for('notes.formation_export', scodoc_dept=g.scodoc_dept, formation_id=formation_id, fmt='xml', export_codes_apo=0) }">sans codes Apogée</a> (permet de l'enregistrer pour l'échanger avec un autre site) </li> <li><a class="stdlink" href="{ url_for('notes.formation_export', scodoc_dept=g.scodoc_dept, formation_id=formation_id, fmt='json') }">Export JSON de la formation</a> </li> <li><a class="stdlink" href="{ url_for('notes.module_table', scodoc_dept=g.scodoc_dept, formation_id=formation_id) }">Liste détaillée des modules de la formation</a> (debug) </li> </ul> </p>""" ) if has_perm_change: H.append( """ <h3> <a name="sems">Semestres ou sessions de cette formation</a></h3> <p><ul>""" ) for formsemestre in formsemestres: H.append(f"""<li>{formsemestre.html_link_status()}""") if not formsemestre.etat: H.append(" [verrouillé]") else: H.append( f""" <a class="stdlink" href="{url_for("notes.formsemestre_editwithmodules", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id )}">Modifier</a>""" ) H.append("</li>") H.append("</ul>") if current_user.has_permission(Permission.EditFormSemestre): H.append( f"""<ul> <li><a class="stdlink" href="{ url_for('notes.formsemestre_createwithmodules', scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_id=1) }">Mettre en place un nouveau semestre de formation {formation.acronyme}</a> </li> </ul>""" ) # <li>(debug) <a class="stdlink" href="check_form_integrity?formation_id=%(formation_id)s">Vérifier cohérence</a></li> warn, _ = sco_formsemestre_validation.check_formation_ues(formation_id) H.append(warn) H.append(html_sco_header.sco_footer()) return "".join(H) def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx): htm = """<form method="get">Semestre: <select onchange="this.form.submit()" name="semestre_idx" id="semestre_idx" > """ for i in list(semestre_ids) + ["all"]: if i == "all": label = "tous" else: label = f"S{i}" htm += f"""<option value="{i}" { 'selected' if (semestre_idx == i) or (i == "all" and semestre_idx is None) else '' }>{label}</option> """ htm += f""" </select> <input type="hidden" name="formation_id" value="{formation_id}"></input> </form>""" return htm def _ue_table_ues( parcours, ues: list[dict], editable, tag_editable, has_perm_change, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, ) -> str: """Édition de programme: liste des UEs (avec leurs matières et modules). Pour les formations classiques (non APC/BUT) """ H = [] cur_ue_semestre_id = None iue = 0 for ue in ues: if ue["ects"] is None: ue["ects_str"] = "" else: ue["ects_str"] = ", %g ECTS" % ue["ects"] if editable: klass = "span_apo_edit" else: klass = "" ue["code_apogee_str"] = ( """, Apo: <span class="%s" data-url="edit_ue_set_code_apogee" id="%s" data-placeholder="%s">""" % (klass, ue["ue_id"], scu.APO_MISSING_CODE_STR) + (ue["code_apogee"] or "") + "</span>" ) if cur_ue_semestre_id != ue["semestre_id"]: cur_ue_semestre_id = ue["semestre_id"] if ue["semestre_id"] == codes_cursus.UE_SEM_DEFAULT: lab = "Pas d'indication de semestre:" else: lab = f"""Semestre {ue["semestre_id"]}:""" H.append( f'<div class="ue_list_div"><div class="ue_list_tit_sem">{lab}</div>' ) H.append('<ul class="notes_ue_list">') H.append('<li class="notes_ue_list">') if iue != 0 and editable: H.append( '<a href="ue_move?ue_id=%s&after=0" class="aud">%s</a>' % (ue["ue_id"], arrow_up) ) else: H.append(arrow_none) if iue < len(ues) - 1 and editable: H.append( '<a href="ue_move?ue_id=%s&after=1" class="aud">%s</a>' % (ue["ue_id"], arrow_down) ) else: H.append(arrow_none) ue["acro_titre"] = str(ue["acronyme"]) if ue["titre"] != ue["acronyme"]: ue["acro_titre"] += " " + str(ue["titre"]) H.append( """%(acro_titre)s <span class="ue_code">(code %(ue_code)s%(ects_str)s, coef. %(coefficient)3.2f%(code_apogee_str)s)</span> <span class="ue_coef"></span> """ % ue ) if ue["type"] != codes_cursus.UE_STANDARD: H.append( '<span class="ue_type">%s</span>' % codes_cursus.UE_TYPE_NAME[ue["type"]] ) if ue["is_external"]: # Cas spécial: si l'UE externe a plus d'un module, c'est peut être une UE # qui a été déclarée externe par erreur (ou suite à un bug d'import/export xml) # Dans ce cas, propose de changer le type (même si verrouillée) if len(sco_moduleimpl.moduleimpls_in_external_ue(ue["ue_id"])) > 1: H.append('<span class="ue_is_external">') if has_perm_change: H.append( f"""<a class="stdlink" href="{ url_for("notes.ue_set_internal", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"]) }">transformer en UE ordinaire</a> """ ) H.append("</span>") ue_editable = editable and not ue_is_locked(ue["ue_id"]) if ue_editable: H.append( '<a class="stdlink" href="ue_edit?ue_id=%(ue_id)s">modifier</a>' % ue ) else: H.append('<span class="locked">[verrouillé]</span>') H.append( _ue_table_matieres( parcours, ue, editable, tag_editable, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, ) ) if (iue >= len(ues) - 1) or ue["semestre_id"] != ues[iue + 1]["semestre_id"]: H.append( f"""</ul><ul><li><a href="{url_for('notes.ue_create', scodoc_dept=g.scodoc_dept, formation_id=ue['formation_id'], semestre_idx=ue['semestre_id']) }">Ajouter une UE dans le semestre {ue['semestre_id'] or ''}</a></li></ul> </div> """ ) iue += 1 return "\n".join(H) def _ue_table_matieres( parcours, ue, editable, tag_editable, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, ): """Édition de programme: liste des matières (et leurs modules) d'une UE.""" H = [] if not parcours.UE_IS_MODULE: H.append('<ul class="notes_matiere_list">') matieres = sco_edit_matiere.matiere_list(args={"ue_id": ue["ue_id"]}) for mat in matieres: if not parcours.UE_IS_MODULE: H.append('<li class="notes_matiere_list">') if editable and not sco_edit_matiere.matiere_is_locked(mat["matiere_id"]): H.append( f"""<a class="stdlink" href="{ url_for("notes.matiere_edit", scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"]) }"> """ ) H.append("%(titre)s" % mat) if editable and not sco_edit_matiere.matiere_is_locked(mat["matiere_id"]): H.append("</a>") modules = sco_edit_module.module_list(args={"matiere_id": mat["matiere_id"]}) H.append( _ue_table_modules( parcours, ue, mat, modules, editable, tag_editable, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, ) ) if not parcours.UE_IS_MODULE: H.append("</li>") if not matieres: H.append("<li>Aucune matière dans cette UE ! ") if editable: H.append( """<a class="stdlink" href="ue_delete?ue_id=%(ue_id)s">supprimer l'UE</a>""" % ue ) H.append("</li>") if editable and not parcours.UE_IS_MODULE: H.append( '<li><a class="stdlink" href="matiere_create?ue_id=%(ue_id)s">créer une matière</a> </li>' % ue ) if not parcours.UE_IS_MODULE: H.append("</ul>") return "\n".join(H) def _ue_table_modules( parcours, ue, mat, modules, editable, tag_editable, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, unit_name="matière", add_suppress_link=True, # lien "supprimer cette matière" empty_list_msg="Aucun élément dans cette matière", create_element_msg="créer un module", ): """Édition de programme: liste des modules d'une matière d'une UE""" H = ['<ul class="notes_module_list">'] im = 0 for mod in modules: mod["nb_moduleimpls"] = sco_edit_module.module_count_moduleimpls( mod["module_id"] ) klass = "notes_module_list" if mod["module_type"] == ModuleType.MALUS: klass += " module_malus" H.append('<li class="%s">' % klass) H.append('<span class="notes_module_list_buts">') if im != 0 and editable: H.append( '<a href="module_move?module_id=%s&after=0" class="aud">%s</a>' % (mod["module_id"], arrow_up) ) else: H.append(arrow_none) if im < len(modules) - 1 and editable: H.append( '<a href="module_move?module_id=%s&after=1" class="aud">%s</a>' % (mod["module_id"], arrow_down) ) else: H.append(arrow_none) im += 1 if mod["nb_moduleimpls"] == 0 and editable: icon = delete_icon else: icon = delete_disabled_icon H.append( '<a class="smallbutton" href="module_delete?module_id=%s">%s</a>' % (mod["module_id"], icon) ) H.append("</span>") mod_editable = ( editable # and not sco_edit_module.module_is_locked( Mod['module_id']) ) if mod_editable: H.append( '<a class="discretelink" title="Modifier le module numéro %(numero)s, utilisé par %(nb_moduleimpls)d sessions" href="module_edit?module_id=%(module_id)s">' % mod ) H.append( '<span class="formation_module_tit">%s</span>' % scu.join_words(mod["code"], mod["titre"]) ) if mod_editable: H.append("</a>") heurescoef = ( "%(heures_cours)s/%(heures_td)s/%(heures_tp)s, coef. %(coefficient)s" % mod ) if mod_editable: klass = "span_apo_edit" else: klass = "" heurescoef += ( ', Apo: <span class="%s" data-url="edit_module_set_code_apogee" id="%s" data-placeholder="%s">' % (klass, mod["module_id"], scu.APO_MISSING_CODE_STR) + (mod["code_apogee"] or "") + "</span>" ) if tag_editable: tag_cls = "module_tag_editor" else: tag_cls = "module_tag_editor_ro" tag_mk = """<span class="sco_tag_edit"><form><textarea data-module_id="{}" class="{}">{}</textarea></form></span>""" tag_edit = tag_mk.format( mod["module_id"], tag_cls, ",".join(sco_tag_module.module_tag_list(mod["module_id"])), ) if ue["semestre_idx"] is not None and mod["semestre_id"] != ue["semestre_idx"]: warning_semestre = ' <span class="red">incohérent ?</span>' else: warning_semestre = "" H.append( " %s %s%s" % (parcours.SESSION_NAME, mod["semestre_id"], warning_semestre) + " (%s)" % heurescoef + tag_edit ) H.append("</li>") if not modules: H.append(f"<li>{empty_list_msg} ! ") if editable and add_suppress_link: H.append( f"""<a class="stdlink" href="{ url_for("notes.matiere_delete", scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])}" >la supprimer</a> """ ) H.append("</li>") if editable: # and ((not parcours.UE_IS_MODULE) or len(Modlist) == 0): H.append( f"""<li> <a class="stdlink" href="{ url_for("notes.module_create", scodoc_dept=g.scodoc_dept, matiere_id=mat["matiere_id"])}" >{create_element_msg}</a></li> """ ) H.append("</ul>") return "\n".join(H) def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None): """HTML list of UE sharing this code Either ue_code or ue_id may be specified. hide_ue_id spécifie un id à retirer de la liste. """ if ue_id is not None: ue = UniteEns.query.get_or_404(ue_id) if not ue_code: ue_code = ue.ue_code formation_code = ue.formation.formation_code # UE du même code, code formation et departement: q_ues = ( UniteEns.query.filter_by(ue_code=ue_code) .join(UniteEns.formation) .filter_by(dept_id=g.scodoc_dept_id, formation_code=formation_code) ) else: # Toutes les UE du departement avec ce code: q_ues = ( UniteEns.query.filter_by(ue_code=ue_code) .join(UniteEns.formation) .filter_by(dept_id=g.scodoc_dept_id) ) if hide_ue_id is not None: # enlève l'ue de depart q_ues = q_ues.filter(UniteEns.id != hide_ue_id) ues = q_ues.all() msg = " dans les formations du département " if not ues: if ue_id is not None: return f"""<span class="ue_share">Seule UE avec code { ue_code if ue_code is not None else '-'}{msg}</span>""" else: return f"""<span class="ue_share">Aucune UE avec code { ue_code if ue_code is not None else '-'}{msg}</span>""" H = [] if ue_id: H.append( f"""<span class="ue_share">Pour information, autres UEs avec le code { ue_code if ue_code is not None else '-'}{msg}:</span>""" ) else: H.append( f"""<span class="ue_share">UE avec le code { ue_code if ue_code is not None else '-'}{msg}:</span>""" ) H.append("<ul>") for ue in ues: H.append( f"""<li>{ue.acronyme} ({ue.titre}) dans <a class="stdlink" href="{ url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}" >{ue.formation.acronyme} ({ue.formation.titre})</a>, version {ue.formation.version} </li> """ ) H.append("</ul>") return "\n".join(H) def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False): "edit an UE" # check ue_id = args["ue_id"] ue = ue_list({"ue_id": ue_id})[0] if (not bypass_lock) and ue_is_locked(ue["ue_id"]): raise ScoLockedFormError() # check: acronyme unique dans cette formation if "acronyme" in args: new_acro = args["acronyme"] ues = ue_list({"formation_id": ue["formation_id"], "acronyme": new_acro}) if ues and ues[0]["ue_id"] != ue_id: raise ScoValueError( f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé ! (chaque UE doit avoir un acronyme unique dans la formation.)""" ) # On ne peut pas supprimer le code UE: if "ue_code" in args and not args["ue_code"]: del args["ue_code"] cnx = ndb.GetDBConnexion() _ueEditor.edit(cnx, args) formation = db.session.get(Formation, ue["formation_id"]) if not dont_invalidate_cache: # Invalide les semestres utilisant cette formation # ainsi que les poids et coefs formation.invalidate_module_coefs() # essai edition en ligne: def edit_ue_set_code_apogee(id=None, value=None): "set UE code apogee" ue_id = id value = value.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque log("edit_ue_set_code_apogee: ue_id=%s code_apogee=%s" % (ue_id, value)) ues = ue_list(args={"ue_id": ue_id}) if not ues: return "ue invalide" do_ue_edit( {"ue_id": ue_id, "code_apogee": value}, bypass_lock=True, dont_invalidate_cache=False, ) if not value: value = scu.APO_MISSING_CODE_STR return value def ue_is_locked(ue_id): """True if UE should not be modified (contains modules used in a locked formsemestre) """ r = ndb.SimpleDictFetch( """SELECT ue.id FROM notes_ue ue, notes_modules mod, notes_formsemestre sem, notes_moduleimpl mi WHERE ue.id = mod.ue_id AND mi.module_id = mod.id AND mi.formsemestre_id = sem.id AND ue.id = %(ue_id)s AND sem.etat = false """, {"ue_id": ue_id}, ) return len(r) > 0 UE_PALETTE = [ "#B80004", # rouge "#F97B3D", # Orange Crayola "#FEB40B", # Honey Yellow "#80CB3F", # Yellow Green "#05162E", # Oxford Blue "#548687", # Steel Teal "#444054", # Independence "#889696", # Spanish Gray "#0CA4A5", # Viridian Green ] def colorie_anciennes_ues(ues: list[UniteEns]) -> None: """Avant ScoDoc 9.2, les ue n'avaient pas de couleurs Met des défauts raisonnables """ nb_colors = len(UE_PALETTE) index = 0 last_sem_idx = 0 for ue in ues: if ue.semestre_idx != last_sem_idx: index = 0 last_sem_idx = ue.semestre_idx if ue.color is None: ue.color = UE_PALETTE[index % nb_colors] index += 1 db.session.add(ue) def ue_guess_color_default(formation_id: int, default_semestre_idx: int) -> str: """Un code couleur pour une nouvelle UE dans ce semestre""" nb_colors = len(UE_PALETTE) # UE existantes dans ce semestre: nb_ues = UniteEns.query.filter( UniteEns.formation_id == formation_id, UniteEns.semestre_idx == default_semestre_idx, ).count() index = nb_ues return UE_PALETTE[index % nb_colors]