ScoDoc/app/scodoc/sco_report.py

1812 lines
61 KiB
Python
Raw Normal View History

2020-09-26 16:19:37 +02:00
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
2023-12-31 23:04:06 +01:00
# Copyright (c) 1999 - 2024 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
#
##############################################################################
"""Rapports suivi:
- statistiques decisions
- suivi cohortes
"""
import collections
2021-01-10 18:05:20 +01:00
import os
import tempfile
import re
import time
2021-02-05 18:21:34 +01:00
import datetime
2021-07-09 23:19:30 +02:00
from operator import itemgetter
2020-09-26 16:19:37 +02:00
2024-08-24 14:39:19 +02:00
from flask import url_for, g, render_template, request
import pydot
from app import log
from app.but import jury_but
2022-02-13 15:50:16 +01:00
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
2022-07-07 16:24:52 +02:00
from app.models import FormSemestre, ScolarAutorisationInscription
from app.models import FormationModalite
from app.models.etudiants import Identite
2022-02-13 15:50:16 +01:00
from app.scodoc import (
codes_cursus,
sco_etud,
sco_formsemestre,
sco_formsemestre_inscriptions,
sco_groups_view,
sco_preferences,
)
from app.scodoc.gen_tables import GenTable
from app.scodoc.codes_cursus import code_semestre_validant
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import notesdb as ndb
import app.scodoc.sco_utils as scu
import sco_version
2020-09-26 16:19:37 +02:00
MAX_ETUD_IN_DESCR = 20
LEGENDES_CODES_BUT = {
"Nb_rcue_valides": "nb RCUE validés",
"decision_annee": "code jury annuel BUT",
}
2020-09-26 16:19:37 +02:00
def formsemestre_etuds_stats(
formsemestre: FormSemestre,
only_primo=False,
groups_infos: sco_groups_view.DisplayedGroupsInfos | None = None,
):
"""Récupère liste d'etudiants avec etat et decision."""
2022-02-13 15:50:16 +01:00
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
etudids = groups_infos.get_etudids() if groups_infos else set()
rows = nt.get_table_moyennes_triees()
# Décisions de jury BUT pour les semestres pairs seulement
jury_but_mode = (
formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0
)
2020-09-26 16:19:37 +02:00
# Construit liste d'étudiants du semestre avec leur decision
etuds = []
for t in rows:
2020-09-26 16:19:37 +02:00
etudid = t[-1]
if etudids and etudid not in etudids:
continue
etudiant = Identite.get_etud(etudid)
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
2023-02-22 00:28:33 +01:00
etud["annee_admission"] = etud["annee"] # plus explicite
2020-09-26 16:19:37 +02:00
decision = nt.get_etud_decision_sem(etudid)
if decision:
etud["codedecision"] = decision["code"]
etud["etat"] = nt.get_etud_etat(etudid)
if etud["etat"] == "D":
etud["codedecision"] = "DEM"
2021-07-09 17:47:06 +02:00
if "codedecision" not in etud:
2020-09-26 16:19:37 +02:00
etud["codedecision"] = "(nd)" # pas de decision jury
# Ajout devenir (autorisations inscriptions), utile pour stats passage
2022-07-07 16:24:52 +02:00
aut_list = ScolarAutorisationInscription.query.filter_by(
etudid=etudid, origin_formsemestre_id=formsemestre.id
2022-07-07 16:24:52 +02:00
).all()
autorisations = [f"S{a.semestre_id}" for a in aut_list]
2020-09-26 16:19:37 +02:00
autorisations.sort()
autorisations_str = ", ".join(autorisations)
etud["devenir"] = autorisations_str
# Décisions de jury BUT (APC)
if jury_but_mode:
deca = jury_but.DecisionsProposeesAnnee(etudiant, formsemestre)
etud["nb_rcue_valides"] = deca.nb_rcue_valides
etud["decision_annee"] = deca.code_valide
2020-09-26 16:19:37 +02:00
# Ajout clé 'bac-specialite'
bs = []
if etud["bac"]:
bs.append(etud["bac"])
if etud["specialite"]:
bs.append(etud["specialite"])
etud["bac-specialite"] = " ".join(bs)
#
if (not only_primo) or is_primo_etud(etud, formsemestre):
2020-09-26 16:19:37 +02:00
etuds.append(etud)
return etuds
def is_primo_etud(etud: dict, formsemestre: FormSemestre):
"""Determine si un (filled) etud a été inscrit avant ce semestre.
Regarde la liste des semestres dans lesquels l'étudiant est inscrit.
Si semestre pair, considère comme primo-entrants ceux qui étaient
primo dans le précédent (S_{2n-1}).
"""
debut_cur_iso = formsemestre.date_debut.isoformat()
# si semestre impair et sem. précédent contigu, recule date debut
if (
(len(etud["sems"]) > 1)
and (formsemestre.semestre_id % 2 == 0)
and (etud["sems"][1]["semestre_id"] == (formsemestre.semestre_id - 1))
):
debut_cur_iso = etud["sems"][1]["date_debut_iso"]
for s in etud["sems"]: # le + recent d'abord
if s["date_debut_iso"] < debut_cur_iso:
return False
return True
2020-09-26 16:19:37 +02:00
def _categories_and_results(etuds, category, result):
categories = {}
results = {}
for etud in etuds:
categories[etud[category]] = True
results[etud[result]] = True
2021-07-09 17:47:06 +02:00
categories = list(categories.keys())
2022-01-05 01:03:25 +01:00
categories.sort(key=scu.heterogeneous_sorting_key)
2021-07-09 17:47:06 +02:00
results = list(results.keys())
2022-01-05 01:03:25 +01:00
results.sort(key=scu.heterogeneous_sorting_key)
2020-09-26 16:19:37 +02:00
return categories, results
def _results_by_category(
etuds,
category="",
result="",
category_name=None,
formsemestre_id=None,
):
"""Construit table: categories (eg types de bacs) en ligne, décisions jury en colonnes
etuds est une liste d'etuds (dicts).
category et result sont des clés de etud (category définie les lignes, result les colonnes).
Retourne une table.
"""
if category_name is None:
category_name = category
# types de bacs differents:
categories, results = _categories_and_results(etuds, category, result)
#
Count = {} # { bac : { decision : nb_avec_ce_bac_et_ce_code } }
results = {} # { result_value : True }
for etud in etuds:
results[etud[result]] = True
2021-07-09 17:47:06 +02:00
if etud[category] in Count:
2020-09-26 16:19:37 +02:00
Count[etud[category]][etud[result]] += 1
else:
Count[etud[category]] = collections.defaultdict(int, {etud[result]: 1})
2020-09-26 16:19:37 +02:00
# conversion en liste de dict
C = [Count[cat] for cat in categories]
# Totaux par lignes et colonnes
tot = 0
for l in [Count[cat] for cat in categories]:
l["sum"] = sum(l.values())
tot += l["sum"]
# pourcentages sur chaque total de ligne
for l in C:
l["sumpercent"] = "%2.1f%%" % ((100.0 * l["sum"]) / tot)
#
2021-07-09 17:47:06 +02:00
codes = list(results.keys())
2022-01-05 01:03:25 +01:00
codes.sort(key=scu.heterogeneous_sorting_key)
2020-09-26 16:19:37 +02:00
bottom_titles = []
if C: # ligne du bas avec totaux:
bottom_titles = {}
for code in codes:
bottom_titles[code] = sum([l[code] for l in C])
bottom_titles["sum"] = tot
bottom_titles["sumpercent"] = "100%"
bottom_titles["row_title"] = "Total"
# ajout titre ligne:
for cat, l in zip(categories, C):
l["row_title"] = cat if cat is not None else "?"
2020-09-26 16:19:37 +02:00
#
codes.append("sum")
codes.append("sumpercent")
# on veut { ADM : ADM, ... }
2020-09-26 16:19:37 +02:00
titles = {x: x for x in codes}
# sauf pour
titles.update(LEGENDES_CODES_BUT)
2020-09-26 16:19:37 +02:00
titles["sum"] = "Total"
titles["sumpercent"] = "%"
titles["DEM"] = "Dém." # démissions
titles["row_title"] = titles.get(category_name, category_name)
2020-09-26 16:19:37 +02:00
return GenTable(
titles=titles,
columns_ids=codes,
rows=C,
bottom_titles=bottom_titles,
html_col_width="4em",
html_sortable=True,
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id=f"results_by_category-{category_name}",
2020-09-26 16:19:37 +02:00
)
# pages
def formsemestre_report(
formsemestre_id,
etuds,
category="bac",
result="codedecision",
category_name="",
result_name="",
):
"""
Tableau sur résultats (result) par type de category bac
"""
2021-08-19 10:28:35 +02:00
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
2020-09-26 16:19:37 +02:00
if not category_name:
category_name = category
if not result_name:
result_name = result
if result_name == "codedecision":
result_name = "résultats"
#
tab = _results_by_category(
etuds,
category=category,
category_name=category_name,
result=result,
formsemestre_id=formsemestre_id,
)
#
2021-01-10 18:05:20 +01:00
tab.filename = scu.make_filename("stats " + sem["titreannee"])
2020-09-26 16:19:37 +02:00
tab.origin = f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}"
tab.caption = (
f"Répartition des résultats par {category_name}, semestre {sem['titreannee']}"
2020-09-26 16:19:37 +02:00
)
tab.html_caption = f"Répartition des résultats par {category_name}."
2020-09-26 16:19:37 +02:00
return tab
def formsemestre_report_counts(
formsemestre_id: int,
fmt="html",
category: str = "bac",
result: str = None,
allkeys: bool = False,
only_primo: bool = False,
group_ids: list[int] = None, # si indiqué, ne prend que ces groupes
2020-09-26 16:19:37 +02:00
):
"""
Tableau comptage avec choix des categories
category: attribut en lignes
result: attribut en colonnes
only_primo: restreint aux primo-entrants (= non redoublants)
allkeys: pour le menu du choix de l'attribut en colonnes:
si vrai, toutes les valeurs présentes dans les données
sinon liste prédéfinie (voir ci-dessous)
2020-09-26 16:19:37 +02:00
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
2024-08-25 07:23:36 +02:00
if request.method == "POST":
group_ids = request.form.getlist("group_ids")
else:
group_ids = request.args.getlist("group_ids")
try:
group_ids = [int(gid) for gid in group_ids]
except ValueError as exc:
raise ScoValueError("group_ids invalide") from exc
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids,
formsemestre_id=formsemestre.id,
select_all_when_unspecified=True,
)
# Décisions de jury BUT pour les semestres pairs seulement
jury_but_mode = (
formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0
)
if result is None:
result = "statut" if formsemestre.formation.is_apc() else "codedecision"
category_name = category.capitalize()
2020-09-26 16:19:37 +02:00
title = "Comptages " + category_name
etuds = formsemestre_etuds_stats(
formsemestre, groups_infos=groups_infos, only_primo=only_primo
)
2020-09-26 16:19:37 +02:00
tab = formsemestre_report(
formsemestre_id,
etuds,
category=category,
result=result,
category_name=category_name,
)
tab.base_url = url_for(
"notes.formsemestre_report_counts",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
category=category,
only_primo=int(bool(only_primo)),
result=result,
group_ids=group_ids,
2020-09-26 16:19:37 +02:00
)
if len(formsemestre.inscriptions) == 0:
2020-09-26 16:19:37 +02:00
F = ["""<p><em>Aucun étudiant</em></p>"""]
else:
if allkeys:
2021-07-09 17:47:06 +02:00
keys = list(etuds[0].keys())
2020-09-26 16:19:37 +02:00
else:
# clés présentées à l'utilisateur:
keys = [
"annee_bac",
"annee_naissance",
"bac",
"specialite",
"bac-specialite",
"codedecision",
"devenir",
"etat",
"civilite",
2020-09-26 16:19:37 +02:00
"qualite",
"villelycee",
"statut",
2023-02-22 00:28:33 +01:00
"annee_admission",
2020-09-26 16:19:37 +02:00
"type_admission",
"boursier",
2020-09-26 16:19:37 +02:00
"boursier_prec",
]
if jury_but_mode:
keys += ["nb_rcue_valides", "decision_annee"]
2022-01-05 01:03:25 +01:00
keys.sort(key=scu.heterogeneous_sorting_key)
2020-09-26 16:19:37 +02:00
F = [
f"""<form id="group_selector" name="f" method="get" action="{request.base_url}">
Colonnes:
<select name="result" onchange="document.f.submit()">
"""
2020-09-26 16:19:37 +02:00
]
for k in keys:
if k == result:
selected = "selected"
else:
selected = ""
F.append(
'<option value="%s" %s>%s</option>'
% (k, selected, LEGENDES_CODES_BUT.get(k, k))
)
2020-09-26 16:19:37 +02:00
F.append("</select>")
F.append(' Lignes: <select name="category" onchange="document.f.submit()">')
for k in keys:
if k == category:
selected = "selected"
else:
selected = ""
F.append(
'<option value="%s" %s>%s</option>'
% (k, selected, LEGENDES_CODES_BUT.get(k, k))
)
2020-09-26 16:19:37 +02:00
F.append(
f"""
</select>
<div style="margin-top:12px;">
<input type="checkbox" name="only_primo" onchange="document.f.submit()"
{'checked' if only_primo else ''}>
Restreindre aux primo-entrants</input>
<span style="margin: 12px;">
Restreindre au(x) groupe(s)&nbsp;:
{sco_groups_view.menu_groups_choice(groups_infos, submit_on_change=True)
if groups_infos else ''}
</span>
<input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
</div>
</form>
"""
2020-09-26 16:19:37 +02:00
)
tableau = tab.make_page(
fmt=fmt,
title="""<h2 class="formsemestre">Comptes croisés</h2>""",
2020-09-26 16:19:37 +02:00
with_html_headers=False,
)
if fmt != "html":
return tableau
2020-09-26 16:19:37 +02:00
H = [
tableau,
2020-09-26 16:19:37 +02:00
"\n".join(F),
"""<p class="help">Le tableau affiche le nombre d'étudiants de ce semestre dans chacun
des cas choisis: à l'aide des deux menus, vous pouvez choisir les catégories utilisées
2023-12-31 23:04:06 +01:00
pour les lignes et les colonnes. Le <tt>codedecision</tt> est le code de la décision
2020-09-26 16:19:37 +02:00
du jury.
</p>""",
]
2024-08-24 14:39:19 +02:00
return render_template(
"sco_page.j2",
2024-08-25 07:23:36 +02:00
javascripts=["js/groups_view.js"],
2024-08-24 14:39:19 +02:00
title=title,
content="\n".join(H),
)
2020-09-26 16:19:37 +02:00
# --------------------------------------------------------------------------
def table_suivi_cohorte(
formsemestre: FormSemestre,
groups_infos,
2020-09-26 16:19:37 +02:00
percent=False,
bac="", # selection sur type de bac
bacspecialite="",
annee_bac="",
2023-02-22 00:28:33 +01:00
annee_admission="",
civilite=None,
2020-09-26 16:19:37 +02:00
statut="",
only_primo=False,
):
"""
Tableau indiquant le nombre d'etudiants de la cohorte dans chaque état:
2020-09-26 16:19:37 +02:00
Etat date_debut_Sn date1 date2 ...
S_n #inscrits en Sn
S_n+1
...
S_last
Diplome
Sorties
Determination des dates: on regroupe les semestres commençant à des dates proches
"""
sem = sco_formsemestre.get_formsemestre(formsemestre.id)
# sem est le semestre origine
2020-09-26 16:19:37 +02:00
t0 = time.time()
def logt(op):
if 0: # debug, set to 0 in production
log("%s: %s" % (op, time.time() - t0))
logt("table_suivi_cohorte: start")
# 1-- Liste des semestres posterieurs dans lesquels ont été les etudiants de sem
2022-02-13 15:50:16 +01:00
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
etudids_inscrits = {ins.etudid for ins in formsemestre.inscriptions}
etudids_groups = groups_infos.get_etudids()
etudids = etudids_inscrits.intersection(etudids_groups)
2020-09-26 16:19:37 +02:00
logt("A: orig etuds set")
S = {formsemestre.id: sem} # ensemble de formsemestre_id
orig_set = set() # ensemble d'etudid du semestre d'origine
bacs = set()
bacspecialites = set()
annee_bacs = set()
2023-02-22 00:28:33 +01:00
annee_admissions = set()
civilites = set()
statuts = set()
2020-09-26 16:19:37 +02:00
for etudid in etudids:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
2023-02-22 00:28:33 +01:00
etud["annee_admission"] = etud["annee"]
bacspe = (etud["bac"] or "?") + " / " + (etud["specialite"] or "")
2020-09-26 16:19:37 +02:00
# sélection sur bac:
if (
(not bac or (bac == etud["bac"]))
and (not bacspecialite or (bacspecialite == bacspe))
and (not annee_bac or (annee_bac == str(etud["annee_bac"])))
2023-02-22 00:28:33 +01:00
and (
not annee_admission or (annee_admission == str(etud["annee_admission"]))
)
and (not civilite or (civilite == etud["civilite"]))
2020-09-26 16:19:37 +02:00
and (not statut or (statut == etud["statut"]))
and (not only_primo or is_primo_etud(etud, formsemestre))
2020-09-26 16:19:37 +02:00
):
orig_set.add(etudid)
# semestres suivants:
for s in etud["sems"]:
if ndb.DateDMYtoISO(s["date_debut"]) > ndb.DateDMYtoISO(
sem["date_debut"]
):
2020-09-26 16:19:37 +02:00
S[s["formsemestre_id"]] = s
if etud.get("bac", False):
bacs.add(etud["bac"])
bacspecialites.add(bacspe)
annee_bacs.add(str(etud["annee_bac"]))
if etud["annee_admission"] is not None:
annee_admissions.add(str(etud["annee_admission"]))
civilites.add(etud["civilite"])
2020-09-26 16:19:37 +02:00
if etud["statut"]: # ne montre pas les statuts non renseignés
statuts.add(etud["statut"])
2021-07-09 17:47:06 +02:00
sems = list(S.values())
2020-09-26 16:19:37 +02:00
# tri les semestres par date de debut
for s in sems:
d, m, y = [int(x) for x in s["date_debut"].split("/")]
2021-02-05 18:21:34 +01:00
s["date_debut_dt"] = datetime.datetime(y, m, d)
2021-07-09 23:19:30 +02:00
sems.sort(key=itemgetter("date_debut_dt"))
2020-09-26 16:19:37 +02:00
# 2-- Pour chaque semestre, trouve l'ensemble des etudiants venant de sem
logt("B: etuds sets")
sem["members"] = orig_set
for s in sems:
ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
2021-08-19 10:28:35 +02:00
args={"formsemestre_id": s["formsemestre_id"]}
) # sans dems
inset = set([i["etudid"] for i in ins])
2020-09-26 16:19:37 +02:00
s["members"] = orig_set.intersection(inset)
nb_dipl = 0 # combien de diplomes dans ce semestre ?
if s["semestre_id"] == nt.parcours.NB_SEM:
s_formsemestre = FormSemestre.get_or_404(s["formsemestre_id"])
2022-02-13 15:50:16 +01:00
nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre)
2020-09-26 16:19:37 +02:00
for etudid in s["members"]:
dec = nt.get_etud_decision_sem(etudid)
if dec and code_semestre_validant(dec["code"]):
nb_dipl += 1
s["nb_dipl"] = nb_dipl
# 3-- Regroupe les semestres par date de debut
class PeriodSem:
def __init__(self, datedebut: datetime.datetime, sems: list[dict]):
self.datedebut = datedebut
self.sems = sems
2020-09-26 16:19:37 +02:00
# semestre de depart:
d, m, y = [int(x) for x in sem["date_debut"].split("/")]
porigin = PeriodSem(datetime.datetime(y, m, d), [sem])
P = [] # liste de periodsem
2020-09-26 16:19:37 +02:00
#
2021-02-05 18:21:34 +01:00
tolerance = datetime.timedelta(days=45)
2020-09-26 16:19:37 +02:00
for s in sems:
merged = False
for p in P:
2021-02-05 18:21:34 +01:00
if abs(s["date_debut_dt"] - p.datedebut) < tolerance:
2020-09-26 16:19:37 +02:00
p.sems.append(s)
merged = True
break
if not merged:
p = PeriodSem(s["date_debut_dt"], [s])
2020-09-26 16:19:37 +02:00
P.append(p)
# 4-- regroupe par indice de semestre S_i
2022-01-05 01:03:25 +01:00
indices_sems = list({s["semestre_id"] for s in sems})
2020-09-26 16:19:37 +02:00
indices_sems.sort()
for p in P:
p.nb_etuds = 0 # nombre total d'etudiants dans la periode
p.sems_by_id = collections.defaultdict(list)
2020-09-26 16:19:37 +02:00
for s in p.sems:
p.sems_by_id[s["semestre_id"]].append(s)
p.nb_etuds += len(s["members"])
# 5-- Contruit table
logt("C: build table")
nb_initial = len(sem["members"])
def fmtval(x):
if not x:
return "" # ne montre pas les 0
if percent:
return "%2.1f%%" % (100.0 * x / nb_initial)
else:
return x
L = [
{
"row_title": "Origine: S%s" % sem["semestre_id"],
porigin.datedebut: nb_initial,
"_css_row_class": "sorttop",
}
]
if nb_initial <= MAX_ETUD_IN_DESCR:
etud_descr = _descr_etud_set(sem["members"])
2020-09-26 16:19:37 +02:00
L[0]["_%s_help" % porigin.datedebut] = etud_descr
for idx_sem in indices_sems:
if idx_sem >= 0:
d = {"row_title": "S%s" % idx_sem}
else:
d = {"row_title": "Autre semestre"}
for p in P:
etuds_period = set()
2020-09-26 16:19:37 +02:00
for s in p.sems:
if s["semestre_id"] == idx_sem:
etuds_period = etuds_period.union(s["members"])
nbetuds = len(etuds_period)
if nbetuds:
d[p.datedebut] = fmtval(nbetuds)
if nbetuds <= MAX_ETUD_IN_DESCR: # si peu d'etudiants, indique la liste
etud_descr = _descr_etud_set(etuds_period)
2020-09-26 16:19:37 +02:00
d["_%s_help" % p.datedebut] = etud_descr
L.append(d)
# Compte nb de démissions et de ré-orientation par période
logt("D: cout dems reos")
sem["dems"], sem["reos"] = _count_dem_reo(formsemestre.id, sem["members"])
2020-09-26 16:19:37 +02:00
for p in P:
p.dems = set()
p.reos = set()
2020-09-26 16:19:37 +02:00
for s in p.sems:
d, r = _count_dem_reo(s["formsemestre_id"], s["members"])
2020-09-26 16:19:37 +02:00
p.dems.update(d)
p.reos.update(r)
# Nombre total d'etudiants par periode
l = {
"row_title": "Inscrits",
"row_title_help": "Nombre d'étudiants inscrits",
"_table_part": "foot",
porigin.datedebut: fmtval(nb_initial),
}
for p in P:
l[p.datedebut] = fmtval(p.nb_etuds)
L.append(l)
# Nombre de démissions par période
l = {
"row_title": "Démissions",
"row_title_help": "Nombre de démissions pendant la période",
"_table_part": "foot",
porigin.datedebut: fmtval(len(sem["dems"])),
}
if len(sem["dems"]) <= MAX_ETUD_IN_DESCR:
etud_descr = _descr_etud_set(sem["dems"])
2020-09-26 16:19:37 +02:00
l["_%s_help" % porigin.datedebut] = etud_descr
for p in P:
l[p.datedebut] = fmtval(len(p.dems))
if len(p.dems) <= MAX_ETUD_IN_DESCR:
etud_descr = _descr_etud_set(p.dems)
2020-09-26 16:19:37 +02:00
l["_%s_help" % p.datedebut] = etud_descr
L.append(l)
# Nombre de réorientations par période
l = {
"row_title": "Echecs",
"row_title_help": "Ré-orientations (décisions NAR)",
"_table_part": "foot",
porigin.datedebut: fmtval(len(sem["reos"])),
}
if len(sem["reos"]) < 10:
etud_descr = _descr_etud_set(sem["reos"])
2020-09-26 16:19:37 +02:00
l["_%s_help" % porigin.datedebut] = etud_descr
for p in P:
l[p.datedebut] = fmtval(len(p.reos))
if len(p.reos) <= MAX_ETUD_IN_DESCR:
etud_descr = _descr_etud_set(p.reos)
2020-09-26 16:19:37 +02:00
l["_%s_help" % p.datedebut] = etud_descr
L.append(l)
# derniere ligne: nombre et pourcentage de diplomes
l = {
"row_title": "Diplômes",
"row_title_help": "Nombre de diplômés à la fin de la période",
"_table_part": "foot",
}
for p in P:
nb_dipl = 0
for s in p.sems:
nb_dipl += s["nb_dipl"]
l[p.datedebut] = fmtval(nb_dipl)
L.append(l)
columns_ids = [p.datedebut for p in P]
titles = dict([(p.datedebut, p.datedebut.strftime(scu.DATE_FMT)) for p in P])
titles[porigin.datedebut] = porigin.datedebut.strftime(scu.DATE_FMT)
2020-09-26 16:19:37 +02:00
if percent:
pp = "(en % de la population initiale) "
titles["row_title"] = "%"
else:
pp = ""
titles["row_title"] = ""
if only_primo:
pp += "(restreint aux primo-entrants) "
if bac:
dbac = " (bacs %s)" % bac
else:
dbac = ""
if bacspecialite:
dbac += " (spécialité %s)" % bacspecialite
if annee_bac:
dbac += " (année bac %s)" % annee_bac
2023-02-22 00:28:33 +01:00
if annee_admission:
dbac += " (année admission %s)" % annee_admission
if civilite:
dbac += " civilité: %s" % civilite
2020-09-26 16:19:37 +02:00
if statut:
dbac += " statut: %s" % statut
tab = GenTable(
caption="Suivi cohorte " + pp + sem["titreannee"] + dbac,
2020-09-26 16:19:37 +02:00
columns_ids=columns_ids,
filename=scu.make_filename("cohorte " + sem["titreannee"]),
html_class="table_cohorte",
2020-09-26 16:19:37 +02:00
html_col_width="4em",
html_sortable=True,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
2020-09-26 16:19:37 +02:00
page_title="Suivi cohorte " + sem["titreannee"],
preferences=sco_preferences.SemPreferences(formsemestre.id),
rows=L,
table_id="table_suivi_cohorte",
titles=titles,
2020-09-26 16:19:37 +02:00
)
# Explication: liste des semestres associés à chaque date
if not P:
expl = [
'<p class="help">(aucun étudiant trouvé dans un semestre ultérieur)</p>'
]
else:
expl = ["<h3>Semestres associés à chaque date:</h3><ul>"]
for p in P:
expl.append(f"""<li><b>{p.datedebut.strftime(scu.DATE_FMT)}</b>:""")
2020-09-26 16:19:37 +02:00
ls = []
for s in p.sems:
ls.append(formsemestre.html_link_status())
2020-09-26 16:19:37 +02:00
expl.append(", ".join(ls) + "</li>")
expl.append("</ul>")
return (
tab,
"\n".join(expl),
bacs,
bacspecialites,
annee_bacs,
2023-02-22 00:28:33 +01:00
annee_admissions,
civilites,
statuts,
)
2020-09-26 16:19:37 +02:00
def formsemestre_suivi_cohorte(
formsemestre_id,
fmt="html",
2020-09-26 16:19:37 +02:00
percent=1,
bac="",
bacspecialite="",
annee_bac="",
2023-02-22 00:28:33 +01:00
annee_admission="",
civilite=None,
2020-09-26 16:19:37 +02:00
statut="",
only_primo=False,
) -> str:
"""Affiche suivi cohortes par numero de semestre"""
try:
annee_bac = str(annee_bac or "")
annee_admission = str(annee_admission or "")
percent = int(percent)
except ValueError as exc:
raise ScoValueError("formsemestre_suivi_cohorte: argument invalide") from exc
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
2024-08-25 07:23:36 +02:00
if request.method == "POST":
group_ids = request.form.getlist("group_ids")
else:
group_ids = request.args.getlist("group_ids")
try:
group_ids = [int(gid) for gid in group_ids]
except ValueError as exc:
raise ScoValueError("group_ids invalide") from exc
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids,
formsemestre_id=formsemestre.id,
select_all_when_unspecified=True,
)
(
tab,
expl,
bacs,
bacspecialites,
annee_bacs,
2023-02-22 00:28:33 +01:00
annee_admissions,
civilites,
statuts,
) = table_suivi_cohorte(
formsemestre,
groups_infos=groups_infos,
2020-09-26 16:19:37 +02:00
percent=percent,
bac=bac,
bacspecialite=bacspecialite,
annee_bac=annee_bac,
2023-02-22 00:28:33 +01:00
annee_admission=annee_admission,
civilite=civilite,
2020-09-26 16:19:37 +02:00
statut=statut,
only_primo=only_primo,
)
tab.base_url = (
2021-05-11 11:48:32 +02:00
"%s?formsemestre_id=%s&percent=%s&bac=%s&bacspecialite=%s&civilite=%s"
% (request.base_url, formsemestre.id, percent, bac, bacspecialite, civilite)
2020-09-26 16:19:37 +02:00
)
if only_primo:
2021-05-11 11:48:32 +02:00
tab.base_url += "&only_primo=on"
t = tab.make_page(fmt=fmt, with_html_headers=False)
if fmt != "html":
2020-09-26 16:19:37 +02:00
return t
base_url = request.base_url
burl = "%s?formsemestre_id=%s&bac=%s&bacspecialite=%s&civilite=%s&statut=%s" % (
base_url,
formsemestre.id,
bac,
bacspecialite,
civilite,
statut,
2020-09-26 16:19:37 +02:00
)
if percent:
pplink = f"""<p><a class="stdlink"
href="{burl}&percent=0">Afficher les résultats bruts</a></p>"""
2020-09-26 16:19:37 +02:00
else:
pplink = f"""<p><a class="stdlink"
href="{burl}&percent=1">Afficher les résultats en pourcentages</a></p>"""
2020-09-26 16:19:37 +02:00
H = [
"""<h2 class="formsemestre">Suivi cohorte: devenir des étudiants de ce semestre</h2>""",
_gen_form_selectetuds(
formsemestre.id,
groups_infos=groups_infos,
2020-09-26 16:19:37 +02:00
only_primo=only_primo,
bac=bac,
bacspecialite=bacspecialite,
annee_bac=annee_bac,
2023-02-22 00:28:33 +01:00
annee_admission=annee_admission,
civilite=civilite,
2020-09-26 16:19:37 +02:00
statut=statut,
bacs=bacs,
bacspecialites=bacspecialites,
annee_bacs=annee_bacs,
2023-02-22 00:28:33 +01:00
annee_admissions=annee_admissions,
civilites=civilites,
2020-09-26 16:19:37 +02:00
statuts=statuts,
percent=percent,
),
t,
f"""{pplink}
<p class="help">Nombre d'étudiants dans chaque semestre.
Les dates indiquées sont les dates approximatives de <b>début</b> des semestres
(les semestres commençant à des dates proches sont groupés). Le nombre de diplômés
est celui à la <b>fin</b> du semestre correspondant.
Lorsqu'il y a moins de {MAX_ETUD_IN_DESCR} étudiants dans une case, vous pouvez
afficher leurs noms en passant le curseur sur le chiffre.
</p>
<p class="help">Les menus permettent de n'étudier que certaines catégories
d'étudiants (titulaires d'un type de bac, garçons ou filles).
La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants
qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré.
</p>
""",
2020-09-26 16:19:37 +02:00
expl,
]
2024-08-24 14:39:19 +02:00
return render_template(
"sco_page.j2",
2024-08-25 07:23:36 +02:00
javascripts=["js/groups_view.js"],
2024-08-24 14:39:19 +02:00
title=tab.page_title,
content="\n".join(H),
)
2020-09-26 16:19:37 +02:00
def _gen_form_selectetuds(
formsemestre_id,
percent=None,
only_primo=None,
bac=None,
bacspecialite=None,
annee_bac=None,
2023-02-22 00:28:33 +01:00
annee_admission=None,
civilite=None,
2020-09-26 16:19:37 +02:00
statut=None,
bacs=None,
bacspecialites=None,
annee_bacs=None,
2023-02-22 00:28:33 +01:00
annee_admissions=None,
civilites=None,
2020-09-26 16:19:37 +02:00
statuts=None,
groups_infos: sco_groups_view.DisplayedGroupsInfos = None,
2020-09-26 16:19:37 +02:00
):
"""HTML form pour choix criteres selection etudiants"""
2023-02-22 00:28:33 +01:00
annee_bacs = annee_bacs or []
annee_admissions = annee_admissions or []
2020-09-26 16:19:37 +02:00
bacs = list(bacs)
2022-01-05 01:03:25 +01:00
bacs.sort(key=scu.heterogeneous_sorting_key)
2020-09-26 16:19:37 +02:00
bacspecialites = list(bacspecialites)
2022-01-05 01:03:25 +01:00
bacspecialites.sort(key=scu.heterogeneous_sorting_key)
2021-08-21 17:37:38 +02:00
# on peut avoir un mix de chaines vides et d'entiers:
annee_bacs = [int(x) if x else 0 for x in annee_bacs]
annee_bacs.sort()
2023-02-22 00:28:33 +01:00
annee_admissions = [int(x) if x else 0 for x in annee_admissions]
annee_admissions.sort()
civilites = list(civilites)
civilites.sort()
2020-09-26 16:19:37 +02:00
statuts = list(statuts)
statuts.sort()
#
if bac:
selected = ""
else:
selected = 'selected="selected"'
F = [
f"""<form id="group_selector" name="f" method="get" action="{request.base_url}">
<div>Bac:
<select name="bac" onchange="javascript: submit(this);">
<option value="" {selected}>tous</option>
2020-09-26 16:19:37 +02:00
"""
]
for b in bacs:
if bac == b:
selected = 'selected="selected"'
else:
selected = ""
F.append(f'<option value="{b}" {selected}>{b}</option>')
2020-09-26 16:19:37 +02:00
F.append("</select>")
if bacspecialite:
selected = ""
else:
selected = 'selected="selected"'
F.append(
f"""&nbsp; Bac/Specialité:
<select name="bacspecialite" onchange="javascript: submit(this);">
<option value="" {selected}>tous</option>
2020-09-26 16:19:37 +02:00
"""
)
for b in bacspecialites:
if bacspecialite == b:
selected = 'selected="selected"'
else:
selected = ""
F.append(f'<option value="{b}" {selected}>{b}</option>')
2020-09-26 16:19:37 +02:00
F.append("</select>")
#
F.append(
2023-02-22 00:28:33 +01:00
"&nbsp; Année bac: " + _gen_select_annee("annee_bac", annee_bacs, annee_bac)
)
F.append(
"&nbsp; Année admission: "
+ _gen_select_annee("annee_admission", annee_admissions, annee_admission)
)
#
2020-09-26 16:19:37 +02:00
F.append(
f"""&nbsp; Genre: <select name="civilite" onchange="javascript: submit(this);">
<option value="" {selected}>tous</option>
2020-09-26 16:19:37 +02:00
"""
)
for b in civilites:
if civilite == b:
2020-09-26 16:19:37 +02:00
selected = 'selected="selected"'
else:
selected = ""
F.append(f'<option value="{b}" {selected}>{b}</option>')
2020-09-26 16:19:37 +02:00
F.append("</select>")
F.append(
f"""&nbsp; Statut: <select name="statut" onchange="javascript: submit(this);">
<option value="" {selected}>tous</option>
2020-09-26 16:19:37 +02:00
"""
)
for b in statuts:
if statut == b:
selected = 'selected="selected"'
else:
selected = ""
F.append(f'<option value="{b}" {selected}>{b}</option>')
2020-09-26 16:19:37 +02:00
F.append(
f"""
</select>
<div style="margin-top:12px;">
<input type="checkbox" name="only_primo"
onchange="javascript: submit(this);"
{'checked="1"' if only_primo else ""}/>Restreindre aux primo-entrants
<span style="margin: 12px;">
Restreindre au(x) groupe(s)&nbsp;:
{sco_groups_view.menu_groups_choice(groups_infos, submit_on_change=True)
if groups_infos else ''}
</span>
<input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
<input type="hidden" name="percent" value="{percent}"/>
</div>
</div>
</form>
"""
2020-09-26 16:19:37 +02:00
)
2020-09-26 16:19:37 +02:00
return "\n".join(F)
2023-02-22 00:28:33 +01:00
def _gen_select_annee(field, values, value) -> str:
"une menu html select"
menu_html = f"""<select name="{field}" onchange="javascript: submit(this);">
<option value="" {'' if value else 'selected="selected"'}>tous</option>
"""
for val in values:
selected = 'selected="selected"' if str(value) == str(val) else ""
menu_html += f"""<option value="{val}" {selected}>{val}</option>"""
return menu_html + "</select>"
def _descr_etud_set(etudids) -> str:
2020-09-26 16:19:37 +02:00
"textual html description of a set of etudids"
etuds = []
for etudid in etudids:
2023-02-22 00:28:33 +01:00
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
etud["annee_admission"] = etud["annee"] # plus explicite
etuds.append(etud)
2020-09-26 16:19:37 +02:00
# sort by name
2021-07-10 13:58:25 +02:00
etuds.sort(key=itemgetter("nom"))
2020-09-26 16:19:37 +02:00
return ", ".join([e["nomprenom"] for e in etuds])
def _count_dem_reo(formsemestre_id, etudids):
2020-09-26 16:19:37 +02:00
"count nb of demissions and reorientation in this etud set"
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
2022-02-13 15:50:16 +01:00
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
validations_annuelles = nt.get_validations_annee() if nt.is_apc else {}
dems = set()
reos = set()
2020-09-26 16:19:37 +02:00
for etudid in etudids:
if nt.get_etud_etat(etudid) == "D":
dems.add(etudid)
if nt.is_apc:
# BUT: utilise les validations annuelles
validation = validations_annuelles.get(etudid)
if validation and validation.code in codes_cursus.CODES_SEM_REO:
reos.add(etudid)
else:
# Autres formations: validations de semestres
dec = nt.get_etud_decision_sem(etudid)
if dec and dec["code"] in codes_cursus.CODES_SEM_REO:
reos.add(etudid)
2020-09-26 16:19:37 +02:00
return dems, reos
EXP_LIC = re.compile(r"licence", re.I)
EXP_LPRO = re.compile(r"professionnelle", re.I)
def _code_sem(
semestre_id: int, titre: str, mois_debut: int, short=True, prefix=""
) -> str:
2020-09-26 16:19:37 +02:00
"code semestre: S1 ou S1d"
idx = semestre_id
2020-09-26 16:19:37 +02:00
# semestre décalé ?
# les semestres pairs normaux commencent entre janvier et mars
# les impairs normaux entre aout et decembre
d = ""
if idx > 0:
2020-09-26 16:19:37 +02:00
if (idx % 2 and mois_debut < 3) or (idx % 2 == 0 and mois_debut >= 8):
d = "d"
if idx == -1:
if short:
idx = "Autre "
else:
idx = titre + " "
2020-09-26 16:19:37 +02:00
idx = EXP_LPRO.sub("pro.", idx)
idx = EXP_LIC.sub("Lic.", idx)
prefix = "" # indique titre au lieu de Sn
return prefix + str(idx) + d
2020-09-26 16:19:37 +02:00
def _code_sem_formsemestre(formsemestre: FormSemestre, short=True, prefix="") -> str:
"code semestre: S1 ou S1d"
titre = formsemestre.titre
mois_debut = formsemestre.date_debut.month
semestre_id = formsemestre.semestre_id
return _code_sem(semestre_id, titre, mois_debut, short=short, prefix=prefix)
def _code_sem_dict(sem, short=True, prefix="") -> str:
"code semestre: S1 ou S1d, à parit d'un dict (sem ScoDoc 7)"
titre = sem["titre"]
mois_debut = int(sem["date_debut"].split("/")[1]) if sem["date_debut"] else 0
semestre_id = sem["semestre_id"]
return _code_sem(semestre_id, titre, mois_debut, short=short, prefix=prefix)
def get_code_cursus_etud(
etudid: int,
sems: list[dict] = None,
formsemestres: list[FormSemestre] | None = None,
prefix="",
separator="",
) -> tuple[str, dict]:
"""calcule un code de cursus (parcours) pour un etudiant
2020-09-26 16:19:37 +02:00
exemples:
1234A pour un etudiant ayant effectué S1, S2, S3, S4 puis diplome
12D pour un étudiant en S1, S2 puis démission en S2
12R pour un etudiant en S1, S2 réorienté en fin de S2
On peut passer soir la liste des semestres dict (anciennes fonctions ScoDoc7)
soit la liste des FormSemestre.
Construit aussi un dict: { semestre_id : decision_jury | None }
2020-09-26 16:19:37 +02:00
"""
# Nota: approche plus moderne:
# ' '.join([ f"S{ins.formsemestre.semestre_id}"
# for ins in reversed(etud.inscriptions())
# if ins.formsemestre.formation.formation_code == XXX ])
#
2020-09-26 16:19:37 +02:00
p = []
decisions_jury = {}
if formsemestres is None:
formsemestres = [
FormSemestre.get_or_404(s["formsemestre_id"]) for s in (sems or [])
]
# élimine les semestres spéciaux hors cursus (LP en 1 sem., ...)
formsemestres = [s for s in formsemestres if s.semestre_id >= 0]
i = len(formsemestres) - 1
2020-09-26 16:19:37 +02:00
while i >= 0:
# 'sems' est a l'envers, du plus recent au plus ancien
formsemestre = formsemestres[i]
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
2022-02-13 15:50:16 +01:00
p.append(_code_sem_formsemestre(formsemestre, prefix=prefix))
2020-09-26 16:19:37 +02:00
# code decisions jury de chaque semestre:
if nt.get_etud_etat(etudid) == "D":
decisions_jury[formsemestre.semestre_id] = "DEM"
2020-09-26 16:19:37 +02:00
else:
dec = nt.get_etud_decision_sem(etudid)
2020-09-26 16:19:37 +02:00
if not dec:
decisions_jury[formsemestre.semestre_id] = ""
2020-09-26 16:19:37 +02:00
else:
decisions_jury[formsemestre.semestre_id] = dec["code"]
# code etat dans le code_cursus sur dernier semestre seulement
2020-09-26 16:19:37 +02:00
if i == 0:
# Démission
if nt.get_etud_etat(etudid) == "D":
2020-09-26 16:19:37 +02:00
p.append(":D")
else:
dec = nt.get_etud_decision_sem(etudid)
if dec and dec["code"] in codes_cursus.CODES_SEM_REO:
2020-09-26 16:19:37 +02:00
p.append(":R")
if (
dec
2024-01-22 16:30:18 +01:00
and formsemestre.semestre_id == nt.parcours.NB_SEM
2020-09-26 16:19:37 +02:00
and code_semestre_validant(dec["code"])
):
p.append(":A")
i -= 1
return separator.join(p), decisions_jury
def tsp_etud_list(
formsemestre_id,
groups_infos: sco_groups_view.DisplayedGroupsInfos | None = None,
2020-09-26 16:19:37 +02:00
only_primo=False,
bac="", # selection sur type de bac
bacspecialite="",
annee_bac="",
2023-02-22 00:28:33 +01:00
annee_admission="",
civilite="",
2020-09-26 16:19:37 +02:00
statut="",
):
"""Liste des etuds a considerer dans table suivi cursus
ramene aussi ensembles des bacs, genres, statuts de (tous) les etudiants
2020-09-26 16:19:37 +02:00
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
etudids_inscrits = {ins.etudid for ins in formsemestre.inscriptions}
if groups_infos:
etudids_groups = groups_infos.get_etudids()
etudids = etudids_inscrits.intersection(etudids_groups)
else:
etudids = etudids_inscrits
2020-09-26 16:19:37 +02:00
etuds = []
bacs = set()
bacspecialites = set()
annee_bacs = set()
2023-02-22 00:28:33 +01:00
annee_admissions = set()
civilites = set()
statuts = set()
2020-09-26 16:19:37 +02:00
for etudid in etudids:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
2023-02-22 00:28:33 +01:00
etud["annee_admission"] = etud["annee"] # plus explicite
bacspe = (etud["bac"] or "?") + " / " + (etud["specialite"] or "")
2020-09-26 16:19:37 +02:00
# sélection sur bac, primo, ...:
if (
(not bac or (bac == etud["bac"]))
and (not bacspecialite or (bacspecialite == bacspe))
and (not annee_bac or (annee_bac == str(etud["annee_bac"])))
2023-02-22 00:28:33 +01:00
and (
not annee_admission or (annee_admission == str(etud["annee_admission"]))
)
and (not civilite or (civilite == etud["civilite"]))
2020-09-26 16:19:37 +02:00
and (not statut or (statut == etud["statut"]))
and (not only_primo or is_primo_etud(etud, formsemestre))
2020-09-26 16:19:37 +02:00
):
etuds.append(etud)
bacs.add(etud["bac"])
bacspecialites.add(bacspe)
annee_bacs.add(etud["annee_bac"])
2023-02-22 00:28:33 +01:00
annee_admissions.add(etud["annee_admission"])
civilites.add(etud["civilite"])
2020-09-26 16:19:37 +02:00
if etud["statut"]: # ne montre pas les statuts non renseignés
statuts.add(etud["statut"])
2023-02-22 00:28:33 +01:00
return etuds, bacs, bacspecialites, annee_bacs, annee_admissions, civilites, statuts
2020-09-26 16:19:37 +02:00
def tsp_grouped_list(codes_etuds):
"""Liste pour table regroupant le nombre d'étudiants
(+ bulle avec les noms) de chaque cursus (parcours)"""
2020-09-26 16:19:37 +02:00
L = []
2021-07-09 17:47:06 +02:00
parcours = list(codes_etuds.keys())
2020-09-26 16:19:37 +02:00
parcours.sort()
for p in parcours:
nb = len(codes_etuds[p])
l = {"parcours": p, "nb": nb}
if nb <= MAX_ETUD_IN_DESCR:
l["_nb_help"] = _descr_etud_set([e["etudid"] for e in codes_etuds[p]])
2020-09-26 16:19:37 +02:00
L.append(l)
# tri par effectifs décroissants
2021-07-09 23:19:30 +02:00
L.sort(key=itemgetter("nb"))
2020-09-26 16:19:37 +02:00
return L
def table_suivi_cursus(formsemestre_id, only_primo=False, grouped_parcours=True):
"""Tableau recapitulant tous les parcours"""
2021-08-19 10:28:35 +02:00
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
2023-02-22 00:28:33 +01:00
(
etuds,
bacs,
bacspecialites,
annee_bacs,
annee_admissions,
civilites,
statuts,
) = tsp_etud_list(formsemestre_id, only_primo=only_primo)
codes_etuds = collections.defaultdict(list)
2020-09-26 16:19:37 +02:00
for etud in etuds:
etud["code_cursus"], etud["decisions_jury"] = get_code_cursus_etud(
etud["etudid"], sems=etud["sems"]
)
codes_etuds[etud["code_cursus"]].append(etud)
fiche_url = url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]
)
etud["_nom_target"] = fiche_url
etud["_prenom_target"] = fiche_url
etud["_nom_td_attrs"] = f'''id="{etud['etudid']}" class="etudinfo"'''
2020-09-26 16:19:37 +02:00
titles = {
"parcours": "Code cursus",
2020-09-26 16:19:37 +02:00
"nb": "Nombre d'étudiants",
"civilite": "",
2020-09-26 16:19:37 +02:00
"nom": "Nom",
"prenom": "Prénom",
"etudid": "etudid",
"code_cursus": "Code cursus",
2020-09-26 16:19:37 +02:00
"bac": "Bac",
"specialite": "Spe.",
}
if grouped_parcours:
L = tsp_grouped_list(codes_etuds)
2020-09-26 16:19:37 +02:00
columns_ids = ("parcours", "nb")
else:
# Table avec le cursus de chaque étudiant:
2020-09-26 16:19:37 +02:00
L = etuds
columns_ids = (
"etudid",
"civilite",
2020-09-26 16:19:37 +02:00
"nom",
"prenom",
"bac",
"specialite",
"code_cursus",
2020-09-26 16:19:37 +02:00
)
# Calcule intitulés de colonnes
S = set()
2021-07-09 17:47:06 +02:00
sems_ids = list(S.union(*[list(e["decisions_jury"].keys()) for e in etuds]))
2020-09-26 16:19:37 +02:00
sems_ids.sort()
sem_tits = ["S%s" % s for s in sems_ids]
titles.update([(s, s) for s in sem_tits])
columns_ids += tuple(sem_tits)
for etud in etuds:
for s in etud["decisions_jury"]:
etud["S%s" % s] = etud["decisions_jury"][s]
if only_primo:
primostr = "primo-entrants du"
else:
primostr = "passés dans le"
tab = GenTable(
columns_ids=columns_ids,
rows=L,
titles=titles,
2021-08-21 17:07:44 +02:00
origin="Généré par %s le " % sco_version.SCONAME
+ scu.timedate_human_repr()
+ "",
2020-09-26 16:19:37 +02:00
caption="Parcours suivis, étudiants %s semestre " % primostr
+ sem["titreannee"],
page_title="Parcours " + sem["titreannee"],
html_sortable=True,
html_class="table_leftalign table_listegroupe",
html_next_section="""<table class="help">
<tr><td><tt>1, 2, ...</tt></td><td> numéros de semestres</td></tr>
<tr><td><tt>1d, 2d, ...</tt></td><td>semestres "décalés"</td></tr>
<tr><td><tt>:A</tt></td><td> étudiants diplômés</td></tr>
<tr><td><tt>:R</tt></td><td> étudiants réorientés</td></tr>
<tr><td><tt>:D</tt></td><td> étudiants démissionnaires</td></tr>
</table>""",
bottom_titles={
"parcours": "Total",
"nb": len(etuds),
"code_cursus": len(etuds),
2020-09-26 16:19:37 +02:00
},
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="table_suivi_cursus",
2020-09-26 16:19:37 +02:00
)
return tab
def tsp_form_primo_group(
only_primo, no_grouping, formsemestre_id, fmt, groups_infos=None
) -> str:
"""Element de formulaire pour choisir si restriction aux primos entrants,
groupement par lycees et groupes
"""
primo_checked = 'checked="1"' if only_primo else ""
no_grouping_checked = 'checked="1"' if no_grouping else ""
return f"""
<form id="group_selector" name="f" method="get" action="{request.base_url}">
<input type="checkbox" name="only_primo"
onchange="document.f.submit()" {primo_checked}>Restreindre aux primo-entrants</input>
<input type="checkbox" name="no_grouping" onchange="document.f.submit()"
{no_grouping_checked}>Lister chaque étudiant</input>
<span style="margin: 12px;">
Restreindre au(x) groupe(s)&nbsp;:
{sco_groups_view.menu_groups_choice(groups_infos, submit_on_change=True)
if groups_infos else ''}
</span>
<input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
<input type="hidden" name="fmt" value="{fmt}"/>
</form>
"""
2020-09-26 16:19:37 +02:00
def formsemestre_suivi_cursus(
2020-09-26 16:19:37 +02:00
formsemestre_id,
fmt="html",
2020-09-26 16:19:37 +02:00
only_primo=False,
no_grouping=False,
):
"""Effectifs dans les differents cursus possibles."""
tab = table_suivi_cursus(
2020-09-26 16:19:37 +02:00
formsemestre_id,
only_primo=only_primo,
grouped_parcours=not no_grouping,
)
tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id)
2020-09-26 16:19:37 +02:00
if only_primo:
2021-05-11 11:48:32 +02:00
tab.base_url += "&only_primo=1"
2020-09-26 16:19:37 +02:00
if no_grouping:
2021-05-11 11:48:32 +02:00
tab.base_url += "&no_grouping=1"
t = tab.make_page(fmt=fmt, with_html_headers=False)
if fmt != "html":
2020-09-26 16:19:37 +02:00
return t
F = [
tsp_form_primo_group(
only_primo, no_grouping, formsemestre_id, fmt, groups_infos=None
)
]
2020-09-26 16:19:37 +02:00
H = [
"""<h2 class="formsemestre">Cursus suivis par les étudiants de ce semestre</h2>""",
2020-09-26 16:19:37 +02:00
"\n".join(F),
t,
]
2024-08-24 14:39:19 +02:00
return render_template("sco_page.j2", title=tab.page_title, content="\n".join(H))
2020-09-26 16:19:37 +02:00
# -------------
def graph_cursus(
2020-09-26 16:19:37 +02:00
formsemestre_id,
groups_infos: sco_groups_view.DisplayedGroupsInfos | None = None,
fmt="svg",
2020-09-26 16:19:37 +02:00
only_primo=False,
bac="", # selection sur type de bac
bacspecialite="",
annee_bac="",
2023-02-22 00:28:33 +01:00
annee_admission="",
civilite="",
2020-09-26 16:19:37 +02:00
statut="",
):
""""""
2023-02-22 00:28:33 +01:00
(
etuds,
bacs,
bacspecialites,
annee_bacs,
annee_admissions,
civilites,
statuts,
) = tsp_etud_list(
2020-09-26 16:19:37 +02:00
formsemestre_id,
groups_infos=groups_infos,
2020-09-26 16:19:37 +02:00
only_primo=only_primo,
bac=bac,
bacspecialite=bacspecialite,
annee_bac=annee_bac,
2023-02-22 00:28:33 +01:00
annee_admission=annee_admission,
civilite=civilite,
2020-09-26 16:19:37 +02:00
statut=statut,
)
# log('graph_cursus: %s etuds (only_primo=%s)' % (len(etuds), only_primo))
2020-09-26 16:19:37 +02:00
if not etuds:
2023-02-22 00:28:33 +01:00
return (
"",
etuds,
bacs,
bacspecialites,
annee_bacs,
annee_admissions,
civilites,
statuts,
)
edges = collections.defaultdict(set)
# {("SEM"formsemestre_id_origin, "SEM"formsemestre_id_dest) : etud_set}
2021-08-12 14:31:15 +02:00
def sem_node_name(sem, prefix="SEM"):
"pydot node name for this integer id"
return prefix + str(sem["formsemestre_id"])
2020-09-26 16:19:37 +02:00
sems = {}
effectifs = collections.defaultdict(set) # formsemestre_id : etud_set
decisions = collections.defaultdict(dict) # formsemestre_id : { code : nb_etud }
2021-08-12 14:31:15 +02:00
isolated_nodes = [] # [ node_name_de_formsemestre_id, ... ]
connected_nodes = set() # { node_name_de_formsemestre_id }
2020-09-26 16:19:37 +02:00
diploma_nodes = []
2021-08-12 14:31:15 +02:00
dem_nodes = {} # formsemestre_id : noeud (node name) pour demissionnaires
2020-09-26 16:19:37 +02:00
nar_nodes = {} # formsemestre_id : noeud pour NAR
for etud in etuds:
2021-02-01 23:54:46 +01:00
nxt = {}
2020-09-26 16:19:37 +02:00
etudid = etud["etudid"]
for s in etud["sems"]: # du plus recent au plus ancien
s_formsemestre = FormSemestre.get_or_404(s["formsemestre_id"])
2022-02-13 15:50:16 +01:00
nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre)
2020-09-26 16:19:37 +02:00
dec = nt.get_etud_decision_sem(etudid)
if nxt:
2020-09-26 16:19:37 +02:00
if (
s["semestre_id"] == nt.parcours.NB_SEM
and dec
and code_semestre_validant(dec["code"])
and nt.get_etud_etat(etudid) == scu.INSCRIT
2020-09-26 16:19:37 +02:00
):
# cas particulier du diplome puis poursuite etude
edges[
2021-08-12 14:31:15 +02:00
(
sem_node_name(s, "_dipl_"),
sem_node_name(nxt), # = "SEM{formsemestre_id}"
)
2020-09-26 16:19:37 +02:00
].add(etudid)
else:
2021-08-12 14:31:15 +02:00
edges[(sem_node_name(s), sem_node_name(nxt))].add(etudid)
connected_nodes.add(sem_node_name(s))
connected_nodes.add(sem_node_name(nxt))
2020-09-26 16:19:37 +02:00
else:
2021-08-12 14:31:15 +02:00
isolated_nodes.append(sem_node_name(s))
2020-09-26 16:19:37 +02:00
sems[s["formsemestre_id"]] = s
effectifs[s["formsemestre_id"]].add(etudid)
nxt = s
2020-09-26 16:19:37 +02:00
# Compte decisions jury de chaque semestres:
dc = decisions[s["formsemestre_id"]]
if dec:
if dec["code"] in dc:
dc[dec["code"]] += 1
else:
dc[dec["code"]] = 1
# ajout noeud pour demissionnaires
if nt.get_etud_etat(etudid) == "D":
2021-08-12 14:31:15 +02:00
nid = sem_node_name(s, "_dem_")
2020-09-26 16:19:37 +02:00
dem_nodes[s["formsemestre_id"]] = nid
2021-08-12 14:31:15 +02:00
edges[(sem_node_name(s), nid)].add(etudid)
2020-09-26 16:19:37 +02:00
# ajout noeud pour NAR (seulement pour noeud de depart)
2021-01-10 18:05:20 +01:00
if (
s["formsemestre_id"] == formsemestre_id
and dec
and dec["code"] == codes_cursus.NAR
2021-01-10 18:05:20 +01:00
):
2021-08-12 14:31:15 +02:00
nid = sem_node_name(s, "_nar_")
2020-09-26 16:19:37 +02:00
nar_nodes[s["formsemestre_id"]] = nid
2021-08-12 14:31:15 +02:00
edges[(sem_node_name(s), nid)].add(etudid)
2020-09-26 16:19:37 +02:00
# si "terminal", ajoute noeud pour diplomes
if s["semestre_id"] == nt.parcours.NB_SEM:
if (
dec
and code_semestre_validant(dec["code"])
and nt.get_etud_etat(etudid) == scu.INSCRIT
2020-09-26 16:19:37 +02:00
):
2021-08-12 14:31:15 +02:00
nid = sem_node_name(s, "_dipl_")
edges[(sem_node_name(s), nid)].add(etudid)
2020-09-26 16:19:37 +02:00
diploma_nodes.append(nid)
#
g = scu.graph_from_edges(list(edges.keys()))
2020-09-26 16:19:37 +02:00
for fid in isolated_nodes:
if not fid in connected_nodes:
n = pydot.Node(name=fid)
2020-09-26 16:19:37 +02:00
g.add_node(n)
g.set("rankdir", "LR") # left to right
g.set_fontname("Helvetica")
if fmt == "svg":
2020-09-26 16:19:37 +02:00
g.set_bgcolor("#fffff0") # ou 'transparent'
# titres des semestres:
for s in sems.values():
2021-08-12 14:31:15 +02:00
n = g.get_node(sem_node_name(s))[0]
2020-09-26 16:19:37 +02:00
log("s['formsemestre_id'] = %s" % s["formsemestre_id"])
log("n=%s" % n)
2021-08-12 14:31:15 +02:00
log("get=%s" % g.get_node(sem_node_name(s)))
2020-09-26 16:19:37 +02:00
log("nodes names = %s" % [x.get_name() for x in g.get_node_list()])
if s["modalite"] and s["modalite"] != FormationModalite.DEFAULT_MODALITE:
2020-09-26 16:19:37 +02:00
modalite = " " + s["modalite"]
else:
modalite = ""
label = "%s%s\\n%d/%s - %d/%s\\n%d" % (
_code_sem_dict(s, short=False, prefix="S"),
2020-09-26 16:19:37 +02:00
modalite,
s["mois_debut_ord"],
s["annee_debut"][2:],
s["mois_fin_ord"],
s["annee_fin"][2:],
len(effectifs[s["formsemestre_id"]]),
)
2021-01-10 18:05:20 +01:00
n.set("label", scu.suppress_accents(label))
2020-09-26 16:19:37 +02:00
n.set_fontname("Helvetica")
n.set_fontsize(8.0)
n.set_width(1.2)
n.set_shape("box")
2021-08-12 14:31:15 +02:00
n.set_URL(f"formsemestre_status?formsemestre_id={s['formsemestre_id']}")
2020-09-26 16:19:37 +02:00
# semestre de depart en vert
2021-08-12 14:31:15 +02:00
n = g.get_node("SEM" + str(formsemestre_id))[0]
2020-09-26 16:19:37 +02:00
n.set_color("green")
2024-04-07 19:52:22 +02:00
n.set_style("filled")
n.set_fillcolor("lightgreen")
n.set_penwidth(2.0)
2020-09-26 16:19:37 +02:00
# demissions en rouge, octagonal
for nid in dem_nodes.values():
n = g.get_node(nid)[0]
2020-09-26 16:19:37 +02:00
n.set_color("red")
n.set_shape("octagon")
n.set("label", "Dem.")
# NAR en rouge, Mcircle
for nid in nar_nodes.values():
n = g.get_node(nid)[0]
2020-09-26 16:19:37 +02:00
n.set_color("red")
n.set_shape("Mcircle")
n.set("label", codes_cursus.NAR)
2020-09-26 16:19:37 +02:00
# diplomes:
for nid in diploma_nodes:
n = g.get_node(nid)[0]
2020-09-26 16:19:37 +02:00
n.set_color("red")
n.set_shape("ellipse")
n.set("label", "Diplome") # bug si accent (pas compris pourquoi)
# Arètes:
bubbles = {} # substitue titres pour bulle aides: src_id:dst_id : etud_descr
for src_id, dst_id in edges.keys():
e = g.get_edge(src_id, dst_id)[0]
2020-09-26 16:19:37 +02:00
e.set("arrowhead", "normal")
e.set("arrowsize", 1)
e.set_label(len(edges[(src_id, dst_id)]))
e.set_fontname("Helvetica")
e.set_fontsize(8.0)
# bulle avec liste etudiants
if len(edges[(src_id, dst_id)]) <= MAX_ETUD_IN_DESCR:
etud_descr = _descr_etud_set(edges[(src_id, dst_id)])
2020-09-26 16:19:37 +02:00
bubbles[src_id + ":" + dst_id] = etud_descr
2021-09-28 07:28:16 +02:00
e.set_URL(f"__xxxetudlist__?{src_id}:{dst_id}")
2020-09-26 16:19:37 +02:00
# Genere graphe
2021-01-10 18:05:20 +01:00
_, path = tempfile.mkstemp(".gr")
g.write(path=path, format=fmt)
with open(path, "rb") as f:
data = f.read()
log("dot generated %d bytes in %s format" % (len(data), fmt))
2020-09-26 16:19:37 +02:00
if not data:
log("graph.to_string=%s" % g.to_string())
raise ValueError("Erreur lors de la génération du document au format %s" % fmt)
2020-09-26 16:19:37 +02:00
os.unlink(path)
if fmt == "svg":
2020-09-26 16:19:37 +02:00
# dot génère un document XML complet, il faut enlever l'en-tête
2021-07-27 13:50:53 +02:00
data_str = data.decode("utf-8")
data = "<svg" + "<svg".join(data_str.split("<svg")[1:])
2020-09-26 16:19:37 +02:00
# Substitution des titres des URL des aretes pour bulles aide
def repl(m):
return '<a title="%s"' % bubbles[m.group("sd")]
exp = re.compile(
r'<a.*?href="__xxxetudlist__\?(?P<sd>\w*:\w*).*?".*?xlink:title=".*?"', re.M
)
data = exp.sub(repl, data)
# Substitution des titres des boites (semestres)
exp1 = re.compile(
r'<a xlink:href="formsemestre_status\?formsemestre_id=(?P<fid>\w*).*?".*?xlink:title="(?P<title>.*?)"',
re.M | re.DOTALL,
)
def repl_title(m):
2021-08-12 14:31:15 +02:00
fid = int(m.group("fid"))
2020-09-26 16:19:37 +02:00
title = sems[fid]["titreannee"]
if decisions[fid]:
title += (
(" (" + str(decisions[fid]) + ")").replace("{", "").replace("'", "")
)
return (
'<a xlink:href="formsemestre_status?formsemestre_id=%s" xlink:title="%s"'
2021-01-10 18:05:20 +01:00
% (fid, scu.suppress_accents(title))
2020-09-26 16:19:37 +02:00
) # evite accents car svg utf-8 vs page en latin1...
data = exp1.sub(repl_title, data)
# Substitution de Arial par Helvetica (new prblem in Debian 5) ???
# bug turnaround: il doit bien y avoir un endroit ou regler cela ?
# cf http://groups.google.com/group/pydot/browse_thread/thread/b3704c53e331e2ec
data = data.replace("font-family:Arial", "font-family:Helvetica")
2023-02-22 00:28:33 +01:00
return (
data,
etuds,
bacs,
bacspecialites,
annee_bacs,
annee_admissions,
civilites,
statuts,
)
2020-09-26 16:19:37 +02:00
def formsemestre_graph_cursus(
2020-09-26 16:19:37 +02:00
formsemestre_id,
fmt="html",
2020-09-26 16:19:37 +02:00
only_primo=False,
bac="", # selection sur type de bac
bacspecialite="",
annee_bac="",
2023-02-22 00:28:33 +01:00
annee_admission="",
civilite="",
2020-09-26 16:19:37 +02:00
statut="",
allkeys=False, # unused
2020-09-26 16:19:37 +02:00
):
"""Graphe suivi cohortes"""
2023-02-22 00:28:33 +01:00
annee_bac = str(annee_bac or "")
annee_admission = str(annee_admission or "")
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
2024-08-25 07:23:36 +02:00
if request.method == "POST":
group_ids = request.form.getlist("group_ids")
else:
group_ids = request.args.getlist("group_ids")
try:
group_ids = [int(gid) for gid in group_ids]
except ValueError as exc:
raise ScoValueError("group_ids invalide") from exc
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids,
formsemestre_id=formsemestre.id,
select_all_when_unspecified=True,
)
2021-08-19 10:28:35 +02:00
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
if fmt == "pdf":
(
doc,
etuds,
bacs,
bacspecialites,
annee_bacs,
2023-02-22 00:28:33 +01:00
annee_admissions,
civilites,
statuts,
) = graph_cursus(
2020-09-26 16:19:37 +02:00
formsemestre_id,
groups_infos=groups_infos,
fmt="pdf",
2020-09-26 16:19:37 +02:00
only_primo=only_primo,
bac=bac,
bacspecialite=bacspecialite,
annee_bac=annee_bac,
2023-02-22 00:28:33 +01:00
annee_admission=annee_admission,
civilite=civilite,
2020-09-26 16:19:37 +02:00
statut=statut,
)
2021-01-10 18:05:20 +01:00
filename = scu.make_filename("flux " + sem["titreannee"])
return scu.sendPDFFile(doc, filename + ".pdf")
elif fmt == "png":
2020-09-26 16:19:37 +02:00
#
(
doc,
etuds,
bacs,
bacspecialites,
annee_bacs,
2023-02-22 00:28:33 +01:00
annee_admissions,
civilites,
statuts,
) = graph_cursus(
2020-09-26 16:19:37 +02:00
formsemestre_id,
groups_infos=groups_infos,
fmt="png",
2020-09-26 16:19:37 +02:00
only_primo=only_primo,
bac=bac,
bacspecialite=bacspecialite,
annee_bac=annee_bac,
2023-02-22 00:28:33 +01:00
annee_admission=annee_admission,
civilite=civilite,
2020-09-26 16:19:37 +02:00
statut=statut,
)
return scu.send_file(
doc,
filename="flux " + sem["titreannee"],
suffix=".png",
attached=True,
mime="image/png",
2020-09-26 16:19:37 +02:00
)
elif fmt == "html":
url_kw = {
"scodoc_dept": g.scodoc_dept,
"formsemestre_id": formsemestre_id,
"bac": bac,
"specialite": bacspecialite,
"civilite": civilite,
"statut": statut,
}
2020-09-26 16:19:37 +02:00
if only_primo:
url_kw["only_primo"] = "on"
(
doc,
etuds,
bacs,
bacspecialites,
annee_bacs,
2023-02-22 00:28:33 +01:00
annee_admissions,
civilites,
statuts,
) = graph_cursus(
2020-09-26 16:19:37 +02:00
formsemestre_id,
groups_infos=groups_infos,
2020-09-26 16:19:37 +02:00
only_primo=only_primo,
bac=bac,
bacspecialite=bacspecialite,
annee_bac=annee_bac,
2023-02-22 00:28:33 +01:00
annee_admission=annee_admission,
civilite=civilite,
2020-09-26 16:19:37 +02:00
statut=statut,
)
H = [
"""<h2 class="formsemestre">Cursus des étudiants de ce semestre</h2>""",
2020-09-26 16:19:37 +02:00
doc,
f"<p>{len(etuds)} étudiants sélectionnés</p>",
2020-09-26 16:19:37 +02:00
_gen_form_selectetuds(
formsemestre_id,
groups_infos=groups_infos,
2020-09-26 16:19:37 +02:00
only_primo=only_primo,
bac=bac,
bacspecialite=bacspecialite,
annee_bac=annee_bac,
2023-02-22 00:28:33 +01:00
annee_admission=annee_admission,
civilite=civilite,
2020-09-26 16:19:37 +02:00
statut=statut,
bacs=bacs,
bacspecialites=bacspecialites,
annee_bacs=annee_bacs,
2023-02-22 00:28:33 +01:00
annee_admissions=annee_admissions,
civilites=civilites,
2020-09-26 16:19:37 +02:00
statuts=statuts,
percent=0,
),
"""<p>Origine et devenir des étudiants inscrits dans %(titreannee)s"""
% sem,
2024-08-24 14:39:19 +02:00
f"""(<a href="{
url_for("notes.formsemestre_graph_cursus", fmt="pdf", **url_kw)
}">version pdf</a>,
<a href="{
url_for("notes.formsemestre_graph_cursus", fmt="png", **url_kw)}">image PNG</a>)
</p>
<p class="help">Le graphe permet de suivre les étudiants inscrits dans le semestre
2020-09-26 16:19:37 +02:00
sélectionné (dessiné en vert). Chaque rectangle représente un semestre (cliquez dedans
pour afficher son tableau de bord). Les flèches indiquent le nombre d'étudiants
passant d'un semestre à l'autre (s'il y en a moins de {MAX_ETUD_IN_DESCR}, vous
pouvez visualiser leurs noms en passant le curseur sur le chiffre).
</p>
<p class="help">
Le menu <em>Restreindre au(x) groupe(s)</em> permet de restreindre l'étude aux
étudiants appartenant aux groupes indiqués <em>dans le semestre d'origine</em>.
</p>
""",
2020-09-26 16:19:37 +02:00
]
2024-08-24 14:39:19 +02:00
return render_template(
"sco_page.j2",
2024-08-25 07:23:36 +02:00
javascripts=["js/groups_view.js"],
2024-08-24 14:39:19 +02:00
page_title=f"Graphe cursus de {sem['titreannee']}",
no_sidebar=True,
content="\n".join(H),
)
2020-09-26 16:19:37 +02:00
else:
raise ValueError(f"invalid format: {fmt}")