# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2022 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 flask from flask import url_for, render_template from flask import g, request from flask_login import current_user from app.models import APO_CODE_STR_LEN from app.models import Formation, UniteEns, ModuleImpl, Module import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType from app import log from app.scodoc.TrivialFormulator import TrivialFormulator, TF from app.scodoc.gen_tables import GenTable from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( ScoGenError, ScoValueError, ScoLockedFormError, ScoNonEmptyFormationObject, ) from app.scodoc import html_sco_header from app.scodoc import sco_cache from app.scodoc import sco_codes_parcours from app.scodoc import sco_edit_apc from app.scodoc import sco_edit_formation from app.scodoc import sco_edit_matiere from app.scodoc import sco_edit_module from app.scodoc import sco_etud from app.scodoc import sco_formsemestre from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl from app.scodoc import sco_news from app.scodoc import sco_permissions from app.scodoc import sco_preferences 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", ), sortkey="numero", input_formators={ "type": ndb.int_null_is_zero, "is_external": ndb.bool_or_str, }, 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" from app.scodoc import sco_formations 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)""" ) # create ue_id = _ueEditor.create(cnx, args) # Invalidate cache: vire les poids de toutes les évals de la formation for modimpl in ModuleImpl.query.filter( ModuleImpl.module_id == Module.id, Module.formation_id == args["formation_id"] ): modimpl.invalidate_evaluations_poids() formation = Formation.query.get(args["formation_id"]) formation.invalidate_module_coefs() # news F = sco_formations.formation_list(args={"formation_id": args["formation_id"]})[0] sco_news.add( typ=sco_news.NEWS_FORM, object=args["formation_id"], text="Modification de la formation %(acronyme)s" % F, max_frequency=3, ) return ue_id def can_delete_ue(ue: UniteEns) -> bool: """True si l'UE n'est pas utilisée dans des formsemestre et n'a pas de module rattachés """ # "pas un seul module de cette UE n'a de modimpl..."" return (not len(ue.modules.all())) and not any(m.modimpls.all() for m in ue.modules) def do_ue_delete(ue_id, delete_validations=False, force=False): "delete UE and attached matieres (but not modules)" from app.scodoc import sco_formations from app.scodoc import sco_parcours_dut ue = UniteEns.query.get_or_404(ue_id) if not can_delete_ue(ue): raise ScoNonEmptyFormationObject( "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, ), ) cnx = ndb.GetDBConnexion() log("do_ue_delete: ue_id=%s, delete_validations=%s" % (ue.id, delete_validations)) # check # if ue_is_locked(ue.id): # raise ScoLockedFormError() # Il y a-t-il des etudiants ayant validé cette UE ? # si oui, propose de supprimer les validations validations = sco_parcours_dut.scolar_formsemestre_validation_list( cnx, args={"ue_id": ue.id} ) if validations and not delete_validations and not force: return scu.confirm_dialog( "

%d étudiants ont validé l'UE %s (%s)

Si vous supprimez cette UE, ces validations vont être supprimées !

" % (len(validations), ue.acronyme, ue.titre), dest_url="", target_variable="delete_validations", cancel_url=url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation_id, semestre_idx=ue.semestre_idx, ), parameters={"ue_id": ue.id, "dialog_confirmed": 1}, ) if delete_validations: log("deleting all validations of UE %s" % ue.id) ndb.SimpleQuery( "DELETE FROM scolar_formsemestre_validation WHERE ue_id=%(ue_id)s", {"ue_id": ue.id}, ) # delete all matiere in this UE mats = sco_edit_matiere.matiere_list({"ue_id": ue.id}) for mat in mats: sco_edit_matiere.do_matiere_delete(mat["matiere_id"]) # delete uecoef and events ndb.SimpleQuery( "DELETE FROM notes_formsemestre_uecoef WHERE ue_id=%(ue_id)s", {"ue_id": ue.id}, ) ndb.SimpleQuery("DELETE FROM scolar_events WHERE ue_id=%(ue_id)s", {"ue_id": ue.id}) cnx = ndb.GetDBConnexion() _ueEditor.delete(cnx, ue.id) # > UE delete + supr. validations associées etudiants (cas compliqué, mais rarement # utilisé: acceptable de tout invalider): sco_cache.invalidate_formsemestre() # news F = sco_formations.formation_list(args={"formation_id": ue.formation_id})[0] sco_news.add( typ=sco_news.NEWS_FORM, object=ue.formation_id, text="Modification de la formation %(acronyme)s" % F, max_frequency=3, ) # if not force: return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=ue.formation_id, semestre_idx=ue.semestre_idx, ) ) return None def ue_create(formation_id=None): """Creation d'une UE""" return ue_edit(create=True, formation_id=formation_id) def ue_edit(ue_id=None, create=False, formation_id=None): """Modification ou création d'une UE""" create = int(create) if not create: U = ue_list(args={"ue_id": ue_id}) if not U: raise ScoValueError("UE inexistante !") U = U[0] formation_id = U["formation_id"] title = "Modification de l'UE %(titre)s" % U initvalues = U submitlabel = "Modifier les valeurs" else: title = "Création d'une UE" initvalues = {} submitlabel = "Créer cette UE" formation = Formation.query.get(formation_id) if not formation: raise ScoValueError(f"Formation inexistante ! (id={formation_id})") parcours = formation.get_parcours() is_apc = parcours.APC_SAE semestres_indices = list(range(1, parcours.NB_SEM + 1)) H = [ html_sco_header.sco_header(page_title=title, javascripts=["js/edit_ue.js"]), "

" + title, f" (formation {formation.acronyme}, version {formation.version})

", """

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")).

Note: sauf exception, l'UE n'a pas de coefficient associé. Seuls les modules ont des coefficients.

""", ] ue_types = parcours.ALLOWED_UE_TYPES ue_types.sort() ue_types_names = [sco_codes_parcours.UE_TYPE_NAME[k] for k in ue_types] ue_types = [str(x) for x in ue_types] fw = [ ("ue_id", {"input_type": "hidden"}), ("create", {"input_type": "hidden", "default": create}), ("formation_id", {"input_type": "hidden", "default": formation_id}), ("titre", {"size": 30, "explanation": "nom de l'UE"}), ("acronyme", {"size": 8, "explanation": "abbréviation", "allow_null": False}), ( "numero", { "size": 2, "explanation": "numéro (1,2,3,4) de l'UE pour l'ordre d'affichage", "type": "int", }, ), ( "semestre_idx", { "input_type": "menu", "type": "int", "allow_null": False, "title": parcours.SESSION_NAME.capitalize(), "explanation": "%s de l'UE dans la formation" % parcours.SESSION_NAME, "labels": ["non spécifié"] + [str(x) for x in semestres_indices], "allowed_values": [""] + semestres_indices, }, ), ( "type", { "explanation": "type d'UE", "input_type": "menu", "allowed_values": ue_types, "labels": ue_types_names, }, ), ( "ects", { "size": 4, "type": "float", "title": "ECTS", "explanation": "nombre de crédits ECTS", }, ), ( "coefficient", { "size": 4, "type": "float", "title": "Coefficient", "explanation": """les coefficients d'UE ne sont utilisés que lorsque l'option Utiliser les coefficients d'UE pour calculer la moyenne générale 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. """, }, ), ( "ue_code", { "size": 12, "title": "Code UE", "explanation": "code interne (optionnel). 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.", }, ), ( "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", "validator": lambda val, _: len(val) < APO_CODE_STR_LEN, }, ), ( "is_external", { "input_type": "boolcheckbox", "title": "UE externe", "explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement", }, ), ] if create and not parcours.UE_IS_MODULE and not is_apc: fw.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(), fw, initvalues=initvalues, submitlabel=submitlabel, ) if tf[0] == 0: X = """
""" return "\n".join(H) + tf[1] + X + html_sco_header.sco_footer() else: 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 parcours.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 parcours.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"], "coefficient": 1.0, # tous les modules auront coef 1, et on utilisera les ECTS "ue_id": ue_id, "matiere_id": matiere_id, "formation_id": formation_id, "semestre_id": tf[2]["semestre_idx"], }, ) else: do_ue_edit(tf[2]) return flask.redirect( url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formation_id, semestre_idx=tf[2]["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"] = sco_codes_parcours.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"] = sco_codes_parcours.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 = Formation.query.get(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_parcours().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.""" ) if not can_delete_ue(ue): raise ScoNonEmptyFormationObject( "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"

Suppression de l'UE {ue.titre} ({ue.acronyme})

", 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.id, 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 = Formation.query.get(formation_id) if not formation: raise ScoValueError("invalid formation_id") parcours = formation.get_parcours() is_apc = parcours.APC_SAE locked = formation.has_locked_sems() if semestre_idx == "all": semestre_idx = None else: semestre_idx = int(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) ues_externes_obj = UniteEns.query.filter_by( formation_id=formation_id, is_external=True ) if is_apc: # pour faciliter la transition des anciens programmes non APC for ue in ues_obj: ue.guess_semestre_idx() 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"])) has_duplicate_ue_codes = len(set([ue["ue_code"] for ue in ues])) != len(ues) has_perm_change = current_user.has_permission(Permission.ScoChangeFormation) # 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.ScoEditFormationTags) 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=["libjs/jQuery-tagEditor/jquery.tag-editor.css"], javascripts=[ "libjs/jinplace-1.2.1.min.js", "js/ue_list.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}", ), f"""

Formation {formation.titre} ({formation.acronyme}) [version {formation.version}] code {formation.formation_code} {lockicon}

""", ] if locked: H.append( f"""

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:

""" ) if msg: H.append('

' + msg + "

") if has_duplicate_ue_codes: H.append( """
Attention: plusieurs UE de cette formation ont le même code. Il faut corriger cela ci-dessous, sinon les calculs d'ECTS seront erronés !
""" ) # Description de la formation H.append( render_template( "pn/form_descr.html", formation=formation, parcours=parcours, editable=editable, ) ) # Formation APC (BUT) ? if is_apc: H.append( f"""
Formation par compétences (BUT) - Semestre {_html_select_semestre_idx(formation_id, semestre_ids, semestre_idx)}
""" ) 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: {formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long} """ msg_refcomp = "changer" H.append( f""" """ ) # Description des UE/matières/modules H.append( """
Programme pédagogique:
montrer les tags
""" ) 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( _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""" """ ) H.append("
") # formation_ue_list if ues_externes: H.append( f"""
UE externes déclarées (pour information):
{_ue_table_ues( parcours, ues_externes, editable, tag_editable, has_perm_change, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, )}
""" ) H.append("

""" ) if has_perm_change: H.append( """

Semestres ou sessions de cette formation

") if current_user.has_permission(Permission.ScoImplement): H.append( f"""""" ) #
  • (debug) Vérifier cohérence
  • 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 = """
    Semestre:
    """ return htm def _ue_table_ues( parcours, ues, editable, tag_editable, has_perm_change, arrow_up, arrow_down, arrow_none, delete_icon, delete_disabled_icon, ): """É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"]: ue["ects_str"] = ", %g ECTS" % ue["ects"] else: ue["ects_str"] = "" if editable: klass = "span_apo_edit" else: klass = "" ue["code_apogee_str"] = ( """, Apo: """ % (klass, ue["ue_id"], scu.APO_MISSING_CODE_STR) + (ue["code_apogee"] or "") + "" ) if cur_ue_semestre_id != ue["semestre_id"]: cur_ue_semestre_id = ue["semestre_id"] if iue > 0: H.append("") if ue["semestre_id"] == sco_codes_parcours.UE_SEM_DEFAULT: lab = "Pas d'indication de semestre:" else: lab = "Semestre %s:" % ue["semestre_id"] H.append('
    %s
    ' % lab) H.append('