BUT: dispenses d'UE capitalisées. Voir #537.

This commit is contained in:
Emmanuel Viennet 2022-12-01 13:00:14 +01:00 committed by iziram
parent ae9aad0619
commit a87dbd9927
13 changed files with 247 additions and 70 deletions

View File

@ -80,6 +80,9 @@ class BulletinBUT:
"""
res = self.res
if (etud.id, ue.id) in self.res.dispense_ues:
return {}
if ue.type == UE_SPORT:
modimpls_spo = [
modimpl

View File

@ -32,9 +32,17 @@ import pandas as pd
from app import db
from app import models
from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef
from app.models import (
DispenseUE,
FormSemestre,
FormSemestreInscription,
Identite,
Module,
ModuleImpl,
ModuleUECoef,
UniteEns,
)
from app.comp import moy_mod
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_preferences
from app.scodoc.sco_codes_parcours import UE_SPORT
@ -140,7 +148,8 @@ def df_load_modimpl_coefs(
mod_coef.ue_id
] = mod_coef.coef
except IndexError:
# il peut y avoir en base des coefs sur des modules ou UE qui ont depuis été retirés de la formation
# il peut y avoir en base des coefs sur des modules ou UE
# qui ont depuis été retirés de la formation
pass
# Initialisation des poids non fixés:
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
@ -199,7 +208,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module)
if len(modimpls_notes):
if len(modimpls_notes) > 0:
cube = notes_sem_assemble_cube(modimpls_notes)
else:
nb_etuds = formsemestre.etuds.count()
@ -211,14 +220,39 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
)
def load_dispense_ues(
formsemestre: FormSemestre, etudids: pd.Index, ues: list[UniteEns]
) -> set[tuple[int, int]]:
"""Construit l'ensemble des
etudids = modimpl_inscr_df.index, # les etudids
ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport
Résultat: set de (etudid, ue_id).
"""
dispense_ues = set()
ue_sem_by_code = {ue.ue_code: ue for ue in ues}
# Prend toutes les dispenses obtenues par des étudiants de ce formsemestre,
# puis filtre sur inscrits et code d'UE UE
for dispense_ue in DispenseUE.query.join(
Identite, FormSemestreInscription
).filter_by(formsemestre_id=formsemestre.id):
if dispense_ue.etudid in etudids:
# UE dans le semestre avec même code ?
ue = ue_sem_by_code.get(dispense_ue.ue.ue_code)
if ue is not None:
dispense_ues.add((dispense_ue.etudid, ue.id))
return dispense_ues
def compute_ue_moys_apc(
sem_cube: np.array,
etuds: list,
modimpls: list,
ues: list,
modimpl_inscr_df: pd.DataFrame,
modimpl_coefs_df: pd.DataFrame,
modimpl_mask: np.array,
dispense_ues: set[tuple[int, int]],
block: bool = False,
) -> pd.DataFrame:
"""Calcul de la moyenne d'UE en mode APC (BUT).
@ -230,7 +264,7 @@ def compute_ue_moys_apc(
etuds : liste des étudiants (dim. 0 du cube)
modimpls : liste des module_impl (dim. 1 du cube)
ues : liste des UE (dim. 2 du cube)
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl)
modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport
modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas.
(utilisé pour éliminer les bonus, et pourra servir à cacluler
@ -239,7 +273,6 @@ def compute_ue_moys_apc(
Résultat: DataFrame columns UE (sans bonus), rows etudid
"""
nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape
nb_ues_tot = len(ues)
assert len(modimpls) == nb_modules
if block or nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0:
return pd.DataFrame(
@ -278,11 +311,16 @@ def compute_ue_moys_apc(
etud_moy_ue = np.sum(
modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1
) / np.sum(modimpl_coefs_etuds_no_nan, axis=1)
return pd.DataFrame(
etud_moy_ue_df = pd.DataFrame(
etud_moy_ue,
index=modimpl_inscr_df.index, # les etudids
columns=modimpl_coefs_df.index, # les UE sans les UE bonus sport
)
# Les "dispenses" sont très peu nombreuses et traitées en python:
for dispense_ue in dispense_ues:
etud_moy_ue_df[dispense_ue[1]][dispense_ue[0]] = 0.0
return etud_moy_ue_df
def compute_ue_moys_classic(
@ -435,7 +473,7 @@ def compute_mat_moys_classic(
Résultat:
- moyennes: pd.Series, index etudid
"""
if (not len(modimpl_mask)) or (
if (0 == len(modimpl_mask)) or (
sem_matrix.shape[0] == 0
): # aucun module ou aucun étudiant
# etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df

View File

@ -39,6 +39,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""ndarray (etuds x modimpl x ue)"""
self.etuds_parcour_id = None
"""Parcours de chaque étudiant { etudid : parcour_id }"""
if not self.load_cached():
t0 = time.time()
self.compute()
@ -71,14 +72,17 @@ class ResultatsSemestreBUT(NotesTableCompat):
modimpl.module.ue.type != UE_SPORT
for modimpl in self.formsemestre.modimpls_sorted
]
self.dispense_ues = moy_ue.load_dispense_ues(
self.formsemestre, self.modimpl_inscr_df.index, self.ues
)
self.etud_moy_ue = moy_ue.compute_ue_moys_apc(
self.sem_cube,
self.etuds,
self.formsemestre.modimpls_sorted,
self.ues,
self.modimpl_inscr_df,
self.modimpl_coefs_df,
modimpls_mask,
self.dispense_ues,
block=self.formsemestre.block_moyennes,
)
# Les coefficients d'UE ne sont pas utilisés en APC

View File

@ -48,12 +48,13 @@ class ResultatsSemestre(ResultatsCache):
_cached_attrs = (
"bonus",
"bonus_ues",
"dispense_ues",
"etud_coef_ue_df",
"etud_moy_gen_ranks",
"etud_moy_gen",
"etud_moy_ue",
"modimpl_inscr_df",
"modimpls_results",
"etud_coef_ue_df",
"moyennes_matieres",
)
@ -66,6 +67,8 @@ class ResultatsSemestre(ResultatsCache):
"Bonus sur moy. gen. Series de float, index etudid"
self.bonus_ues: pd.DataFrame = None # virtuel
"DataFrame de float, index etudid, columns: ue.id"
self.dispense_ues: set[tuple[int, int]] = set()
"""set des dispenses d'UE: (etudid, ue_id), en APC seulement."""
# ResultatsSemestreBUT ou ResultatsSemestreClassic
self.etud_moy_ue = {}
"etud_moy_ue: DataFrame columns UE, rows etudid"

View File

@ -36,7 +36,7 @@ from app.models.etudiants import (
from app.models.events import Scolog, ScolarNews
from app.models.formations import Formation, Matiere
from app.models.modules import Module, ModuleUECoef, NotesTag, notes_modules_tags
from app.models.ues import UniteEns
from app.models.ues import DispenseUE, UniteEns
from app.models.formsemestre import (
FormSemestre,
FormSemestreEtape,

View File

@ -58,6 +58,12 @@ class Identite(db.Model):
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
#
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
dispense_ues = db.relationship(
"DispenseUE",
back_populates="etud",
cascade="all, delete",
passive_deletes=True,
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship("Assiduite", backref="etudiant", lazy="dynamic")

View File

@ -5,6 +5,7 @@ from app import db, log
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ApcNiveau, ApcParcours
from app.models.modules import Module
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.scodoc import sco_utils as scu
@ -57,6 +58,12 @@ class UniteEns(db.Model):
# relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
modules = db.relationship("Module", lazy="dynamic", backref="ue")
dispense_ues = db.relationship(
"DispenseUE",
back_populates="ue",
cascade="all, delete",
passive_deletes=True,
)
def __repr__(self):
return f"""<{self.__class__.__name__}(id={self.id}, formation_id={
@ -237,3 +244,31 @@ class UniteEns(db.Model):
db.session.add(self)
db.session.commit()
log(f"ue.set_parcour( {self}, {parcour} )")
class DispenseUE(db.Model):
"""Dispense d'UE
Utilisé en PCC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée
qu'ils ne refont pas.
"""
__table_args__ = (db.UniqueConstraint("ue_id", "etudid"),)
id = db.Column(db.Integer, primary_key=True)
ue_id = db.Column(
db.Integer,
db.ForeignKey(UniteEns.id, ondelete="CASCADE"),
index=True,
nullable=False,
)
ue = db.relationship("UniteEns", back_populates="dispense_ues")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etud = db.relationship("Identite", back_populates="dispense_ues")
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} {self.id} etud={
repr(self.etud)} ue={repr(self.ue)}>"""

View File

@ -36,7 +36,7 @@ from flask_login import current_user
from app import log
from app.models import ScolarNews
from app.models import ModuleImpl, ScolarNews
from app.models.evaluations import evaluation_enrich_dict, check_evaluation_args
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
@ -126,10 +126,13 @@ def do_evaluation_create(
"""Create an evaluation"""
if not sco_permissions_check.can_edit_evaluation(moduleimpl_id=moduleimpl_id):
raise AccessDenied(
"Modification évaluation impossible pour %s" % current_user.get_nomplogin()
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
)
args = locals()
log("do_evaluation_create: args=" + str(args))
modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
if modimpl is None:
raise ValueError("module not found")
check_evaluation_args(args)
# Check numeros
module_evaluation_renumber(moduleimpl_id, only_if_unumbered=True)
@ -172,16 +175,18 @@ def do_evaluation_create(
r = _evaluationEditor.create(cnx, args)
# news
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0]
sco_cache.invalidate_formsemestre(formsemestre_id=M["formsemestre_id"])
mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
mod["moduleimpl_id"] = M["moduleimpl_id"]
mod["url"] = "Notes/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod
sco_cache.invalidate_formsemestre(formsemestre_id=modimpl.formsemestre_id)
url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=moduleimpl_id,
)
ScolarNews.add(
typ=ScolarNews.NEWS_NOTE,
obj=moduleimpl_id,
text='Création d\'une évaluation dans <a href="%(url)s">%(titre)s</a>' % mod,
url=mod["url"],
text=f"""Création d'une évaluation dans <a href="{url}">{
modimpl.module.titre or '(module sans titre)'}</a>""",
url=url,
)
return r

View File

@ -262,6 +262,7 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
"""
authuser = current_user
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
is_apc = formsemestre.formation.is_apc()
inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
args={"formsemestre_id": formsemestre_id}
@ -390,36 +391,39 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
H.append("</table>")
# Etudiants "dispensés" d'une UE (capitalisée)
UECaps = get_etuds_with_capitalized_ue(formsemestre_id)
if UECaps:
H.append('<h3>Etudiants avec UEs capitalisées:</h3><ul class="ue_inscr_list">')
ues = [sco_edit_ue.ue_list({"ue_id": ue_id})[0] for ue_id in UECaps.keys()]
ues_cap_info = get_etuds_with_capitalized_ue(formsemestre_id)
if ues_cap_info:
H.append('<h3>Étudiants avec UEs capitalisées:</h3><ul class="ue_inscr_list">')
ues = [
sco_edit_ue.ue_list({"ue_id": ue_id})[0] for ue_id in ues_cap_info.keys()
]
ues.sort(key=lambda u: u["numero"])
for ue in ues:
H.append(
'<li class="tit"><span class="tit">%(acronyme)s: %(titre)s</span>' % ue
f"""<li class="tit"><span class="tit">{ue['acronyme']}: {ue['titre']}</span>"""
)
H.append("<ul>")
for info in UECaps[ue["ue_id"]]:
for info in ues_cap_info[ue["ue_id"]]:
etud = sco_etud.get_etud_info(etudid=info["etudid"], filled=True)[0]
H.append(
'<li class="etud"><a class="discretelink" href="%s">%s</a>'
% (
f"""<li class="etud"><a class="discretelink" href="{
url_for(
"scolar.ficheEtud",
scodoc_dept=g.scodoc_dept,
etudid=etud["etudid"],
),
etud["nomprenom"],
)
}">{etud["nomprenom"]}</a>"""
)
if info["ue_status"]["event_date"]:
H.append(
"(cap. le %s)"
% (info["ue_status"]["event_date"]).strftime("%d/%m/%Y")
f"""(cap. le {info["ue_status"]["event_date"].strftime("%d/%m/%Y")})"""
)
if info["is_ins"]:
if is_apc:
is_inscrit_ue = (etud["etudid"], ue["id"]) not in res.dispense_ues
else:
# CLASSIQUE
is_inscrit_ue = info["is_ins"]
if is_inscrit_ue:
dm = ", ".join(
[
m["code"] or m["abbrev"] or "pas_de_code"
@ -427,28 +431,40 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
]
)
H.append(
'actuellement inscrit dans <a title="%s" class="discretelink">%d modules</a>'
% (dm, len(info["is_ins"]))
f"""actuellement inscrit dans <a title="{dm}" class="discretelink"
>{len(info["is_ins"])} modules</a>"""
)
if is_inscrit_ue:
if info["ue_status"]["is_capitalized"]:
H.append(
"""<div><em style="font-size: 70%">UE actuelle moins bonne que l'UE capitalisée</em></div>"""
"""<div><em style="font-size: 70%">UE actuelle moins bonne que
l'UE capitalisée</em>
</div>"""
)
else:
H.append(
"""<div><em style="font-size: 70%">UE actuelle meilleure que l'UE capitalisée</em></div>"""
"""<div><em style="font-size: 70%">UE actuelle meilleure que
l'UE capitalisée</em>
</div>"""
)
if can_change:
H.append(
'<div><a class="stdlink" href="etud_desinscrit_ue?etudid=%s&formsemestre_id=%s&ue_id=%s">désinscrire des modules de cette UE</a></div>'
% (etud["etudid"], formsemestre_id, ue["ue_id"])
f"""<div><a class="stdlink" href="{
url_for("notes.etud_desinscrit_ue",
scodoc_dept=g.scodoc_dept, etudid=etud["etudid"],
formsemestre_id=formsemestre_id, ue_id=ue["ue_id"])
}">désinscrire {"des modules" if not is_apc else ""} de cette UE</a></div>
"""
)
else:
H.append("(non réinscrit dans cette UE)")
if can_change:
H.append(
'<div><a class="stdlink" href="etud_inscrit_ue?etudid=%s&formsemestre_id=%s&ue_id=%s">inscrire à tous les modules de cette UE</a></div>'
% (etud["etudid"], formsemestre_id, ue["ue_id"])
f"""<div><a class="stdlink" href="{
url_for("notes.etud_inscrit_ue", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"],
formsemestre_id=formsemestre_id, ue_id=ue["ue_id"])
}">inscrire à {"" if is_apc else "tous les modules de"} cette UE</a></div>
"""
)
H.append("</li>")
H.append("</ul></li>")
@ -524,11 +540,11 @@ def _fmt_etud_set(ins, max_list_size=7):
)
def get_etuds_with_capitalized_ue(formsemestre_id):
def get_etuds_with_capitalized_ue(formsemestre_id: int) -> list[dict]:
"""For each UE, computes list of students capitalizing the UE.
returns { ue_id : [ { infos } ] }
"""
UECaps = scu.DictDefault(defaultvalue=[])
ues_cap_info = scu.DictDefault(defaultvalue=[])
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
@ -540,21 +556,22 @@ def get_etuds_with_capitalized_ue(formsemestre_id):
for etud in inscrits:
ue_status = nt.get_etud_ue_status(etud["etudid"], ue["ue_id"])
if ue_status and ue_status["was_capitalized"]:
UECaps[ue["ue_id"]].append(
ues_cap_info[ue["ue_id"]].append(
{
"etudid": etud["etudid"],
"ue_status": ue_status,
"is_ins": is_inscrit_ue(
"is_ins": etud_modules_ue_inscr(
etud["etudid"], formsemestre_id, ue["ue_id"]
),
}
)
return UECaps
return ues_cap_info
def is_inscrit_ue(etudid, formsemestre_id, ue_id):
def etud_modules_ue_inscr(etudid, formsemestre_id, ue_id) -> list[int]:
"""Modules de cette UE dans ce semestre
auxquels l'étudiant est inscrit.
Utile pour formations classiques seulement.
"""
r = ndb.SimpleDictFetch(
"""SELECT mod.id AS module_id, mod.*
@ -573,8 +590,10 @@ def is_inscrit_ue(etudid, formsemestre_id, ue_id):
return r
def do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
"""Desincrit l'etudiant de tous les modules de cette UE dans ce semestre."""
def do_etud_desinscrit_ue_classic(etudid, formsemestre_id, ue_id):
"""Désinscrit l'etudiant de tous les modules de cette UE dans ce semestre.
N'utiliser que pour les formations classiques, pas APC.
"""
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
@ -597,7 +616,7 @@ def do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
cnx,
method="etud_desinscrit_ue",
etudid=etudid,
msg="desinscription UE %s" % ue_id,
msg=f"desinscription UE {ue_id}",
commit=False,
)
sco_cache.invalidate_formsemestre(

View File

@ -58,7 +58,7 @@ from app.models.formsemestre import FormSemestre
from app.models.formsemestre import FormSemestreUEComputationExpr
from app.models.moduleimpls import ModuleImpl
from app.models.modules import Module
from app.models.ues import UniteEns
from app.models.ues import DispenseUE, UniteEns
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.views import notes_bp as bp
@ -1588,12 +1588,30 @@ sco_publish(
@permission_required(Permission.ScoEtudInscrit)
@scodoc7func
def etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
"""Desinscrit l'etudiant de tous les modules de cette UE dans ce semestre."""
sco_moduleimpl_inscriptions.do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id)
"""
- En classique: désinscrit l'etudiant de tous les modules de cette UE dans ce semestre.
- En APC: dispense de l'UE indiquée.
"""
etud = Identite.query.get_or_404(etudid)
ue = UniteEns.query.get_or_404(ue_id)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
if ue.formation.is_apc():
if DispenseUE.query.filter_by(etudid=etudid, ue_id=ue_id).count() == 0:
disp = DispenseUE(ue_id=ue_id, etudid=etudid)
db.session.add(disp)
db.session.commit()
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id)
else:
sco_moduleimpl_inscriptions.do_etud_desinscrit_ue_classic(
etudid, formsemestre_id, ue_id
)
flash(f"{etud.nomprenom} déinscrit de {ue.acronyme}")
return flask.redirect(
scu.ScoURL()
+ "/Notes/moduleimpl_inscriptions_stats?formsemestre_id="
+ str(formsemestre_id)
url_for(
"notes.moduleimpl_inscriptions_stats",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)

View File

@ -0,0 +1,43 @@
"""DispenseUE
Revision ID: f95656fdd3ef
Revises: 5542cac8c34a
Create Date: 2022-11-30 22:22:05.045255
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "f95656fdd3ef"
down_revision = "5542cac8c34a"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"dispenseUE",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("ue_id", sa.Integer(), nullable=False),
sa.Column("etudid", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["etudid"], ["identite.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["ue_id"], ["notes_ue.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("ue_id", "etudid"),
)
op.create_index(
op.f("ix_dispenseUE_etudid"), "dispenseUE", ["etudid"], unique=False
)
op.create_index(op.f("ix_dispenseUE_ue_id"), "dispenseUE", ["ue_id"], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_dispenseUE_ue_id"), table_name="dispenseUE")
op.drop_index(op.f("ix_dispenseUE_etudid"), table_name="dispenseUE")
op.drop_table("dispenseUE")
# ### end Alembic commands ###

View File

@ -1,13 +1,17 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
SCOVERSION = "9.4.6"
SCOVERSION = "9.4.7"
SCONAME = "ScoDoc"
SCONEWS = """
<h4>Année 2022</h4>
<ul>
<li>ScoDoc 9.4</li>
<ul>
<li>Jury BUT2 avec parcours BUT</li>
</ul>
<li>ScoDoc 9.3</li>
<ul>
<li>Nouvelle API REST pour connecter ScoDoc à d'autres applications<li>

View File

@ -74,7 +74,6 @@ def test_ue_moy(test_client):
sem_cube,
etuds,
modimpls,
ues,
modimpl_inscr_df,
modimpl_coefs_df,
modimpl_mask,
@ -123,7 +122,7 @@ def test_ue_moy(test_client):
modimpl.module.ue.type != UE_SPORT for modimpl in formsemestre.modimpls_sorted
]
etud_moy_ue = moy_ue.compute_ue_moys_apc(
sem_cube, etuds, modimpls, ues, modimpl_inscr_df, modimpl_coefs_df, modimpl_mask
sem_cube, etuds, modimpls, modimpl_inscr_df, modimpl_coefs_df, modimpl_mask
)
assert etud_moy_ue[ue1.id][etudid] == n1
assert etud_moy_ue[ue2.id][etudid] == n1