forked from ScoDoc/DocScoDoc
587 lines
19 KiB
Python
587 lines
19 KiB
Python
# -*- 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
|
|
#
|
|
##############################################################################
|
|
|
|
"""Operations de base sur les formsemestres
|
|
"""
|
|
import datetime
|
|
import time
|
|
from operator import itemgetter
|
|
|
|
from flask import g, request, url_for
|
|
|
|
import app
|
|
import app.scodoc.notesdb as ndb
|
|
import app.scodoc.sco_utils as scu
|
|
from app import log
|
|
from app.models import Departement
|
|
from app.models import FormSemestre
|
|
from app.scodoc import sco_cache, sco_codes_parcours, sco_formations, sco_preferences
|
|
from app.scodoc.gen_tables import GenTable
|
|
from app.scodoc.sco_codes_parcours import NO_SEMESTRE_ID
|
|
from app.scodoc.sco_exceptions import ScoInvalidIdType, ScoValueError
|
|
from app.scodoc.sco_vdi import ApoEtapeVDI
|
|
|
|
_formsemestreEditor = ndb.EditableTable(
|
|
"notes_formsemestre",
|
|
"formsemestre_id",
|
|
(
|
|
"formsemestre_id",
|
|
"semestre_id",
|
|
"formation_id",
|
|
"titre",
|
|
"date_debut",
|
|
"date_fin",
|
|
"gestion_compensation",
|
|
"gestion_semestrielle",
|
|
"etat",
|
|
"bul_hide_xml",
|
|
"block_moyennes",
|
|
"block_moyenne_generale",
|
|
"bul_bgcolor",
|
|
"modalite",
|
|
"resp_can_edit",
|
|
"resp_can_change_ens",
|
|
"ens_can_edit_eval",
|
|
"elt_sem_apo",
|
|
"elt_annee_apo",
|
|
),
|
|
filter_dept=True,
|
|
sortkey="date_debut",
|
|
output_formators={
|
|
"date_debut": ndb.DateISOtoDMY,
|
|
"date_fin": ndb.DateISOtoDMY,
|
|
},
|
|
input_formators={
|
|
"date_debut": ndb.DateDMYtoISO,
|
|
"date_fin": ndb.DateDMYtoISO,
|
|
"etat": bool,
|
|
"bul_hide_xml": bool,
|
|
"block_moyennes": bool,
|
|
"block_moyenne_generale": bool,
|
|
"gestion_compensation": bool,
|
|
"gestion_semestrielle": bool,
|
|
"resp_can_edit": bool,
|
|
"resp_can_change_ens": bool,
|
|
"ens_can_edit_eval": bool,
|
|
"bul_bgcolor": lambda color: color or "white",
|
|
"titre": lambda titre: titre or "sans titre",
|
|
},
|
|
)
|
|
|
|
|
|
def get_formsemestre(formsemestre_id: int):
|
|
"list ONE formsemestre"
|
|
if formsemestre_id is None:
|
|
raise ValueError("get_formsemestre: id manquant")
|
|
if formsemestre_id in g.stored_get_formsemestre:
|
|
return g.stored_get_formsemestre[formsemestre_id]
|
|
if not isinstance(formsemestre_id, int):
|
|
log(f"get_formsemestre: invalid id '{formsemestre_id}'")
|
|
raise ScoInvalidIdType("get_formsemestre: formsemestre_id must be an integer !")
|
|
sems = do_formsemestre_list(args={"formsemestre_id": formsemestre_id})
|
|
if not sems:
|
|
log(f"get_formsemestre: invalid formsemestre_id ({formsemestre_id})")
|
|
raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
|
|
|
|
g.stored_get_formsemestre[formsemestre_id] = sems[0]
|
|
return sems[0]
|
|
|
|
|
|
def do_formsemestre_list(*a, **kw):
|
|
"list formsemestres"
|
|
# log('do_formsemestre_list: a=%s kw=%s' % (str(a),str(kw)))
|
|
cnx = ndb.GetDBConnexion()
|
|
|
|
sems = _formsemestreEditor.list(cnx, *a, **kw)
|
|
|
|
# Ajoute les étapes Apogee et les responsables:
|
|
for sem in sems:
|
|
sem["etapes"] = read_formsemestre_etapes(sem["formsemestre_id"])
|
|
sem["responsables"] = read_formsemestre_responsables(sem["formsemestre_id"])
|
|
|
|
# Filtre sur code etape si indiqué:
|
|
if "args" in kw:
|
|
etape = kw["args"].get("etape_apo", None)
|
|
if etape:
|
|
sems = [sem for sem in sems if etape in sem["etapes"]]
|
|
|
|
for sem in sems:
|
|
_formsemestre_enrich(sem)
|
|
|
|
# tri par date, le plus récent d'abord
|
|
sems.sort(key=itemgetter("dateord", "semestre_id"), reverse=True)
|
|
|
|
return sems
|
|
|
|
|
|
def _formsemestre_enrich(sem):
|
|
"""Ajoute champs souvent utiles: titre + annee et dateord (pour tris).
|
|
XXX obsolete: préférer formsemestre.to_dict() ou, mieux, les méthodes de FormSemestre.
|
|
"""
|
|
# imports ici pour eviter refs circulaires
|
|
from app.scodoc import sco_formsemestre_edit
|
|
|
|
formations = sco_formations.formation_list(
|
|
args={"formation_id": sem["formation_id"]}
|
|
)
|
|
if not formations:
|
|
raise ScoValueError("pas de formation pour ce semestre !")
|
|
F = formations[0]
|
|
parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"])
|
|
# 'S1', 'S2', ... ou '' pour les monosemestres
|
|
if sem["semestre_id"] != NO_SEMESTRE_ID:
|
|
sem["sem_id_txt"] = "S%s" % sem["semestre_id"]
|
|
else:
|
|
sem["sem_id_txt"] = ""
|
|
# Nom avec numero semestre:
|
|
sem["titre_num"] = sem["titre"] # eg "DUT Informatique"
|
|
if sem["semestre_id"] != NO_SEMESTRE_ID:
|
|
sem["titre_num"] += " %s %s" % (
|
|
parcours.SESSION_NAME,
|
|
sem["semestre_id"],
|
|
) # eg "DUT Informatique semestre 2"
|
|
|
|
sem["date_debut_iso"] = ndb.DateDMYtoISO(sem["date_debut"])
|
|
sem["date_fin_iso"] = ndb.DateDMYtoISO(sem["date_fin"])
|
|
sem["dateord"] = sem["date_debut_iso"] # pour les tris
|
|
try:
|
|
mois_debut, annee_debut = sem["date_debut"].split("/")[1:]
|
|
except:
|
|
mois_debut, annee_debut = "", ""
|
|
try:
|
|
mois_fin, annee_fin = sem["date_fin"].split("/")[1:]
|
|
except:
|
|
mois_fin, annee_fin = "", ""
|
|
sem["annee_debut"] = annee_debut
|
|
sem["annee_fin"] = annee_fin
|
|
sem["mois_debut_ord"] = int(mois_debut)
|
|
sem["mois_fin_ord"] = int(mois_fin)
|
|
|
|
sem["annee"] = annee_debut
|
|
# 2007 ou 2007-2008:
|
|
sem["anneescolaire"] = scu.annee_scolaire_repr(
|
|
int(annee_debut), sem["mois_debut_ord"]
|
|
)
|
|
# La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre
|
|
# devrait sans doute pouvoir etre changé...
|
|
if sem["mois_debut_ord"] >= 8 and sem["mois_debut_ord"] <= 10:
|
|
sem["periode"] = 1 # typiquement, début en septembre: S1, S3...
|
|
else:
|
|
sem["periode"] = 2 # typiquement, début en février: S2, S4...
|
|
|
|
sem["titreannee"] = "%s %s %s" % (
|
|
sem["titre_num"],
|
|
sem.get("modalite", ""),
|
|
annee_debut,
|
|
)
|
|
if annee_fin != annee_debut:
|
|
sem["titreannee"] += "-" + annee_fin
|
|
sem["annee"] += "-" + annee_fin
|
|
# et les dates sous la forme "oct 2007 - fev 2008"
|
|
months = scu.MONTH_NAMES_ABBREV
|
|
if mois_debut:
|
|
mois_debut = months[int(mois_debut) - 1]
|
|
if mois_fin:
|
|
mois_fin = months[int(mois_fin) - 1]
|
|
sem["mois_debut"] = mois_debut + " " + annee_debut
|
|
sem["mois_fin"] = mois_fin + " " + annee_fin
|
|
sem["titremois"] = "%s %s (%s - %s)" % (
|
|
sem["titre_num"],
|
|
sem.get("modalite", ""),
|
|
sem["mois_debut"],
|
|
sem["mois_fin"],
|
|
)
|
|
sem["session_id"] = sco_formsemestre_edit.get_formsemestre_session_id(
|
|
sem, F, parcours
|
|
)
|
|
sem["etapes"] = read_formsemestre_etapes(sem["formsemestre_id"])
|
|
sem["etapes_apo_str"] = formsemestre_etape_apo_str(sem)
|
|
sem["responsables"] = read_formsemestre_responsables(sem["formsemestre_id"])
|
|
|
|
|
|
def formsemestre_etape_apo_str(sem):
|
|
"chaine décrivant le(s) codes étapes Apogée"
|
|
return etapes_apo_str(sem["etapes"])
|
|
|
|
|
|
def etapes_apo_str(etapes):
|
|
"Chaine decrivant une liste d'instance de ApoEtapeVDI"
|
|
return ", ".join([str(x) for x in etapes])
|
|
|
|
|
|
def do_formsemestre_create(args, silent=False):
|
|
"create a formsemestre"
|
|
from app.models import ScolarNews
|
|
from app.scodoc import sco_groups
|
|
|
|
cnx = ndb.GetDBConnexion()
|
|
formsemestre_id = _formsemestreEditor.create(cnx, args)
|
|
if args["etapes"]:
|
|
args["formsemestre_id"] = formsemestre_id
|
|
write_formsemestre_etapes(args)
|
|
if args["responsables"]:
|
|
args["formsemestre_id"] = formsemestre_id
|
|
write_formsemestre_responsables(args)
|
|
|
|
# create default partition
|
|
partition_id = sco_groups.partition_create(
|
|
formsemestre_id,
|
|
default=True,
|
|
redirect=0,
|
|
numero=1000000, # à la fin
|
|
)
|
|
_ = sco_groups.create_group(partition_id, default=True)
|
|
|
|
# news
|
|
if "titre" not in args:
|
|
args["titre"] = "sans titre"
|
|
args["formsemestre_id"] = formsemestre_id
|
|
args["url"] = "Notes/formsemestre_status?formsemestre_id=%(formsemestre_id)s" % args
|
|
if not silent:
|
|
ScolarNews.add(
|
|
typ=ScolarNews.NEWS_SEM,
|
|
text='Création du semestre <a href="%(url)s">%(titre)s</a>' % args,
|
|
url=args["url"],
|
|
)
|
|
return formsemestre_id
|
|
|
|
|
|
def do_formsemestre_edit(sem, cnx=None, **kw):
|
|
"""Apply modifications to formsemestre.
|
|
Update etapes and resps. Invalidate cache."""
|
|
if not cnx:
|
|
cnx = ndb.GetDBConnexion()
|
|
|
|
_formsemestreEditor.edit(cnx, sem, **kw)
|
|
write_formsemestre_etapes(sem)
|
|
write_formsemestre_responsables(sem)
|
|
|
|
sco_cache.invalidate_formsemestre(
|
|
formsemestre_id=sem["formsemestre_id"]
|
|
) # > modif formsemestre
|
|
|
|
|
|
def read_formsemestre_responsables(formsemestre_id: int) -> list[int]: # py3.9+ syntax
|
|
"""recupere liste des responsables de ce semestre
|
|
:returns: liste d'id
|
|
"""
|
|
r = ndb.SimpleDictFetch(
|
|
"""SELECT responsable_id
|
|
FROM notes_formsemestre_responsables
|
|
WHERE formsemestre_id = %(formsemestre_id)s
|
|
""",
|
|
{"formsemestre_id": formsemestre_id},
|
|
)
|
|
return [x["responsable_id"] for x in r]
|
|
|
|
|
|
def write_formsemestre_responsables(sem):
|
|
return _write_formsemestre_aux(sem, "responsables", "responsable_id")
|
|
|
|
|
|
# ---------------------- Coefs des UE
|
|
|
|
_formsemestre_uecoef_editor = ndb.EditableTable(
|
|
"notes_formsemestre_uecoef",
|
|
"formsemestre_uecoef_id",
|
|
("formsemestre_uecoef_id", "formsemestre_id", "ue_id", "coefficient"),
|
|
)
|
|
|
|
formsemestre_uecoef_create = _formsemestre_uecoef_editor.create
|
|
formsemestre_uecoef_edit = _formsemestre_uecoef_editor.edit
|
|
formsemestre_uecoef_list = _formsemestre_uecoef_editor.list
|
|
formsemestre_uecoef_delete = _formsemestre_uecoef_editor.delete
|
|
|
|
|
|
def do_formsemestre_uecoef_edit_or_create(cnx, formsemestre_id, ue_id, coef):
|
|
"modify or create the coef"
|
|
coefs = formsemestre_uecoef_list(
|
|
cnx, args={"formsemestre_id": formsemestre_id, "ue_id": ue_id}
|
|
)
|
|
if coefs:
|
|
formsemestre_uecoef_edit(
|
|
cnx,
|
|
args={
|
|
"formsemestre_uecoef_id": coefs[0]["formsemestre_uecoef_id"],
|
|
"coefficient": coef,
|
|
},
|
|
)
|
|
else:
|
|
formsemestre_uecoef_create(
|
|
cnx,
|
|
args={
|
|
"formsemestre_id": formsemestre_id,
|
|
"ue_id": ue_id,
|
|
"coefficient": coef,
|
|
},
|
|
)
|
|
|
|
|
|
def do_formsemestre_uecoef_delete(cnx, formsemestre_id, ue_id):
|
|
"delete coef for this (ue,sem)"
|
|
coefs = formsemestre_uecoef_list(
|
|
cnx, args={"formsemestre_id": formsemestre_id, "ue_id": ue_id}
|
|
)
|
|
if coefs:
|
|
formsemestre_uecoef_delete(cnx, coefs[0]["formsemestre_uecoef_id"])
|
|
|
|
|
|
def read_formsemestre_etapes(formsemestre_id): # OBSOLETE
|
|
"""recupere liste des codes etapes associés à ce semestre
|
|
:returns: liste d'instance de ApoEtapeVDI
|
|
"""
|
|
r = ndb.SimpleDictFetch(
|
|
"""SELECT etape_apo
|
|
FROM notes_formsemestre_etapes
|
|
WHERE formsemestre_id = %(formsemestre_id)s
|
|
ORDER BY etape_apo
|
|
""",
|
|
{"formsemestre_id": formsemestre_id},
|
|
)
|
|
return [ApoEtapeVDI(x["etape_apo"]) for x in r if x["etape_apo"]]
|
|
|
|
|
|
def write_formsemestre_etapes(sem):
|
|
return _write_formsemestre_aux(sem, "etapes", "etape_apo")
|
|
|
|
|
|
def _write_formsemestre_aux(sem, fieldname, valuename):
|
|
"""fieldname: 'etapes' ou 'responsables'
|
|
valuename: 'etape_apo' ou 'responsable_id'
|
|
"""
|
|
if not fieldname in sem:
|
|
return
|
|
# uniquify
|
|
values = set([str(x) for x in sem[fieldname]])
|
|
|
|
cnx = ndb.GetDBConnexion()
|
|
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
|
|
tablename = "notes_formsemestre_" + fieldname
|
|
try:
|
|
cursor.execute(
|
|
"DELETE from " + tablename + " where formsemestre_id = %(formsemestre_id)s",
|
|
{"formsemestre_id": sem["formsemestre_id"]},
|
|
)
|
|
for item in values:
|
|
if item:
|
|
cursor.execute(
|
|
"INSERT INTO "
|
|
+ tablename
|
|
+ " (formsemestre_id, "
|
|
+ valuename
|
|
+ ") VALUES (%(formsemestre_id)s, %("
|
|
+ valuename
|
|
+ ")s)",
|
|
{"formsemestre_id": sem["formsemestre_id"], valuename: item},
|
|
)
|
|
except:
|
|
log("Warning: exception in write_formsemestre_aux !")
|
|
cnx.rollback()
|
|
raise
|
|
cnx.commit()
|
|
|
|
|
|
def sem_set_responsable_name(sem):
|
|
"ajoute champs responsable_name"
|
|
from app.scodoc import sco_users
|
|
|
|
sem["responsable_name"] = ", ".join(
|
|
[
|
|
sco_users.user_info(responsable_id)["nomprenom"]
|
|
for responsable_id in sem["responsables"]
|
|
]
|
|
)
|
|
|
|
|
|
def sem_in_semestre_scolaire(
|
|
sem,
|
|
year=False,
|
|
periode=None,
|
|
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
|
|
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
|
|
) -> bool:
|
|
"""Vrai si la date du début du semestre est dans la période indiquée (1,2,0)
|
|
du semestre `periode` de l'année scolaire indiquée
|
|
(ou, à défaut, de celle en cours).
|
|
|
|
La période utilise les même conventions que semset["sem_id"];
|
|
* 1 : première période
|
|
* 2 : deuxième période
|
|
* 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
|
|
)
|
|
"""
|
|
if not year:
|
|
year = scu.annee_scolaire()
|
|
# n'utilise pas le jour pivot
|
|
jour_pivot_annee = jour_pivot_periode = 1
|
|
# calcule l'année universitaire et la période
|
|
sem_annee, sem_periode = FormSemestre.comp_periode(
|
|
datetime.datetime.fromisoformat(sem["date_debut_iso"]),
|
|
mois_pivot_annee,
|
|
mois_pivot_periode,
|
|
jour_pivot_annee,
|
|
jour_pivot_periode,
|
|
)
|
|
if periode is None or periode == 0:
|
|
return sem_annee == year
|
|
return sem_annee == year and sem_periode == periode
|
|
|
|
|
|
def sem_in_annee_scolaire(sem, year=False):
|
|
"""Test si sem appartient à l'année scolaire year (int).
|
|
N'utilise que la date de début, pivot au 1er août.
|
|
Si année non specifiée, année scolaire courante
|
|
"""
|
|
return sem_in_semestre_scolaire(sem, year, periode=0)
|
|
|
|
|
|
def sem_est_courant(sem): # -> FormSemestre.est_courant
|
|
"""Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses)"""
|
|
now = time.strftime("%Y-%m-%d")
|
|
debut = ndb.DateDMYtoISO(sem["date_debut"])
|
|
fin = ndb.DateDMYtoISO(sem["date_fin"])
|
|
return debut <= now <= fin
|
|
|
|
|
|
def scodoc_get_all_unlocked_sems():
|
|
"""Liste de tous les semestres non verrouillés de _tous_ les départements
|
|
(utilisé pour rapports d'activités)
|
|
"""
|
|
cur_dept = g.scodoc_dept
|
|
depts = Departement.query.filter_by(visible=True).all()
|
|
semdepts = []
|
|
try:
|
|
for dept in depts:
|
|
app.set_sco_dept(dept.acronym)
|
|
semdepts += [(sem, dept) for sem in do_formsemestre_list() if sem["etat"]]
|
|
finally:
|
|
app.set_sco_dept(cur_dept)
|
|
return semdepts
|
|
|
|
|
|
def table_formsemestres(
|
|
sems: list[dict],
|
|
columns_ids=(),
|
|
sup_columns_ids=(),
|
|
html_title="<h2>Semestres</h2>",
|
|
html_next_section="",
|
|
):
|
|
"""Une table presentant des semestres"""
|
|
for sem in sems:
|
|
sem_set_responsable_name(sem)
|
|
sem["_titre_num_target"] = url_for(
|
|
"notes.formsemestre_status",
|
|
scodoc_dept=g.scodoc_dept,
|
|
formsemestre_id=sem["formsemestre_id"],
|
|
)
|
|
|
|
if not columns_ids:
|
|
columns_ids = (
|
|
"etat",
|
|
"modalite",
|
|
"mois_debut",
|
|
"mois_fin",
|
|
"titre_num",
|
|
"responsable_name",
|
|
"etapes_apo_str",
|
|
)
|
|
columns_ids += sup_columns_ids
|
|
|
|
titles = {
|
|
"modalite": "",
|
|
"mois_debut": "Début",
|
|
"mois_fin": "Fin",
|
|
"titre_num": "Semestre",
|
|
"responsable_name": "Resp.",
|
|
"etapes_apo_str": "Apo.",
|
|
}
|
|
if sems:
|
|
preferences = sco_preferences.SemPreferences(sems[0]["formsemestre_id"])
|
|
else:
|
|
preferences = sco_preferences.SemPreferences()
|
|
tab = GenTable(
|
|
columns_ids=columns_ids,
|
|
rows=sems,
|
|
titles=titles,
|
|
html_class="table_leftalign",
|
|
html_sortable=True,
|
|
html_title=html_title,
|
|
html_next_section=html_next_section,
|
|
html_empty_element="<p><em>aucun résultat</em></p>",
|
|
page_title="Semestres",
|
|
preferences=preferences,
|
|
)
|
|
return tab
|
|
|
|
|
|
def list_formsemestre_by_etape(etape_apo=False, annee_scolaire=False) -> list[dict]:
|
|
"""Liste des semestres de cette etape,
|
|
pour l'annee scolaire indiquée (sinon, pour toutes).
|
|
"""
|
|
ds = {} # formsemestre_id : sem
|
|
if etape_apo:
|
|
sems = do_formsemestre_list(args={"etape_apo": etape_apo})
|
|
for sem in sems:
|
|
if annee_scolaire: # restriction annee scolaire
|
|
if sem_in_annee_scolaire(sem, year=int(annee_scolaire)):
|
|
ds[sem["formsemestre_id"]] = sem
|
|
sems = list(ds.values())
|
|
else:
|
|
sems = do_formsemestre_list()
|
|
if annee_scolaire:
|
|
sems = [
|
|
sem
|
|
for sem in sems
|
|
if sem_in_annee_scolaire(sem, year=int(annee_scolaire))
|
|
]
|
|
|
|
sems.sort(key=lambda s: (s["modalite"], s["dateord"]))
|
|
return sems
|
|
|
|
|
|
def view_formsemestre_by_etape(etape_apo=None, format="html"):
|
|
"""Affiche table des semestres correspondants à l'étape"""
|
|
if etape_apo:
|
|
html_title = f"""<h2>Semestres courants de l'étape <tt>{etape_apo}</tt></h2>"""
|
|
else:
|
|
html_title = """<h2>Semestres courants</h2>"""
|
|
tab = table_formsemestres(
|
|
list_formsemestre_by_etape(
|
|
etape_apo=etape_apo, annee_scolaire=scu.annee_scolaire()
|
|
),
|
|
html_title=html_title,
|
|
html_next_section="""<form action="view_formsemestre_by_etape">
|
|
Etape: <input name="etape_apo" type="text" size="8"></input>
|
|
</form>""",
|
|
)
|
|
tab.base_url = "%s?etape_apo=%s" % (request.base_url, etape_apo or "")
|
|
return tab.make_page(format=format)
|
|
|
|
|
|
def sem_has_etape(sem, code_etape):
|
|
return code_etape in sem["etapes"]
|