ScoDoc/app/scodoc/sco_formsemestre.py

582 lines
19 KiB
Python
Raw Permalink Normal View History

2020-09-26 16:19:37 +02:00
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
2023-01-02 13:16:27 +01:00
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
2020-09-26 16:19:37 +02:00
#
# 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
"""
2022-11-09 12:50:10 +01:00
import datetime
import time
from operator import itemgetter
2020-09-26 16:19:37 +02:00
from flask import g, request, url_for
2021-08-20 01:22:44 +02:00
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 Formation, FormSemestre
from app.scodoc import sco_cache, codes_cursus, sco_formations, sco_preferences
2021-06-21 10:17:16 +02:00
from app.scodoc.gen_tables import GenTable
from app.scodoc.codes_cursus import NO_SEMESTRE_ID
from app.scodoc.sco_exceptions import ScoInvalidIdType, ScoValueError
2021-07-10 13:55:35 +02:00
from app.scodoc.sco_vdi import ApoEtapeVDI
2020-09-26 16:19:37 +02:00
2021-02-03 22:00:41 +01:00
_formsemestreEditor = ndb.EditableTable(
2020-09-26 16:19:37 +02:00
"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",
2020-09-26 16:19:37 +02:00
"bul_bgcolor",
"modalite",
"resp_can_edit",
"resp_can_change_ens",
"ens_can_edit_eval",
"elt_sem_apo",
"elt_annee_apo",
),
2021-08-13 00:34:58 +02:00
filter_dept=True,
2020-09-26 16:19:37 +02:00
sortkey="date_debut",
output_formators={
2021-02-03 22:00:41 +01:00
"date_debut": ndb.DateISOtoDMY,
"date_fin": ndb.DateISOtoDMY,
2020-09-26 16:19:37 +02:00
},
input_formators={
2021-02-03 22:00:41 +01:00
"date_debut": ndb.DateDMYtoISO,
"date_fin": ndb.DateDMYtoISO,
2021-08-10 09:10:36 +02:00
"etat": bool,
2021-08-10 12:57:38 +02:00
"bul_hide_xml": bool,
"block_moyennes": bool,
"block_moyenne_generale": bool,
2021-08-11 00:36:07 +02:00
"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",
2020-09-26 16:19:37 +02:00
},
)
2023-02-01 14:35:25 +01:00
def get_formsemestre(formsemestre_id: int):
2020-09-26 16:19:37 +02:00
"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]
2021-09-13 17:10:38 +02:00
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})")
2023-02-01 14:35:25 +01:00
raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
g.stored_get_formsemestre[formsemestre_id] = sems[0]
return sems[0]
2020-09-26 16:19:37 +02:00
2021-08-19 10:28:35 +02:00
def do_formsemestre_list(*a, **kw):
2020-09-26 16:19:37 +02:00
"list formsemestres"
# log('do_formsemestre_list: a=%s kw=%s' % (str(a),str(kw)))
2021-06-15 13:59:56 +02:00
cnx = ndb.GetDBConnexion()
2020-09-26 16:19:37 +02:00
sems = _formsemestreEditor.list(cnx, *a, **kw)
# Ajoute les étapes Apogee et les responsables:
for sem in sems:
2021-08-19 10:28:35 +02:00
sem["etapes"] = read_formsemestre_etapes(sem["formsemestre_id"])
sem["responsables"] = read_formsemestre_responsables(sem["formsemestre_id"])
2020-09-26 16:19:37 +02:00
# 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:
2021-08-19 10:28:35 +02:00
_formsemestre_enrich(sem)
2020-09-26 16:19:37 +02:00
2021-07-09 23:19:30 +02:00
# tri par date, le plus récent d'abord
sems.sort(key=itemgetter("dateord", "semestre_id"), reverse=True)
2020-09-26 16:19:37 +02:00
return sems
2021-08-19 10:28:35 +02:00
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.
"""
2020-09-26 16:19:37 +02:00
# imports ici pour eviter refs circulaires
2021-07-12 00:25:23 +02:00
from app.scodoc import sco_formsemestre_edit
2020-09-26 16:19:37 +02:00
formation: Formation = Formation.query.get_or_404(sem["formation_id"])
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
2020-09-26 16:19:37 +02:00
# '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"
2021-02-03 22:00:41 +01:00
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
2020-09-26 16:19:37 +02:00
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:
2021-02-04 20:02:44 +01:00
sem["anneescolaire"] = scu.annee_scolaire_repr(
int(annee_debut), sem["mois_debut_ord"]
)
2020-09-26 16:19:37 +02:00
# 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"
2021-12-04 21:04:09 +01:00
months = scu.MONTH_NAMES_ABBREV
2020-09-26 16:19:37 +02:00
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, formation.code_specialite, parcours
2020-09-26 16:19:37 +02:00
)
2021-08-19 10:28:35 +02:00
sem["etapes"] = read_formsemestre_etapes(sem["formsemestre_id"])
2020-09-26 16:19:37 +02:00
sem["etapes_apo_str"] = formsemestre_etape_apo_str(sem)
2021-08-19 10:28:35 +02:00
sem["responsables"] = read_formsemestre_responsables(sem["formsemestre_id"])
2020-09-26 16:19:37 +02:00
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):
2021-06-21 10:17:16 +02:00
"create a formsemestre"
2022-04-12 17:12:51 +02:00
from app.models import ScolarNews
from app.scodoc import sco_groups
2021-06-21 11:22:55 +02:00
2021-06-21 10:17:16 +02:00
cnx = ndb.GetDBConnexion()
formsemestre_id = _formsemestreEditor.create(cnx, args)
if args["etapes"]:
args["formsemestre_id"] = formsemestre_id
2021-08-19 10:28:35 +02:00
write_formsemestre_etapes(args)
2021-06-21 10:17:16 +02:00
if args["responsables"]:
args["formsemestre_id"] = formsemestre_id
2021-08-19 10:28:35 +02:00
write_formsemestre_responsables(args)
2021-06-21 10:17:16 +02:00
# create default partition
partition_id = sco_groups.partition_create(
formsemestre_id,
default=True,
redirect=0,
numero=1000000, # à la fin
2021-06-21 10:17:16 +02:00
)
_ = sco_groups.create_group(partition_id, default=True)
2021-06-21 10:17:16 +02:00
# news
2021-07-09 17:47:06 +02:00
if "titre" not in args:
2021-06-21 10:17:16 +02:00
args["titre"] = "sans titre"
args["formsemestre_id"] = formsemestre_id
args["url"] = "Notes/formsemestre_status?formsemestre_id=%(formsemestre_id)s" % args
if not silent:
2022-04-12 17:12:51 +02:00
ScolarNews.add(
typ=ScolarNews.NEWS_SEM,
2021-06-21 10:17:16 +02:00
text='Création du semestre <a href="%(url)s">%(titre)s</a>' % args,
url=args["url"],
)
return formsemestre_id
2021-08-19 10:28:35 +02:00
def do_formsemestre_edit(sem, cnx=None, **kw):
2021-01-16 11:49:02 +01:00
"""Apply modifications to formsemestre.
Update etapes and resps. Invalidate cache."""
2020-09-26 16:19:37 +02:00
if not cnx:
2021-06-15 13:59:56 +02:00
cnx = ndb.GetDBConnexion()
2020-09-26 16:19:37 +02:00
_formsemestreEditor.edit(cnx, sem, **kw)
2021-08-19 10:28:35 +02:00
write_formsemestre_etapes(sem)
write_formsemestre_responsables(sem)
2020-09-26 16:19:37 +02:00
2021-07-19 19:53:01 +02:00
sco_cache.invalidate_formsemestre(
formsemestre_id=sem["formsemestre_id"]
2021-06-13 23:37:14 +02:00
) # > modif formsemestre
2020-09-26 16:19:37 +02:00
2021-08-22 13:24:36 +02:00
def read_formsemestre_responsables(formsemestre_id: int) -> list[int]: # py3.9+ syntax
2020-09-26 16:19:37 +02:00
"""recupere liste des responsables de ce semestre
2021-08-22 13:24:36 +02:00
:returns: liste d'id
2020-09-26 16:19:37 +02:00
"""
2021-02-03 22:00:41 +01:00
r = ndb.SimpleDictFetch(
"""SELECT responsable_id
FROM notes_formsemestre_responsables
WHERE formsemestre_id = %(formsemestre_id)s
""",
2020-09-26 16:19:37 +02:00
{"formsemestre_id": formsemestre_id},
)
return [x["responsable_id"] for x in r]
2021-08-19 10:28:35 +02:00
def write_formsemestre_responsables(sem):
return _write_formsemestre_aux(sem, "responsables", "responsable_id")
2020-09-26 16:19:37 +02:00
# ---------------------- Coefs des UE
2021-02-03 22:00:41 +01:00
_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
2021-08-19 10:28:35 +02:00
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,
},
)
2021-08-19 10:28:35 +02:00
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"])
2022-02-09 23:22:00 +01:00
def read_formsemestre_etapes(formsemestre_id): # OBSOLETE
2020-09-26 16:19:37 +02:00
"""recupere liste des codes etapes associés à ce semestre
:returns: liste d'instance de ApoEtapeVDI
"""
2021-02-03 22:00:41 +01:00
r = ndb.SimpleDictFetch(
"""SELECT etape_apo
FROM notes_formsemestre_etapes
WHERE formsemestre_id = %(formsemestre_id)s
ORDER BY etape_apo
""",
2020-09-26 16:19:37 +02:00
{"formsemestre_id": formsemestre_id},
)
return [ApoEtapeVDI(x["etape_apo"]) for x in r if x["etape_apo"]]
2021-08-19 10:28:35 +02:00
def write_formsemestre_etapes(sem):
return _write_formsemestre_aux(sem, "etapes", "etape_apo")
2020-09-26 16:19:37 +02:00
2021-08-19 10:28:35 +02:00
def _write_formsemestre_aux(sem, fieldname, valuename):
2020-09-26 16:19:37 +02:00
"""fieldname: 'etapes' ou 'responsables'
valuename: 'etape_apo' ou 'responsable_id'
"""
if not fieldname in sem:
2020-09-26 16:19:37 +02:00
return
# uniquify
values = set([str(x) for x in sem[fieldname]])
cnx = ndb.GetDBConnexion()
2021-02-03 22:00:41 +01:00
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
2020-09-26 16:19:37 +02:00
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:
2020-09-26 16:19:37 +02:00
if item:
cursor.execute(
"INSERT INTO "
+ tablename
+ " (formsemestre_id, "
+ valuename
+ ") VALUES (%(formsemestre_id)s, %("
+ valuename
+ ")s)",
{"formsemestre_id": sem["formsemestre_id"], valuename: item},
2020-09-26 16:19:37 +02:00
)
except:
log("Warning: exception in write_formsemestre_aux !")
cnx.rollback()
raise
cnx.commit()
2021-08-19 10:28:35 +02:00
def sem_set_responsable_name(sem):
2020-09-26 16:19:37 +02:00
"ajoute champs responsable_name"
from app.scodoc import sco_users
2020-09-26 16:19:37 +02:00
sem["responsable_name"] = ", ".join(
[
sco_users.user_info(responsable_id)["nomprenom"]
2020-09-26 16:19:37 +02:00
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)
)
2020-09-26 16:19:37 +02:00
"""
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,
2020-09-26 16:19:37 +02:00
)
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)
2020-09-26 16:19:37 +02:00
2021-12-04 21:04:09 +01:00
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")
2021-02-03 22:00:41 +01:00
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()
2020-09-26 16:19:37 +02:00
semdepts = []
try:
for dept in depts:
app.set_sco_dept(dept.acronym)
2021-08-19 10:28:35 +02:00
semdepts += [(sem, dept) for sem in do_formsemestre_list() if sem["etat"]]
finally:
app.set_sco_dept(cur_dept)
2020-09-26 16:19:37 +02:00
return semdepts
def table_formsemestres(
sems: list[dict],
2020-09-26 16:19:37 +02:00
columns_ids=(),
sup_columns_ids=(),
html_title="<h2>Semestres</h2>",
html_next_section="",
):
"""Une table presentant des semestres"""
2020-09-26 16:19:37 +02:00
for sem in sems:
2021-08-19 10:28:35 +02:00
sem_set_responsable_name(sem)
sem["_titre_num_target"] = url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=sem["formsemestre_id"],
2020-09-26 16:19:37 +02:00
)
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"])
2020-09-26 16:19:37 +02:00
else:
preferences = sco_preferences.SemPreferences()
2020-09-26 16:19:37 +02:00
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).
"""
2020-09-26 16:19:37 +02:00
ds = {} # formsemestre_id : sem
if etape_apo:
2021-08-19 10:28:35 +02:00
sems = do_formsemestre_list(args={"etape_apo": etape_apo})
2020-09-26 16:19:37 +02:00
for sem in sems:
if annee_scolaire: # restriction annee scolaire
2021-08-19 10:28:35 +02:00
if sem_in_annee_scolaire(sem, year=int(annee_scolaire)):
2020-09-26 16:19:37 +02:00
ds[sem["formsemestre_id"]] = sem
2021-07-09 17:47:06 +02:00
sems = list(ds.values())
2020-09-26 16:19:37 +02:00
else:
2021-08-19 10:28:35 +02:00
sems = do_formsemestre_list()
2020-09-26 16:19:37 +02:00
if annee_scolaire:
sems = [
sem
for sem in sems
2021-08-19 10:28:35 +02:00
if sem_in_annee_scolaire(sem, year=int(annee_scolaire))
2020-09-26 16:19:37 +02:00
]
sems.sort(key=lambda s: (s["modalite"], s["dateord"]))
return sems
2021-09-24 20:20:45 +02:00
def view_formsemestre_by_etape(etape_apo=None, format="html"):
"""Affiche table des semestres correspondants à l'étape"""
2020-09-26 16:19:37 +02:00
if etape_apo:
html_title = f"""<h2>Semestres courants de l'étape <tt>{etape_apo}</tt></h2>"""
2020-09-26 16:19:37 +02:00
else:
html_title = """<h2>Semestres courants</h2>"""
tab = table_formsemestres(
list_formsemestre_by_etape(
etape_apo=etape_apo, annee_scolaire=scu.annee_scolaire()
2020-09-26 16:19:37 +02:00
),
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)
2020-09-26 16:19:37 +02:00
def sem_has_etape(sem, code_etape):
return code_etape in sem["etapes"]