WIP: associations UEs / Competences, ref. comp., tests, refactoring.

This commit is contained in:
Emmanuel Viennet 2023-04-03 17:46:31 +02:00
parent c6e1a16b99
commit dfa453768d
43 changed files with 826 additions and 248 deletions

View File

@ -12,6 +12,7 @@ import traceback
import logging import logging
from logging.handlers import SMTPHandler, WatchedFileHandler from logging.handlers import SMTPHandler, WatchedFileHandler
from threading import Thread from threading import Thread
import warnings
from flask import current_app, g, request from flask import current_app, g, request
from flask import Flask from flask import Flask
@ -254,6 +255,8 @@ def create_app(config_class=DevConfig):
# Evite de logguer toutes les requetes dans notre log # Evite de logguer toutes les requetes dans notre log
logging.getLogger("werkzeug").disabled = True logging.getLogger("werkzeug").disabled = True
app.logger.setLevel(app.config["LOG_LEVEL"]) app.logger.setLevel(app.config["LOG_LEVEL"])
if app.config["TESTING"] or app.config["DEBUG"]:
warnings.filterwarnings("error")
# Vérifie/crée lien sym pour les URL statiques # Vérifie/crée lien sym pour les URL statiques
link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}" link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}"

View File

@ -8,17 +8,17 @@
ScoDoc 9 API : accès aux formations ScoDoc 9 API : accès aux formations
""" """
from flask import g, jsonify from flask import g, jsonify, request
from flask_login import login_required from flask_login import login_required
import app import app
from app import log
from app.api import api_bp as bp, api_web_bp from app.api import api_bp as bp, api_web_bp
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
from app.models.formations import Formation from app.models import ApcParcours, Formation, FormSemestre, ModuleImpl, UniteEns
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
from app.scodoc import sco_formations from app.scodoc import sco_formations
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
@ -174,7 +174,7 @@ def formation_export_by_formation_id(formation_id: int, export_ids=False):
] ]
}, },
{ {
"titre": "Se sensibiliser \u00e0 l'hygi\u00e8ne informatique et \u00e0 la cybers\u00e9curit\u00e9", "titre": "Se sensibiliser \u00e0 l'hygi\u00e8ne informatique...",
"abbrev": "Hygi\u00e8ne informatique", "abbrev": "Hygi\u00e8ne informatique",
"code": "SAE11", "code": "SAE11",
"heures_cours": 0.0, "heures_cours": 0.0,
@ -282,3 +282,29 @@ def moduleimpl(moduleimpl_id: int):
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
modimpl: ModuleImpl = query.first_or_404() modimpl: ModuleImpl = query.first_or_404()
return jsonify(modimpl.to_dict(convert_objects=True)) return jsonify(modimpl.to_dict(convert_objects=True))
@bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@api_web_bp.route("/set_ue_parcours/<int:ue_id>", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoChangeFormation)
def set_ue_parcours(ue_id: int):
"""Associe UE et parcours BUT.
La liste des ids de parcours est passée en argument JSON.
JSON arg: [parcour_id1, parcour_id2, ...]
"""
query = UniteEns.query.filter_by(id=ue_id)
if g.scodoc_dept:
query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
ue: UniteEns = query.first_or_404()
parcours_ids = request.get_json(force=True) or [] # may raise 400 Bad Request
if parcours_ids == [""]:
parcours = []
else:
parcours = [
ApcParcours.query.get_or_404(int(parcour_id)) for parcour_id in parcours_ids
]
log(f"set_ue_parcours: ue_id={ue.id} parcours_ids={parcours_ids}")
ok, error_message = ue.set_parcours(parcours)
return jsonify({"status": ok, "message": error_message})

View File

@ -7,6 +7,8 @@
""" """
ScoDoc 9 API : accès aux formsemestres ScoDoc 9 API : accès aux formsemestres
""" """
from operator import attrgetter, itemgetter
from flask import g, jsonify, request from flask import g, jsonify, request
from flask_login import login_required from flask_login import login_required
@ -254,7 +256,7 @@ def formsemestre_programme(formsemestre_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
ues = formsemestre.query_ues() ues = formsemestre.get_ues()
m_list = { m_list = {
ModuleType.RESSOURCE: [], ModuleType.RESSOURCE: [],
ModuleType.SAE: [], ModuleType.SAE: [],
@ -345,7 +347,7 @@ def formsemestre_etudiants(
etud["id"], formsemestre_id, exclude_default=True etud["id"], formsemestre_id, exclude_default=True
) )
return jsonify(sorted(etuds, key=lambda e: e["sort_key"])) return jsonify(sorted(etuds, key=itemgetter("sort_key")))
@bp.route("/formsemestre/<int:formsemestre_id>/etat_evals") @bp.route("/formsemestre/<int:formsemestre_id>/etat_evals")
@ -432,7 +434,7 @@ def etat_evals(formsemestre_id: int):
# Si il y a plus d'une note saisie pour l'évaluation # Si il y a plus d'une note saisie pour l'évaluation
if len(notes) >= 1: if len(notes) >= 1:
# Tri des notes en fonction de leurs dates # Tri des notes en fonction de leurs dates
notes_sorted = sorted(notes, key=lambda note: note.date) notes_sorted = sorted(notes, key=attrgetter("date"))
date_debut = notes_sorted[0].date date_debut = notes_sorted[0].date
date_fin = notes_sorted[-1].date date_fin = notes_sorted[-1].date

View File

@ -7,6 +7,8 @@
""" """
ScoDoc 9 API : partitions ScoDoc 9 API : partitions
""" """
from operator import attrgetter
from flask import g, jsonify, request from flask import g, jsonify, request
from flask_login import login_required from flask_login import login_required
@ -85,7 +87,7 @@ def formsemestre_partitions(formsemestre_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id) formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
partitions = sorted(formsemestre.partitions, key=lambda p: p.numero or 0) partitions = sorted(formsemestre.partitions, key=attrgetter("numero"))
return jsonify( return jsonify(
{ {
partition.id: partition.to_dict(with_groups=True) partition.id: partition.to_dict(with_groups=True)
@ -441,9 +443,9 @@ def formsemestre_order_partitions(formsemestre_id: int):
message="paramètre liste des partitions invalide", message="paramètre liste des partitions invalide",
) )
for p_id, numero in zip(partition_ids, range(len(partition_ids))): for p_id, numero in zip(partition_ids, range(len(partition_ids))):
p = Partition.query.get_or_404(p_id) partition = Partition.query.get_or_404(p_id)
p.numero = numero partition.numero = numero
db.session.add(p) db.session.add(partition)
db.session.commit() db.session.commit()
app.set_sco_dept(formsemestre.departement.acronym) app.set_sco_dept(formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(formsemestre_id) sco_cache.invalidate_formsemestre(formsemestre_id)

View File

@ -7,9 +7,10 @@
""" """
Edition associations UE <-> Ref. Compétence Edition associations UE <-> Ref. Compétence
""" """
from flask import g, url_for from flask import g, render_template, url_for
from app.models import ApcReferentielCompetences, UniteEns from app.models import ApcReferentielCompetences, UniteEns
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
from app.forms.formation.ue_parcours_niveau import UEParcoursNiveauForm
def form_ue_choix_niveau(ue: UniteEns) -> str: def form_ue_choix_niveau(ue: UniteEns) -> str:
@ -32,7 +33,7 @@ def form_ue_choix_niveau(ue: UniteEns) -> str:
for parcour in ref_comp.parcours: for parcour in ref_comp.parcours:
parcours_options.append( parcours_options.append(
f"""<option value="{parcour.id}" { f"""<option value="{parcour.id}" {
'selected' if ue.parcour == parcour else ''} 'selected' if parcour in ue.parcours else ''}
>{parcour.libelle} ({parcour.code}) >{parcour.libelle} ({parcour.code})
</option>""" </option>"""
) )
@ -44,14 +45,14 @@ def form_ue_choix_niveau(ue: UniteEns) -> str:
<div class="cont_ue_choix_niveau"> <div class="cont_ue_choix_niveau">
<div> <div>
<b>Parcours&nbsp;:</b> <b>Parcours&nbsp;:</b>
<select class="select_parcour" <select class="select_parcour multiselect"
onchange="set_ue_parcour(this);" onchange="set_ue_parcour(this);"
data-ue_id="{ue.id}" data-ue_id="{ue.id}"
data-setter="{ data-setter="{
url_for( "notes.set_ue_parcours", scodoc_dept=g.scodoc_dept) url_for( "notes.set_ue_parcours", scodoc_dept=g.scodoc_dept)
}"> }">
<option value="" { <option value="" {
'selected' if ue.parcour is None else '' 'selected' if not ue.parcours else ''
}>Tous</option> }>Tous</option>
{newline.join(parcours_options)} {newline.join(parcours_options)}
</select> </select>
@ -72,6 +73,28 @@ def form_ue_choix_niveau(ue: UniteEns) -> str:
""" """
# Nouvelle version XXX WIP
def form_ue_choix_parcours_niveau(ue: UniteEns):
"""formulaire (div) pour choix association des parcours et du niveau de compétence d'une UE"""
if ue.type != codes_cursus.UE_STANDARD:
return ""
ref_comp = ue.formation.referentiel_competence
if ref_comp is None:
return f"""<div class="ue_choix_niveau">
<div class="warning">Pas de référentiel de compétence associé à cette formation !</div>
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
}">associer un référentiel de compétence</a>
</div>
</div>"""
parcours = ue.formation.referentiel_competence.parcours
form = UEParcoursNiveauForm(ue, parcours)
return f"""<div class="ue_choix_niveau">
{ render_template( "pn/ue_choix_parcours_niveau.j2", form_ue_parcours_niveau=form ) }
</div>
"""
def get_ue_niveaux_options_html(ue: UniteEns) -> str: def get_ue_niveaux_options_html(ue: UniteEns) -> str:
"""fragment html avec les options du menu de sélection du """fragment html avec les options du menu de sélection du
niveau de compétences associé à une UE. niveau de compétences associé à une UE.
@ -85,9 +108,7 @@ def get_ue_niveaux_options_html(ue: UniteEns) -> str:
return "" return ""
# Les niveaux: # Les niveaux:
annee = ue.annee() # 1, 2, 3 annee = ue.annee() # 1, 2, 3
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours( parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee, ue.parcours)
annee, parcour=ue.parcour
)
# Les niveaux déjà associés à d'autres UE du même semestre # Les niveaux déjà associés à d'autres UE du même semestre
autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx) autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)

View File

@ -24,7 +24,6 @@ from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp import res_sem from app.comp import res_sem
from app.models import formsemestre
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours, ApcAnneeParcours,
@ -32,6 +31,7 @@ from app.models.but_refcomp import (
ApcNiveau, ApcNiveau,
ApcParcours, ApcParcours,
ApcParcoursNiveauCompetence, ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
) )
from app.models import Scolog, ScolarAutorisationInscription from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import ( from app.models.but_validations import (
@ -109,7 +109,7 @@ class EtudCursusBUT:
"cache les niveaux" "cache les niveaux"
for annee in (1, 2, 3): for annee in (1, 2, 3):
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours( niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
annee, self.parcour annee, [self.parcour]
)[1] )[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour # groupe les niveaux de tronc commun et ceux spécifiques au parcour
self.niveaux_by_annee[annee] = niveaux_d["TC"] + ( self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
@ -170,6 +170,7 @@ class EtudCursusBUT:
} }
} }
""" """
# XXX lent, provisoirement utilisé par TableJury.add_but_competences()
return { return {
competence.id: { competence.id: {
annee: self.validation_par_competence_et_annee.get( annee: self.validation_par_competence_et_annee.get(
@ -185,7 +186,7 @@ class EtudCursusBUT:
""" """
{ {
competence_id : { competence_id : {
annee : { validation} annee : { validation }
} }
} }
validation est un petit dict avec niveau_id, etc. validation est un petit dict avec niveau_id, etc.
@ -204,3 +205,210 @@ class EtudCursusBUT:
validation_rcue.to_dict_codes() if validation_rcue else None validation_rcue.to_dict_codes() if validation_rcue else None
) )
return d return d
class FormSemestreCursusBUT:
"""L'état des étudiants d'un formsemestre dans leur cursus BUT
Permet d'obtenir pour chacun liste des niveaux validés/à valider
"""
def __init__(self, res: ResultatsSemestreBUT):
"""res indique le formsemestre de référence,
qui donne la liste des étudiants et le référentiel de compétence.
"""
self.res = res
self.formsemestre = res.formsemestre
if not res.formsemestre.formation.referentiel_competence:
raise ScoNoReferentielCompetences(formation=res.formsemestre.formation)
# Données cachées pour accélerer les accès:
self.referentiel_competences_id: int = (
self.res.formsemestre.formation.referentiel_competence_id
)
self.ue_ids: set[int] = set()
"set of ue_ids known to belong to our cursus"
self.parcours_by_id: dict[int, ApcParcours] = {}
"cache des parcours"
self.niveaux_by_parcour_by_annee: dict[int, dict[int, list[ApcNiveau]]] = {}
"cache { parcour_id : { annee : [ parcour] } }"
self.niveaux_by_id: dict[int, ApcNiveau] = {}
"cache niveaux"
def get_niveaux_parcours_etud(self, etud: Identite) -> dict[int, list[ApcNiveau]]:
"""Les niveaux compétences que doit valider cet étudiant.
Le parcour considéré est celui de l'inscription dans le semestre courant.
Si on est en début de cursus, on peut être en tronc commun sans avoir choisi
de parcours. Dans ce cas, on n'aura que les compétences de tronc commun.
Il faudra donc, avant de diplômer, s'assurer que les compétences du parcours
du dernier semestre (S6) sont validées (avec parcour non NULL).
"""
parcour_id = self.res.etuds_parcour_id.get(etud.id)
if parcour_id is None:
parcour = None
else:
if parcour_id not in self.parcours_by_id:
self.parcours_by_id[parcour_id] = ApcParcours.query.get(parcour_id)
parcour = self.parcours_by_id[parcour_id]
return self.get_niveaux_parcours_by_annee(parcour)
def get_niveaux_parcours_by_annee(
self, parcour: ApcParcours
) -> dict[int, list[ApcNiveau]]:
"""La liste des niveaux de compétences du parcours, par année BUT.
{ 1 : [ niveau, ... ] }
Si parcour est None, donne uniquement les niveaux tronc commun
(cas utile par exemple en 1ere année, mais surtout pas pour donner un diplôme!)
"""
parcour_id = None if parcour is None else parcour.id
if parcour_id in self.niveaux_by_parcour_by_annee:
return self.niveaux_by_parcour_by_annee[parcour_id]
ref_comp: ApcReferentielCompetences = (
self.res.formsemestre.formation.referentiel_competence
)
niveaux_by_annee = {}
for annee in (1, 2, 3):
niveaux_d = ref_comp.get_niveaux_by_parcours(annee, [parcour])[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[parcour.id] if parcour else []
)
self.niveaux_by_parcour_by_annee[parcour_id] = niveaux_by_annee
self.niveaux_by_id.update(
{niveau.id: niveau for niveau in niveaux_by_annee[annee]}
)
return niveaux_by_annee
def get_etud_validation_par_competence_et_annee(self, etud: Identite):
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_par_competence_et_annee = {}
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# On s'assurer qu'elle concerne notre cursus !
ue = validation_rcue.ue2
if ue.id not in self.ue_ids:
if (
ue.formation.referentiel_competences_id
== self.referentiel_competences_id
):
self.ue_ids = ue.id
else:
continue # skip this validation
niveau = validation_rcue.niveau()
if not niveau.competence.id in validation_par_competence_et_annee:
validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
return validation_par_competence_et_annee
def list_etud_inscriptions(self, etud: Identite):
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee = {}
"{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux"
for annee in (1, 2, 3):
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
annee, [self.parcour]
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[self.parcour.id] if self.parcour else []
)
self.niveaux.update(
{niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
)
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
self.competences = {
competence.id: competence
for competence in (
self.parcour.query_competences()
if self.parcour
else self.formation.referentiel_competence.get_competences_tronc_commun()
)
}
"cache { competence_id : competence }"
def formsemestre_warning_apc_setup(
formsemestre: FormSemestre, res: ResultatsSemestreBUT
) -> str:
"""Vérifie que la formation est OK pour un BUT:
- ref. compétence associé
- tous les niveaux des parcours du semestre associés à des UEs du formsemestre
- pas d'UE non associée à un niveau
Renvoie fragment de HTML.
"""
if not formsemestre.formation.is_apc():
return ""
if formsemestre.formation.referentiel_competence is None:
return f"""<div class="formsemestre_status_warning">
La <a class=stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">formation n'est pas associée à un référentiel de compétence.</a>
</div>
"""
# Vérifie les niveaux de chaque parcours
H = []
for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = {
niveau.id
for niveau in ApcNiveau.niveaux_annee_de_parcours(
parcour, annee, formsemestre.formation.referentiel_competence
)
}
ues_parcour = formsemestre.formation.query_ues_parcour(parcour).filter(
UniteEns.semestre_idx == formsemestre.semestre_id
)
ues_niveaux_ids = {
ue.niveau_competence.id for ue in ues_parcour if ue.niveau_competence
}
if niveaux_ids != ues_niveaux_ids:
H.append(
f"""Parcours {parcour.code if parcour else "Tronc commun"} :
{len(ues_niveaux_ids)} UE avec niveaux
mais {len(niveaux_ids)} niveaux à valider !
"""
)
if not H:
return ""
return f"""<div class="formsemestre_status_warning">
Problème dans la configuration de la formation:
<ul>
<li>{ '<li></li>'.join(H) }</li>
</ul>
<p class="help">Vérifiez les parcours cochés pour ce semestre,
et les associations entre UE et niveaux <a class=stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">dans la formation.
</p>
</div>
"""

View File

@ -324,7 +324,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
parcours, parcours,
niveaux_by_parcours, niveaux_by_parcours,
) = formation.referentiel_competence.get_niveaux_by_parcours( ) = formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, self.parcour self.annee_but, [self.parcour]
) )
self.niveaux_competences = niveaux_by_parcours["TC"] + ( self.niveaux_competences = niveaux_by_parcours["TC"] + (
niveaux_by_parcours[self.parcour.id] if self.parcour else [] niveaux_by_parcours[self.parcour.id] if self.parcour else []
@ -1003,7 +1003,7 @@ def list_ue_parcour_etud(
parcour = ApcParcours.query.get(res.etuds_parcour_id[etud.id]) parcour = ApcParcours.query.get(res.etuds_parcour_id[etud.id])
ues = ( ues = (
formsemestre.formation.query_ues_parcour(parcour) formsemestre.formation.query_ues_parcour(parcour)
.filter_by(semestre_idx=formsemestre.semestre_id) .filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.order_by(UniteEns.numero) .order_by(UniteEns.numero)
.all() .all()
) )

View File

@ -228,14 +228,14 @@ class BonusSportAdditif(BonusSport):
# en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus) # en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus)
if self.formsemestre.formation.is_apc(): if self.formsemestre.formation.is_apc():
# Bonus sur les UE et None sur moyenne générale # Bonus sur les UE et None sur moyenne générale
ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)]
self.bonus_ues = pd.DataFrame( self.bonus_ues = pd.DataFrame(
bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float
) )
elif self.classic_use_bonus_ues: elif self.classic_use_bonus_ues:
# Formations classiques apppliquant le bonus sur les UEs # Formations classiques apppliquant le bonus sur les UEs
# ici bonus_moy_arr = ndarray 1d nb_etuds # ici bonus_moy_arr = ndarray 1d nb_etuds
ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)]
self.bonus_ues = pd.DataFrame( self.bonus_ues = pd.DataFrame(
np.stack([bonus_moy_arr] * len(ues_idx)).T, np.stack([bonus_moy_arr] * len(ues_idx)).T,
index=self.etuds_idx, index=self.etuds_idx,
@ -420,7 +420,7 @@ class BonusAmiens(BonusSportAdditif):
# # Bonus moyenne générale et sur les UE # # Bonus moyenne générale et sur les UE
# self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float) # self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float)
# ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] # ues_idx = [ue.id for ue in self.formsemestre.get_ues(with_sport=False)]
# nb_ues_no_bonus = len(ues_idx) # nb_ues_no_bonus = len(ues_idx)
# self.bonus_ues = pd.DataFrame( # self.bonus_ues = pd.DataFrame(
# np.stack([bonus] * nb_ues_no_bonus, axis=1), # np.stack([bonus] * nb_ues_no_bonus, axis=1),
@ -597,7 +597,7 @@ class BonusCachan1(BonusSportAdditif):
sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0]
# ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic
note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds
ues = self.formsemestre.query_ues(with_sport=False).all() ues = self.formsemestre.get_ues(with_sport=False)
ues_idx = [ue.id for ue in ues] ues_idx = [ue.id for ue in ues]
if self.formsemestre.formation.is_apc(): # --- BUT if self.formsemestre.formation.is_apc(): # --- BUT
@ -687,7 +687,7 @@ class BonusCalais(BonusSportAdditif):
else: else:
self.classic_use_bonus_ues = True # pour les LP self.classic_use_bonus_ues = True # pour les LP
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
ues = self.formsemestre.query_ues(with_sport=False).all() ues = self.formsemestre.get_ues(with_sport=False)
ues_sans_bs = [ ues_sans_bs = [
ue for ue in ues if ue.acronyme[-2:].upper() != "BS" ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
] # les 2 derniers cars forcés en majus ] # les 2 derniers cars forcés en majus
@ -788,7 +788,7 @@ class BonusIUTRennes1(BonusSportAdditif):
sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0]
# ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic
note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds
nb_ues = self.formsemestre.query_ues(with_sport=False).count() nb_ues = len(self.formsemestre.get_ues(with_sport=False))
bonus_moy_arr = np.where( bonus_moy_arr = np.where(
note_bonus_max > self.seuil_moy_gen, note_bonus_max > self.seuil_moy_gen,

View File

@ -409,7 +409,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
""" """
modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all()
ues = modimpl.formsemestre.query_ues(with_sport=False).all() ues = modimpl.formsemestre.get_ues(with_sport=False)
ue_ids = [ue.id for ue in ues] ue_ids = [ue.id for ue in ues]
evaluation_ids = [evaluation.id for evaluation in evaluations] evaluation_ids = [evaluation.id for evaluation in evaluations]
evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float)

View File

@ -121,7 +121,7 @@ def df_load_modimpl_coefs(
DataFrame rows = UEs (sans bonus), columns = modimpl, value = coef. DataFrame rows = UEs (sans bonus), columns = modimpl, value = coef.
""" """
if ues is None: if ues is None:
ues = formsemestre.query_ues().all() ues = formsemestre.get_ues()
ue_ids = [x.id for x in ues] ue_ids = [x.id for x in ues]
if modimpls is None: if modimpls is None:
modimpls = formsemestre.modimpls_sorted modimpls = formsemestre.modimpls_sorted

View File

@ -247,9 +247,9 @@ class ResultatsSemestreBUT(NotesTableCompat):
) in self.formsemestre.formation.referentiel_competence.parcours.all() + [None]: ) in self.formsemestre.formation.referentiel_competence.parcours.all() + [None]:
ue_by_parcours[None if parcour is None else parcour.id] = { ue_by_parcours[None if parcour is None else parcour.id] = {
ue.id: 1.0 ue.id: 1.0
for ue in self.formsemestre.formation.query_ues_parcour( for ue in self.formsemestre.formation.query_ues_parcour(parcour).filter(
parcour UniteEns.semestre_idx == self.formsemestre.semestre_id
).filter_by(semestre_idx=self.formsemestre.semestre_id) )
} }
# #
for etudid in etuds_parcour_id: for etudid in etuds_parcour_id:
@ -290,7 +290,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
ues_parcour = self.formsemestre.formation.query_ues_parcour(parcour) ues_parcour = self.formsemestre.formation.query_ues_parcour(parcour)
ues_ids = set() ues_ids = set()
for niveau in niveaux: for niveau in niveaux:
ue = ues_parcour.filter_by(niveau_competence=niveau).first() ue = ues_parcour.filter_by(UniteEns.niveau_competence == niveau).first()
if ue: if ue:
ues_ids.add(ue.id) ues_ids.add(ue.id)

View File

@ -10,6 +10,8 @@
from collections import Counter, defaultdict from collections import Counter, defaultdict
from collections.abc import Generator from collections.abc import Generator
from functools import cached_property from functools import cached_property
from operator import attrgetter
import numpy as np import numpy as np
import pandas as pd import pandas as pd
@ -162,7 +164,7 @@ class ResultatsSemestre(ResultatsCache):
(indices des DataFrames). (indices des DataFrames).
Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs. Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs.
""" """
return self.formsemestre.query_ues(with_sport=True).all() return self.formsemestre.get_ues(with_sport=True)
@cached_property @cached_property
def ressources(self): def ressources(self):
@ -233,7 +235,7 @@ class ResultatsSemestre(ResultatsCache):
for modimpl in self.formsemestre.modimpls_sorted for modimpl in self.formsemestre.modimpls_sorted
if self.modimpl_inscr_df[modimpl.id][etudid] if self.modimpl_inscr_df[modimpl.id][etudid]
} }
ues = sorted(list(ues), key=lambda x: x.numero or 0) ues = sorted(list(ues), key=attrgetter("numero"))
return ues return ues
def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]: def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]:
@ -283,7 +285,7 @@ class ResultatsSemestre(ResultatsCache):
# Quand il y a une capitalisation, vérifie toutes les UEs # Quand il y a une capitalisation, vérifie toutes les UEs
sum_notes_ue = 0.0 sum_notes_ue = 0.0
sum_coefs_ue = 0.0 sum_coefs_ue = 0.0
for ue in self.formsemestre.query_ues(): for ue in self.formsemestre.get_ues():
ue_cap = self.get_etud_ue_status(etudid, ue.id) ue_cap = self.get_etud_ue_status(etudid, ue.id)
if ue_cap is None: if ue_cap is None:
continue continue

View File

@ -108,7 +108,7 @@ class NotesTableCompat(ResultatsSemestre):
Si filter_sport, retire les UE de type SPORT. Si filter_sport, retire les UE de type SPORT.
Résultat: liste de dicts { champs UE U stats moyenne UE } Résultat: liste de dicts { champs UE U stats moyenne UE }
""" """
ues = self.formsemestre.query_ues(with_sport=not filter_sport) ues = self.formsemestre.get_ues(with_sport=not filter_sport)
ues_dict = [] ues_dict = []
for ue in ues: for ue in ues:
d = ue.to_dict() d = ue.to_dict()
@ -178,7 +178,7 @@ class NotesTableCompat(ResultatsSemestre):
self.etud_moy_gen_ranks, self.etud_moy_gen_ranks,
self.etud_moy_gen_ranks_int, self.etud_moy_gen_ranks_int,
) = moy_sem.comp_ranks_series(etud_moy_gen_dem_zero) ) = moy_sem.comp_ranks_series(etud_moy_gen_dem_zero)
ues = self.formsemestre.query_ues() ues = self.formsemestre.get_ues()
for ue in ues: for ue in ues:
moy_ue = self.etud_moy_ue[ue.id] moy_ue = self.etud_moy_ue[ue.id]
self.ue_rangs[ue.id] = ( self.ue_rangs[ue.id] = (
@ -260,7 +260,7 @@ class NotesTableCompat(ResultatsSemestre):
Return: True|False, message explicatif Return: True|False, message explicatif
""" """
ue_status_list = [] ue_status_list = []
for ue in self.formsemestre.query_ues(): for ue in self.formsemestre.get_ues():
ue_status = self.get_etud_ue_status(etudid, ue.id) ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status: if ue_status:
ue_status_list.append(ue_status) ue_status_list.append(ue_status)
@ -477,7 +477,7 @@ class NotesTableCompat(ResultatsSemestre):
""" """
table_moyennes = [] table_moyennes = []
etuds_inscriptions = self.formsemestre.etuds_inscriptions etuds_inscriptions = self.formsemestre.etuds_inscriptions
ues = self.formsemestre.query_ues(with_sport=True) # avec bonus ues = self.formsemestre.get_ues(with_sport=True) # avec bonus
for etudid in etuds_inscriptions: for etudid in etuds_inscriptions:
moy_gen = self.etud_moy_gen.get(etudid, False) moy_gen = self.etud_moy_gen.get(etudid, False)
if moy_gen is False: if moy_gen is False:

View File

@ -0,0 +1,39 @@
from flask import g, url_for
from flask_wtf import FlaskForm
from wtforms.fields import SelectField, SelectMultipleField
from app.models import ApcParcours, ApcReferentielCompetences, UniteEns
class UEParcoursNiveauForm(FlaskForm):
"Formulaire association parcours et niveau de compétence à une UE"
niveau_select = SelectField(
"Niveau de compétence:", render_kw={"class": "niveau_select"}
)
parcours_multiselect = SelectMultipleField(
"Parcours :",
coerce=int,
option_widget={"class": "form-check-input"},
# widget_attrs={"class": "form-check"},
render_kw={"class": "multiselect select_ue_parcours", "multiple": "multiple"},
)
def __init__(self, ue: UniteEns, parcours: list[ApcParcours], *args, **kwargs):
super().__init__(*args, **kwargs)
# Initialise le menu des niveaux:
self.niveau_select.render_kw["data-ue_id"] = ue.id
self.niveau_select.choices = [
(r.id, f"{r.type_titre} {r.specialite_long} ({r.get_version()})")
for r in ApcReferentielCompetences.query.filter_by(dept_id=g.scodoc_dept_id)
]
# Initialise le menu des parcours
self.parcours_multiselect.render_kw["data-set_ue_parcours"] = url_for(
"apiweb.set_ue_parcours", ue_id=ue.id, scodoc_dept=g.scodoc_dept
)
parcours_options = [(str(p.id), f"{p.libelle} ({p.code})") for p in parcours]
self.parcours_multiselect.choices = parcours_options
# initialize checked items based on u instance
parcours_selected = [str(p.id) for p in ue.parcours]
self.parcours_multiselect.process_data(parcours_selected)

View File

@ -29,14 +29,14 @@ Formulaire changement formation
""" """
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import RadioField, SubmitField, validators from wtforms import RadioField, SubmitField
from app.models import Formation from app.models import Formation
class FormSemestreChangeFormationForm(FlaskForm): class FormSemestreChangeFormationForm(FlaskForm):
"Formulaire changement formation d'un formsemestre" "Formulaire changement formation d'un formsemestre"
# consrtuit dynamiquement ci-dessous # construit dynamiquement ci-dessous
def gen_formsemestre_change_formation_form( def gen_formsemestre_change_formation_form(

View File

@ -6,6 +6,7 @@
"""ScoDoc 9 models : Référentiel Compétence BUT 2021 """ScoDoc 9 models : Référentiel Compétence BUT 2021
""" """
from datetime import datetime from datetime import datetime
from operator import attrgetter
import flask_sqlalchemy import flask_sqlalchemy
from sqlalchemy.orm import class_mapper from sqlalchemy.orm import class_mapper
@ -129,11 +130,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
} }
def get_niveaux_by_parcours( def get_niveaux_by_parcours(
self, annee: int, parcour: "ApcParcours" = None self, annee: int, parcours: list["ApcParcours"] = None
) -> tuple[list["ApcParcours"], dict]: ) -> tuple[list["ApcParcours"], dict]:
""" """
Construit la liste des niveaux de compétences pour chaque parcours Construit la liste des niveaux de compétences pour chaque parcours
de ce référentiel, ou seulement pour le parcours donné. de ce référentiel, ou seulement pour les parcours donnés.
Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun. Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
@ -150,10 +151,8 @@ class ApcReferentielCompetences(db.Model, XMLModel):
) )
""" """
parcours_ref = self.parcours.order_by(ApcParcours.numero).all() parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
if parcour is None: if parcours is None:
parcours = parcours_ref parcours = parcours_ref
else:
parcours = [parcour]
niveaux_by_parcours = { niveaux_by_parcours = {
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self) parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
for parcour in parcours_ref for parcour in parcours_ref
@ -205,7 +204,7 @@ class ApcReferentielCompetences(db.Model, XMLModel):
for competence in parcours[0].query_competences() for competence in parcours[0].query_competences()
if competence.id in ids if competence.id in ids
], ],
key=lambda c: c.numero or 0, key=attrgetter("numero"),
) )
def table_niveaux_parcours(self) -> dict: def table_niveaux_parcours(self) -> dict:
@ -241,7 +240,7 @@ class ApcCompetence(db.Model, XMLModel):
titre = db.Column(db.Text(), nullable=False, index=True) titre = db.Column(db.Text(), nullable=False, index=True)
titre_long = db.Column(db.Text()) titre_long = db.Column(db.Text())
couleur = db.Column(db.Text()) couleur = db.Column(db.Text())
numero = db.Column(db.Integer) # ordre de présentation numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
_xml_attribs = { # xml_attrib : attribute _xml_attribs = { # xml_attrib : attribute
"id": "id_orebut", "id": "id_orebut",
"nom_court": "titre", # was name "nom_court": "titre", # was name
@ -523,7 +522,7 @@ class ApcParcours(db.Model, XMLModel):
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"), db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
nullable=False, nullable=False,
) )
numero = db.Column(db.Integer) # ordre de présentation numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
code = db.Column(db.Text(), nullable=False) code = db.Column(db.Text(), nullable=False)
libelle = db.Column(db.Text(), nullable=False) libelle = db.Column(db.Text(), nullable=False)
annees = db.relationship( annees = db.relationship(
@ -532,7 +531,6 @@ class ApcParcours(db.Model, XMLModel):
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
ues = db.relationship("UniteEns", back_populates="parcour")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>" return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"

View File

@ -3,6 +3,7 @@
"""ScoDoc models: evaluations """ScoDoc models: evaluations
""" """
import datetime import datetime
from operator import attrgetter
from app import db from app import db
from app.models.etudiants import Identite from app.models.etudiants import Identite
@ -44,7 +45,7 @@ class Evaluation(db.Model):
) )
# ordre de presentation (par défaut, le plus petit numero # ordre de presentation (par défaut, le plus petit numero
# est la plus ancienne eval): # est la plus ancienne eval):
numero = db.Column(db.Integer) numero = db.Column(db.Integer, nullable=False, default=0)
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True) ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
def __repr__(self): def __repr__(self):
@ -151,7 +152,7 @@ class Evaluation(db.Model):
Return True if (uncommited) modification, False otherwise. Return True if (uncommited) modification, False otherwise.
""" """
ue_coef_dict = self.moduleimpl.module.get_ue_coef_dict() ue_coef_dict = self.moduleimpl.module.get_ue_coef_dict()
sem_ues = self.moduleimpl.formsemestre.query_ues(with_sport=False).all() sem_ues = self.moduleimpl.formsemestre.get_ues(with_sport=False)
modified = False modified = False
for ue in sem_ues: for ue in sem_ues:
existing_poids = EvaluationUEPoids.query.filter_by( existing_poids = EvaluationUEPoids.query.filter_by(
@ -196,7 +197,7 @@ class Evaluation(db.Model):
return { return {
p.ue.id: p.poids p.ue.id: p.poids
for p in sorted( for p in sorted(
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme) self.ue_poids, key=lambda p: attrgetter("ue.numero", "ue.acronyme")
) )
} }

View File

@ -9,13 +9,12 @@ from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours, ApcAnneeParcours,
ApcCompetence, ApcCompetence,
ApcNiveau,
ApcParcours, ApcParcours,
ApcParcoursNiveauCompetence, ApcParcoursNiveauCompetence,
) )
from app.models.modules import Module from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns from app.models.ues import UniteEns, UEParcours
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -213,23 +212,36 @@ class Formation(db.Model):
if change: if change:
app.clear_scodoc_cache() app.clear_scodoc_cache()
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery: def query_ues_parcour(
"""Les UEs d'un parcours de la formation. self, parcour: ApcParcours, with_sport: bool = False
) -> flask_sqlalchemy.BaseQuery:
"""Les UEs (non bonus) d'un parcours de la formation
(déclarée comme faisant partie du parcours ou du tronc commun, sans aucun parcours)
Si parcour est None, les UE sans parcours. Si parcour est None, les UE sans parcours.
Exemple: pour avoir les UE du semestre 3, faire Exemple: pour avoir les UE du semestre 3, faire
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)` `formation.query_ues_parcour(parcour).filter(UniteEns.semestre_idx == 3)`
""" """
if with_sport:
query_f = UniteEns.query.filter_by(formation=self)
else:
query_f = UniteEns.query.filter_by(formation=self, type=UE_STANDARD)
# Les UE sans parcours:
query_no_parcours = query_f.outerjoin(UEParcours).filter(
UEParcours.parcours_id == None
)
if parcour is None: if parcour is None:
return UniteEns.query.filter_by( return query_no_parcours.order_by(UniteEns.numero)
formation=self, type=UE_STANDARD, parcour_id=None # Ajoute les UE du parcours sélectionné:
) return query_no_parcours.union(
return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter( query_f.join(UEParcours).filter_by(parcours_id=parcour.id)
UniteEns.niveau_competence_id == ApcNiveau.id, ).order_by(UniteEns.numero)
(UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None), # return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id, # UniteEns.niveau_competence_id == ApcNiveau.id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, # (UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
ApcAnneeParcours.parcours_id == parcour.id, # ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
) # ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
# ApcAnneeParcours.parcours_id == parcour.id,
# )
def query_competences_parcour( def query_competences_parcour(
self, parcour: ApcParcours self, parcour: ApcParcours
@ -281,7 +293,7 @@ class Matiere(db.Model):
matiere_id = db.synonym("id") matiere_id = db.synonym("id")
ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id")) ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"))
titre = db.Column(db.Text()) titre = db.Column(db.Text())
numero = db.Column(db.Integer) # ordre de présentation numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
modules = db.relationship("Module", lazy="dynamic", backref="matiere") modules = db.relationship("Module", lazy="dynamic", backref="matiere")

View File

@ -12,6 +12,7 @@
""" """
import datetime import datetime
from functools import cached_property from functools import cached_property
from operator import attrgetter
from flask_login import current_user from flask_login import current_user
import flask_sqlalchemy import flask_sqlalchemy
@ -281,26 +282,34 @@ class FormSemestre(db.Model):
) )
return r or [] return r or []
def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery: def get_ues(self, with_sport=False) -> list[UniteEns]:
"""UE des modules de ce semestre, triées par numéro. """UE des modules de ce semestre, triées par numéro.
- Formations classiques: les UEs auxquelles appartiennent - Formations classiques: les UEs auxquelles appartiennent
les modules mis en place dans ce semestre. les modules mis en place dans ce semestre.
- Formations APC / BUT: les UEs de la formation qui - Formations APC / BUT: les UEs de la formation qui
- ont le même numéro de semestre que ce formsemestre - ont le même numéro de semestre que ce formsemestre;
- sont associées à l'un des parcours de ce formsemestre (ou à aucun) - et sont associées à l'un des parcours de ce formsemestre
(ou à aucun, donc tronc commun).
""" """
if self.formation.get_cursus().APC_SAE: formation: Formation = self.formation
sem_ues = UniteEns.query.filter_by( if formation.is_apc():
formation=self.formation, semestre_idx=self.semestre_id sem_ues = {
ue.id: ue
for ue in formation.query_ues_parcour(
None, with_sport=with_sport
).filter(UniteEns.semestre_idx == self.semestre_id)
}
for parcour in self.parcours:
sem_ues.update(
{
ue.id: ue
for ue in formation.query_ues_parcour(
parcour, with_sport=with_sport
).filter(UniteEns.semestre_idx == self.semestre_id)
}
) )
if self.parcours: ues = sem_ues.values()
# Prend toutes les UE de l'un des parcours du sem., ou déclarées sans parcours return sorted(ues, key=attrgetter("numero"))
sem_ues = sem_ues.filter(
(UniteEns.parcour == None)
| (UniteEns.parcour_id.in_([p.id for p in self.parcours]))
)
# si le sem. ne coche aucun parcours, prend toutes les UE
else: else:
sem_ues = db.session.query(UniteEns).filter( sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id, ModuleImpl.formsemestre_id == self.id,
@ -309,32 +318,7 @@ class FormSemestre(db.Model):
) )
if not with_sport: if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT) sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
return sem_ues.order_by(UniteEns.numero) return sem_ues.order_by(UniteEns.numero).all()
def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
"""XXX inutilisé à part pour un test unitaire => supprimer ?
UEs que suit l'étudiant dans ce semestre BUT
en fonction du parcours dans lequel il est inscrit.
Si l'étudiant n'est inscrit à aucun parcours,
renvoie uniquement les UEs de tronc commun (sans parcours).
Si voulez les UE d'un parcours, il est plus efficace de passer par
`formation.query_ues_parcour(parcour)`.
"""
return self.query_ues().filter(
FormSemestreInscription.etudid == etudid,
FormSemestreInscription.formsemestre == self,
UniteEns.niveau_competence_id == ApcNiveau.id,
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
or_(
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
and_(
FormSemestreInscription.parcour_id.is_(None),
UniteEns.parcour_id.is_(None),
),
),
)
@cached_property @cached_property
def modimpls_sorted(self) -> list[ModuleImpl]: def modimpls_sorted(self) -> list[ModuleImpl]:
@ -960,7 +944,7 @@ class FormationModalite(db.Model):
) # code ) # code
titre = db.Column(db.Text()) # texte explicatif titre = db.Column(db.Text()) # texte explicatif
# numero = ordre de presentation) # numero = ordre de presentation)
numero = db.Column(db.Integer) numero = db.Column(db.Integer, nullable=False, default=0)
@staticmethod @staticmethod
def insert_modalites(): def insert_modalites():

View File

@ -7,6 +7,7 @@
"""ScoDoc models: Groups & partitions """ScoDoc models: Groups & partitions
""" """
from operator import attrgetter
from app import db from app import db
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
@ -29,7 +30,7 @@ class Partition(db.Model):
# "TD", "TP", ... (NULL for 'all') # "TD", "TP", ... (NULL for 'all')
partition_name = db.Column(db.String(SHORT_STR_LEN)) partition_name = db.Column(db.String(SHORT_STR_LEN))
# Numero = ordre de presentation) # Numero = ordre de presentation)
numero = db.Column(db.Integer) numero = db.Column(db.Integer, nullable=False, default=0)
# Calculer le rang ? # Calculer le rang ?
bul_show_rank = db.Column( bul_show_rank = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" db.Boolean(), nullable=False, default=False, server_default="false"
@ -92,7 +93,7 @@ class Partition(db.Model):
d.pop("formsemestre", None) d.pop("formsemestre", None)
if with_groups: if with_groups:
groups = sorted(self.groups, key=lambda g: (g.numero or 0, g.group_name)) groups = sorted(self.groups, key=attrgetter("numero", "group_name"))
# un dict et non plus une liste, pour JSON # un dict et non plus une liste, pour JSON
d["groups"] = { d["groups"] = {
group.id: group.to_dict(with_partition=False) for group in groups group.id: group.to_dict(with_partition=False) for group in groups
@ -121,7 +122,7 @@ class GroupDescr(db.Model):
# "A", "C2", ... (NULL for 'all'): # "A", "C2", ... (NULL for 'all'):
group_name = db.Column(db.String(GROUPNAME_STR_LEN)) group_name = db.Column(db.String(GROUPNAME_STR_LEN))
# Numero = ordre de presentation # Numero = ordre de presentation
numero = db.Column(db.Integer) numero = db.Column(db.Integer, nullable=False, default=0)
etuds = db.relationship( etuds = db.relationship(
"Identite", "Identite",

View File

@ -33,7 +33,7 @@ class Module(db.Model):
# pas un id mais le numéro du semestre: 1, 2, ... # pas un id mais le numéro du semestre: 1, 2, ...
# note: en APC, le semestre qui fait autorité est celui de l'UE # note: en APC, le semestre qui fait autorité est celui de l'UE
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
numero = db.Column(db.Integer) # ordre de présentation numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
# id de l'element pedagogique Apogee correspondant: # id de l'element pedagogique Apogee correspondant:
code_apogee = db.Column(db.String(APO_CODE_STR_LEN)) code_apogee = db.Column(db.String(APO_CODE_STR_LEN))
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum) # Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)

View File

@ -21,7 +21,7 @@ class UniteEns(db.Model):
ue_id = db.synonym("id") ue_id = db.synonym("id")
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
acronyme = db.Column(db.Text(), nullable=False) acronyme = db.Column(db.Text(), nullable=False)
numero = db.Column(db.Integer) # ordre de présentation numero = db.Column(db.Integer, nullable=False, default=0) # ordre de présentation
titre = db.Column(db.Text()) titre = db.Column(db.Text())
# Le semestre_idx n'est pas un id mais le numéro du semestre: 1, 2, ... # Le semestre_idx n'est pas un id mais le numéro du semestre: 1, 2, ...
# En ScoDoc7 et pour les formations classiques, il est NULL # En ScoDoc7 et pour les formations classiques, il est NULL
@ -56,11 +56,10 @@ class UniteEns(db.Model):
) )
niveau_competence = db.relationship("ApcNiveau", back_populates="ues") niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
# Une ue appartient soit à tous les parcours (tronc commun), soit à un seul: # Une UE appartient soit à tous les parcours (tronc commun), soit à un sous-ensemble
parcour_id = db.Column( parcours = db.relationship(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True ApcParcours, secondary="ue_parcours", backref=db.backref("ues", lazy=True)
) )
parcour = db.relationship("ApcParcours", back_populates="ues")
# relations # relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue") matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
@ -115,7 +114,9 @@ class UniteEns(db.Model):
e["ects"] = e["ects"] e["ects"] = e["ects"]
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None e["code_apogee"] = e["code_apogee"] or "" # pas de None
e["parcour"] = self.parcour.to_dict(with_annees=False) if self.parcour else None e["parcours"] = [
parcour.to_dict(with_annees=False) for parcour in self.parcours
]
if with_module_ue_coefs: if with_module_ue_coefs:
if convert_objects: if convert_objects:
e["module_ue_coefs"] = [ e["module_ue_coefs"] = [
@ -184,78 +185,142 @@ class UniteEns(db.Model):
return {x.strip() for x in self.code_apogee.split(",") if x} return {x.strip() for x in self.code_apogee.split(",") if x}
return set() return set()
def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int): def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
"raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre" """set des ids de niveaux dans les parcours listés"""
# Les UE du même semestre que nous: return set.union(
ues_sem = self.formation.ues.filter_by(semestre_idx=self.semestre_idx) *[
if (new_niveau_id, new_parcour_id) in ( {
(oue.niveau_competence_id, oue.parcour_id) n.id
for oue in ues_sem for n in self.niveau_competence.niveaux_annee_de_parcours(
if oue.id != self.id parcour, self.annee(), self.formation.referentiel_competence
): )
log( }
f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé" for parcour in parcours
]
) )
raise ScoFormationConflict()
def set_niveau_competence(self, niveau: ApcNiveau): def check_niveau_unique_dans_parcours(
self, niveau: ApcNiveau, parcours=list[ApcParcours]
) -> tuple[bool, str]:
"""Vérifie que
- le niveau est dans au moins l'un des parcours listés;
- et que l'un des parcours associé à cette UE ne contient pas
déjà une UE associée au niveau donné dans une autre année.
Renvoie: (True, "") si ok, sinon (False, message).
"""
# Le niveau est-il dans l'un des parcours listés ?
if parcours:
if niveau.id not in self._parcours_niveaux_ids(parcours):
log(
f"Le niveau {niveau} ne fait pas partie des parcours de l'UE {self}."
)
return (
False,
f"""Le niveau {
niveau.libelle} ne fait pas partie des parcours de l'UE {self.acronyme}.""",
)
for parcour in parcours or [None]:
if parcour is None:
code_parcour = "TC"
ues_meme_niveau = [
ue
for ue in self.formation.query_ues_parcour(None).filter(
UniteEns.niveau_competence == niveau
)
]
else:
code_parcour = parcour.code
ues_meme_niveau = [
ue
for ue in parcour.ues
if ue.formation_id == self.formation_id
and ue.niveau_competence_id == niveau.id
]
if ues_meme_niveau:
if len(ues_meme_niveau) > 1: # deja 2 UE sur ce niveau
msg = f"""Niveau "{
niveau.libelle}" déjà associé à deux UE du parcours {code_parcour}"""
log("check_niveau_unique_dans_parcours: " + msg)
return False, msg
# s'il y a déjà une UE associée à ce niveau, elle doit être dans l'autre semestre
# de la même année scolaire
other_semestre_idx = self.semestre_idx + (
2 * (self.semestre_idx % 2) - 1
)
if ues_meme_niveau[0].semestre_idx != other_semestre_idx:
msg = f"""Niveau "{
niveau.libelle}" associé à une autre année du parcours {code_parcour}"""
log("check_niveau_unique_dans_parcours: " + msg)
return False, msg
return True, ""
def set_niveau_competence(self, niveau: ApcNiveau) -> tuple[bool, str]:
"""Associe cette UE au niveau de compétence indiqué. """Associe cette UE au niveau de compétence indiqué.
Le niveau doit être dans le parcours de l'UE, s'il y en a un. Le niveau doit être dans l'un des parcours de l'UE (si elle n'est pas
de tronc commun).
Assure que ce soit la seule dans son parcours. Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict. Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie. Si niveau est None, désassocie.
Returns True if (de)association done, False on error.
""" """
if niveau.id == self.niveau_competence_id:
return True # nothing to do
if niveau is not None: if niveau is not None:
self._check_apc_conflict(niveau.id, self.parcour_id) ok, error_message = self.check_niveau_unique_dans_parcours(
# Le niveau est-il dans le parcours ? Sinon, erreur niveau, self.parcours
if self.parcour and niveau.id not in (
n.id
for n in niveau.niveaux_annee_de_parcours(
self.parcour, self.annee(), self.formation.referentiel_competence
) )
): if not ok:
log( return ok, error_message
f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}"
)
return
self.niveau_competence = niveau self.niveau_competence = niveau
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
# Invalidation du cache # Invalidation du cache
self.formation.invalidate_cached_sems() self.formation.invalidate_cached_sems()
log(f"ue.set_niveau_competence( {self}, {niveau} )") log(f"ue.set_niveau_competence( {self}, {niveau} )")
return True, ""
def set_parcour(self, parcour: ApcParcours): def set_parcours(self, parcours: list[ApcParcours]) -> tuple[bool, str]:
"""Associe cette UE au parcours indiqué. """Associe cette UE aux parcours indiqués.
Assure que ce soit la seule dans son parcours. Si un niveau est déjà associé, vérifie sa cohérence.
Sinon, raises ScoFormationConflict. Renvoie (True, "") si ok, sinon (False, error_message)
Si niveau est None, désassocie.
""" """
if (parcour is not None) and self.niveau_competence is not None:
self._check_apc_conflict(self.niveau_competence.id, parcour.id)
self.parcour = parcour
# Le niveau est-il dans ce parcours ? Sinon, l'enlève # Le niveau est-il dans ce parcours ? Sinon, l'enlève
# breakpoint()
if ( if (
parcour parcours
and self.niveau_competence and self.niveau_competence
and self.niveau_competence.id and self.niveau_competence.id not in self._parcours_niveaux_ids(parcours)
not in (
n.id
for n in self.niveau_competence.niveaux_annee_de_parcours(
parcour, self.annee(), self.formation.referentiel_competence
)
)
): ):
self.niveau_competence = None self.niveau_competence = None
if parcours and self.niveau_competence:
ok, error_message = self.check_niveau_unique_dans_parcours(
self.niveau_competence, parcours
)
if not ok:
return False, error_message
self.parcours = parcours
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
# Invalidation du cache # Invalidation du cache
self.formation.invalidate_cached_sems() self.formation.invalidate_cached_sems()
log(f"ue.set_parcour( {self}, {parcour} )") log(f"ue.set_parcours( {self}, {parcours} )")
return True, ""
class UEParcours(db.Model):
"""Association ue <-> parcours, indiquant les ECTS"""
__tablename__ = "ue_parcours"
ue_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), primary_key=True)
parcours_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
)
ects = db.Column(db.Float, nullable=True) # si NULL, on prendra les ECTS de l'UE
class DispenseUE(db.Model): class DispenseUE(db.Model):

View File

@ -1009,10 +1009,7 @@ class ApoData(object):
] ]
) )
codes_ues = set().union( codes_ues = set().union(
*[ *[ue.get_codes_apogee() for ue in formsemestre.get_ues(with_sport=True)]
ue.get_codes_apogee()
for ue in formsemestre.query_ues(with_sport=True)
]
) )
s = set() s = set()
codes_by_sem[sem["formsemestre_id"]] = s codes_by_sem[sem["formsemestre_id"]] = s

View File

@ -107,7 +107,7 @@ def html_edit_formation_apc(
icons=icons, icons=icons,
ues_by_sem=ues_by_sem, ues_by_sem=ues_by_sem,
ects_by_sem=ects_by_sem, ects_by_sem=ects_by_sem,
form_ue_choix_niveau=apc_edit_ue.form_ue_choix_niveau, form_ue_choix_parcours_niveau=apc_edit_ue.form_ue_choix_parcours_niveau,
), ),
] ]
for semestre_idx in semestre_ids: for semestre_idx in semestre_ids:

View File

@ -737,8 +737,10 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
) )
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(
cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"], cssstyles=html_sco_header.BOOTSTRAP_MULTISELECT_CSS
javascripts=[ + ["libjs/jQuery-tagEditor/jquery.tag-editor.css"],
javascripts=html_sco_header.BOOTSTRAP_MULTISELECT_JS
+ [
"libjs/jinplace-1.2.1.min.js", "libjs/jinplace-1.2.1.min.js",
"js/ue_list.js", "js/ue_list.js",
"js/edit_ue.js", "js/edit_ue.js",

View File

@ -79,7 +79,7 @@ def evaluation_create_form(
mod = modimpl_o["module"] mod = modimpl_o["module"]
formsemestre_id = modimpl_o["formsemestre_id"] formsemestre_id = modimpl_o["formsemestre_id"]
formsemestre = modimpl.formsemestre formsemestre = modimpl.formsemestre
sem_ues = formsemestre.query_ues(with_sport=False).all() sem_ues = formsemestre.get_ues(with_sport=False)
is_malus = mod["module_type"] == ModuleType.MALUS is_malus = mod["module_type"] == ModuleType.MALUS
is_apc = mod["module_type"] in (ModuleType.RESSOURCE, ModuleType.SAE) is_apc = mod["module_type"] in (ModuleType.RESSOURCE, ModuleType.SAE)
preferences = sco_preferences.SemPreferences(formsemestre.id) preferences = sco_preferences.SemPreferences(formsemestre.id)

View File

@ -128,8 +128,10 @@ def formation_export_dict(
ue_dict["apc_niveau_annee"] = ue.niveau_competence.annee ue_dict["apc_niveau_annee"] = ue.niveau_competence.annee
ue_dict["apc_niveau_ordre"] = ue.niveau_competence.ordre ue_dict["apc_niveau_ordre"] = ue.niveau_competence.ordre
# Et le parcour: # Et le parcour:
if ue.parcour: if ue.parcours:
ue_dict["parcour"] = [ue.parcour.to_dict(with_annees=False)] ue_dict["parcours"] = [
parcour.to_dict(with_annees=False) for parcour in ue.parcours
]
# pour les coefficients: # pour les coefficients:
ue_dict["reference"] = ue.id if ue_reference_style == "id" else ue.acronyme ue_dict["reference"] = ue.id if ue_reference_style == "id" else ue.acronyme
if not export_ids: if not export_ids:
@ -372,6 +374,7 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
# -- create matieres # -- create matieres
for mat_info in ue_info[2]: for mat_info in ue_info[2]:
# Backward compat: un seul parcours par UE (ScoDoc < 9.4.71)
if mat_info[0] == "parcour": if mat_info[0] == "parcour":
# Parcours (BUT) # Parcours (BUT)
code_parcours = mat_info[1]["code"] code_parcours = mat_info[1]["code"]
@ -380,11 +383,28 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
referentiel_id=referentiel_competence_id, referentiel_id=referentiel_competence_id,
).first() ).first()
if parcour: if parcour:
ue.parcour = parcour ue.parcours = [parcour]
db.session.add(ue) db.session.add(ue)
else: else:
flash(f"Attention: parcours {code_parcours} inexistant !")
log(f"Warning: parcours {code_parcours} inexistant !") log(f"Warning: parcours {code_parcours} inexistant !")
continue continue
elif mat_info[0] == "parcours":
# Parcours (BUT), liste (ScoDoc > 9.4.70)
codes_parcours = mat_info[1]["code"]
for code_parcours in codes_parcours:
parcour = ApcParcours.query.filter_by(
code=code_parcours,
referentiel_id=referentiel_competence_id,
).first()
if parcour:
ue.parcours.append(parcour)
else:
flash(f"Attention: parcours {code_parcours} inexistant !")
log(f"Warning: parcours {code_parcours} inexistant !")
db.session.add(ue)
continue
assert mat_info[0] == "matiere" assert mat_info[0] == "matiere"
mat_info[1]["ue_id"] = ue_id mat_info[1]["ue_id"] = ue_id
mat_id = sco_edit_matiere.do_matiere_create(mat_info[1]) mat_id = sco_edit_matiere.do_matiere_create(mat_info[1])

View File

@ -37,6 +37,7 @@ from flask import flash, redirect, render_template, url_for
from flask_login import current_user from flask_login import current_user
from app import log from app import log
from app.but.cursus_but import formsemestre_warning_apc_setup
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_common import ResultatsSemestre from app.comp.res_common import ResultatsSemestre
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
@ -604,7 +605,7 @@ def formsemestre_description_table(
columns_ids += ["Coef."] columns_ids += ["Coef."]
ues = [] # liste des UE, seulement en APC pour les coefs ues = [] # liste des UE, seulement en APC pour les coefs
else: else:
ues = formsemestre.query_ues().all() ues = formsemestre.get_ues()
columns_ids += [f"ue_{ue.id}" for ue in ues] columns_ids += [f"ue_{ue.id}" for ue in ues]
if sco_preferences.get_preference("bul_show_ects", formsemestre_id): if sco_preferences.get_preference("bul_show_ects", formsemestre_id):
columns_ids += ["ects"] columns_ids += ["ects"]
@ -1057,6 +1058,7 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
formsemestre_status_head( formsemestre_status_head(
formsemestre_id=formsemestre_id, page_title="Tableau de bord" formsemestre_id=formsemestre_id, page_title="Tableau de bord"
), ),
formsemestre_warning_apc_setup(formsemestre, nt),
formsemestre_warning_etuds_sans_note(formsemestre, nt) formsemestre_warning_etuds_sans_note(formsemestre, nt)
if can_change_all_notes if can_change_all_notes
else "", else "",
@ -1282,7 +1284,7 @@ def formsemestre_tableau_modules(
""" """
) )
if mod.module_type in (ModuleType.RESSOURCE, ModuleType.SAE): if mod.module_type in (ModuleType.RESSOURCE, ModuleType.SAE):
coefs = mod.ue_coefs_list(ues=formsemestre.query_ues().all()) coefs = mod.ue_coefs_list(ues=formsemestre.get_ues())
H.append(f'<a class="invisible_link" href="#" title="{mod_descr}">') H.append(f'<a class="invisible_link" href="#" title="{mod_descr}">')
for coef in coefs: for coef in coefs:
if coef[1] > 0: if coef[1] > 0:

View File

@ -606,7 +606,9 @@ def formsemestre_recap_parcours_table(
else: else:
# si l'étudiant n'est pas inscrit à un parcours mais que le semestre a plus d'UE # si l'étudiant n'est pas inscrit à un parcours mais que le semestre a plus d'UE
# signale un éventuel problème: # signale un éventuel problème:
if nt.formsemestre.query_ues().count() > len(nt.etud_ues_ids(etudid)): if len(nt.formsemestre.get_ues()) > len(
nt.etud_ues_ids(etudid)
): # XXX sans dispenses
parcours_name = f""" parcours_name = f"""
<span class="code_parcours no_parcours">{scu.EMO_WARNING}&nbsp;pas de parcours <span class="code_parcours no_parcours">{scu.EMO_WARNING}&nbsp;pas de parcours
</span>""" </span>"""

View File

@ -992,8 +992,8 @@ def icontag(name, file_format="png", no_size=False, **attrs):
file_format, file_format,
), ),
) )
im = PILImage.open(img_file) with PILImage.open(img_file) as image:
width, height = im.size[0], im.size[1] width, height = image.size[0], image.size[1]
ICONSIZES[name] = (width, height) # cache ICONSIZES[name] = (width, height) # cache
else: else:
width, height = ICONSIZES[name] width, height = ICONSIZES[name]

View File

@ -89,7 +89,7 @@ function update_menus_niveau_competence() {
// ); // );
// nouveau: // nouveau:
document.querySelectorAll("select.select_niveau_ue").forEach( document.querySelectorAll("select.niveau_select").forEach(
elem => { elem => {
let ue_id = elem.dataset.ue_id; let ue_id = elem.dataset.ue_id;
$.get("get_ue_niveaux_options_html", $.get("get_ue_niveaux_options_html",
@ -103,3 +103,65 @@ function update_menus_niveau_competence() {
} }
); );
} }
// ---- Nouveau formulaire choix parcours et niveau -----
//document.querySelectorAll("select.select_ue_parcours").forEach(
// elem => { elem.addEventListener('change', change_ue_parcours); }
//);
$().ready(function () {
$('select.select_ue_parcours').multiselect(
{
includeSelectAllOption: false,
nonSelectedText: 'choisir...',
// buttonContainer: '<div id="group_ids_sel_container"/>',
onChange: function (element, checked) {
var parent = element.parent();
var selectedOptions = parent.getValue().split(",");
let set_ue_parcours = element.context.dataset.set_ue_parcours;
fetch(set_ue_parcours, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(selectedOptions)
})
.then(response => response.json())
.then(data => {
if (!data.status) {
sco_message(data.message);
// get the option element corresponding to the selected value
var option = parent.find('option[value="' + element.val() + '"]');
// uncheck the option
option.prop('selected', false);
// refresh the multiselect to reflect the change
parent.multiselect('refresh');
}
})
.catch(error => console.error('Error: ' + error));
// // referme le menu apres chaque choix:
// $("#group_selector .btn-group").removeClass('open');
// if ($("#group_ids_sel").hasClass("submit_on_change")) {
// submit_group_selector();
// }
}
}
);
});
function change_ue_parcours(event) {
const multiselect = event.target;
const selectedOptions = Array.from(this.selectedOptions).map(option => option.value);
fetch('/set_option/', { // XXX TODO
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(selectedOptions)
})
.then(response => response.json())
.then(data => console.log('Success!'))
.catch(error => console.error('Error: ' + error));
};

View File

@ -74,7 +74,7 @@ class TableRecap(tb.Table):
# couples (modimpl, ue) effectivement présents dans la table: # couples (modimpl, ue) effectivement présents dans la table:
self.modimpl_ue_ids = set() self.modimpl_ue_ids = set()
ues = res.formsemestre.query_ues(with_sport=True) # avec bonus ues = res.formsemestre.get_ues(with_sport=True) # avec bonus
ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
if res.formsemestre.etuds_inscriptions: # table non vide if res.formsemestre.etuds_inscriptions: # table non vide

View File

@ -65,7 +65,7 @@
}}">modifier</a> }}">modifier</a>
{% endif %} {% endif %}
{{ form_ue_choix_niveau(ue)|safe }} {{ form_ue_choix_parcours_niveau(ue)|safe }}
{% if ue.type == 1 and ue.modules.count() == 0 %} {% if ue.type == 1 and ue.modules.count() == 0 %}

View File

@ -0,0 +1,13 @@
{# inclu par form_ues.j2 #}
<form method="POST" action="">
{{ form_ue_parcours_niveau.csrf_token }}
<div class="form-group">
{{ form_ue_parcours_niveau.niveau_select.label }}
{{ form_ue_parcours_niveau.niveau_select }}
{{ form_ue_parcours_niveau.parcours_multiselect.label }}
{{ form_ue_parcours_niveau.parcours_multiselect }}
</div>
</form>

View File

@ -421,25 +421,6 @@ def set_ue_niveau_competence():
return "", 204 return "", 204
@bp.route("/set_ue_parcours", methods=["POST"])
@scodoc
@permission_required(Permission.ScoChangeFormation)
def set_ue_parcours():
"""Associe UE et parcours BUT.
Si le parcour_id est "", désassocie."""
ue_id = request.form.get("ue_id")
parcour_id = request.form.get("parcour_id")
if parcour_id == "":
parcour_id = None
ue: UniteEns = UniteEns.query.get_or_404(ue_id)
parcour = None if parcour_id is None else ApcParcours.query.get_or_404(parcour_id)
try:
ue.set_parcour(parcour)
except ScoFormationConflict:
return "", 409 # conflict
return "", 204
@bp.route("/get_ue_niveaux_options_html") @bp.route("/get_ue_niveaux_options_html")
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@ -448,6 +429,9 @@ def get_ue_niveaux_options_html():
niveau de compétences associé à une UE niveau de compétences associé à une UE
""" """
ue_id = request.args.get("ue_id") ue_id = request.args.get("ue_id")
if ue_id is None:
log("WARNING: get_ue_niveaux_options_html missing ue_id arg")
return "???"
ue: UniteEns = UniteEns.query.get_or_404(ue_id) ue: UniteEns = UniteEns.query.get_or_404(ue_id)
return apc_edit_ue.get_ue_niveaux_options_html(ue) return apc_edit_ue.get_ue_niveaux_options_html(ue)

View File

@ -79,7 +79,7 @@ def table_modules_ue_coefs(formation_id, semestre_idx=None, parcours_id: int = N
ues = [ ues = [
ue ue
for ue in ues for ue in ues
if (parcours_id == ue.parcour_id) or (ue.parcour_id is None) if (parcours_id in (p.id for p in ue.parcours)) or (not ue.parcours)
] ]
modules = [ modules = [
mod mod
@ -113,13 +113,14 @@ def table_modules_ue_coefs(formation_id, semestre_idx=None, parcours_id: int = N
cells = [] cells = []
for (row, mod) in enumerate(modules, start=2): for (row, mod) in enumerate(modules, start=2):
style = "champs champs_" + scu.ModuleType(mod.module_type).name style = "champs champs_" + scu.ModuleType(mod.module_type).name
mod_parcours_ids = {p.id for p in mod.parcours}
for (col, ue) in enumerate(ues, start=2): for (col, ue) in enumerate(ues, start=2):
# met en gris les coefs qui devraient être nuls # met en gris les coefs qui devraient être nuls
# car le module n'est pas dans le parcours de l'UE: # car le module n'est pas dans le parcours de l'UE:
if ( if (
(mod.parcours is not None) (mod.parcours is not None)
and (ue.parcour_id is not None) and (ue.parcours)
and ue.parcour_id not in (p.id for p in mod.parcours) and not {p.id for p in ue.parcours}.intersection(mod_parcours_ids)
): ):
cell_style = style + " champs_coef_hors_parcours" cell_style = style + " champs_coef_hors_parcours"
else: else:

View File

@ -0,0 +1,98 @@
"""Association UEs <-> parcours
Revision ID: 054dd6133b9c
Revises: 6520faf67508
Create Date: 2023-03-30 19:40:50.575293
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker # added by ev
# revision identifiers, used by Alembic.
revision = "054dd6133b9c"
down_revision = "6520faf67508"
branch_labels = None
depends_on = None
Session = sessionmaker()
def upgrade():
"""Passe d'une relation UE - Parcours one-to-many à une relation many-to-many
crée la table d'association, copie l'éventuelle relation existante
puis supprime la clé étrangère parcour_id
"""
op.create_table(
"ue_parcours",
sa.Column("ue_id", sa.Integer(), nullable=False),
sa.Column("parcours_id", sa.Integer(), nullable=False),
sa.Column("ects", sa.Float(), nullable=True),
sa.ForeignKeyConstraint(
["parcours_id"],
["apc_parcours.id"],
),
sa.ForeignKeyConstraint(
["ue_id"],
["notes_ue.id"],
),
sa.PrimaryKeyConstraint("ue_id", "parcours_id"),
)
#
bind = op.get_bind()
session = Session(bind=bind)
session.execute(
sa.text(
"""
INSERT INTO ue_parcours
SELECT id as ue_id, parcour_id as parcours_id
FROM notes_ue
WHERE parcour_id is not NULL;
"""
)
)
session.commit()
op.drop_column("notes_ue", "parcour_id")
# Numeros non nullables
for table in (
"apc_competence",
"apc_parcours",
"notes_form_modalites",
"notes_ue",
"notes_matieres",
"notes_modules",
"notes_evaluation",
"partition",
"group_descr",
):
session.execute(
sa.text(
f"""UPDATE {table} SET numero=0 WHERE numero is NULL;
"""
)
)
session.commit()
op.alter_column(table, "numero", existing_type=sa.INTEGER(), nullable=False)
def downgrade():
#
op.add_column(
"notes_ue",
sa.Column("parcour_id", sa.INTEGER(), autoincrement=False, nullable=True),
)
op.drop_table("ue_parcours")
for table in (
"apc_competence",
"apc_parcours",
"notes_form_modalites",
"notes_ue",
"notes_matieres",
"notes_modules",
"notes_evaluation",
"partition",
"group_descr",
):
op.alter_column(table, "numero", existing_type=sa.INTEGER(), nullable=True)

View File

@ -87,15 +87,15 @@ Formation:
competence: "Solutions TP" competence: "Solutions TP"
'UE5.3': 'UE5.3':
annee: BUT3 annee: BUT3
parcours: RAPEB # + BEC parcours: [RAPEB, BEC]
competence: "Dimensionner" competence: "Dimensionner"
'UE5.4': 'UE5.4':
annee: BUT3 annee: BUT3
parcours: BAT # + TP parcours: [BAT, TP]
competence: Organiser competence: Organiser
'UE5.5': 'UE5.5':
annee: BUT3 annee: BUT3
parcours: BAT # + TP parcours: [BAT, TP]
competence: Piloter competence: Piloter
# S6 Parcours BAT + TP # S6 Parcours BAT + TP
'UE6.1': # Parcours BAT seulement 'UE6.1': # Parcours BAT seulement
@ -104,19 +104,19 @@ Formation:
competence: "Solutions Bâtiment" competence: "Solutions Bâtiment"
'UE6.2': # Parcours TP seulement 'UE6.2': # Parcours TP seulement
annee: BUT3 annee: BUT3
parcours: TP # + BEC parcours: [TP,BEC]
competence: "Solutions TP" competence: "Solutions TP"
'UE6.3': 'UE6.3':
annee: BUT3 annee: BUT3
parcours: RAPEB # + BEC parcours: [RAPEB,BEC]
competence: "Dimensionner" competence: "Dimensionner"
'UE6.4': 'UE6.4':
annee: BUT3 annee: BUT3
parcours: BAT # + TP parcours: [BAT, TP]
competence: Organiser competence: Organiser
'UE6.5': 'UE6.5':
annee: BUT3 annee: BUT3
parcours: BAT # + TP parcours: [BAT,TP]
competence: Piloter competence: Piloter
modules_parcours: modules_parcours:

View File

@ -111,7 +111,7 @@ def build_modules_with_evaluations(
modimpl = models.ModuleImpl.query.get(moduleimpl_id) modimpl = models.ModuleImpl.query.get(moduleimpl_id)
assert modimpl.formsemestre.formation.get_cursus().APC_SAE # BUT assert modimpl.formsemestre.formation.get_cursus().APC_SAE # BUT
# Check ModuleImpl # Check ModuleImpl
ues = modimpl.formsemestre.query_ues().all() ues = modimpl.formsemestre.get_ues()
assert len(ues) == 3 assert len(ues) == 3
# #
for _ in range(nb_evals_per_modimpl): for _ in range(nb_evals_per_modimpl):

View File

@ -24,7 +24,7 @@ from tests.unit import yaml_setup, yaml_setup_but
import app import app
from app.but.jury_but_validation_auto import formsemestre_validation_auto_but from app.but.jury_but_validation_auto import formsemestre_validation_auto_but
from app.models import Formation, FormSemestre from app.models import Formation, FormSemestre, UniteEns
from config import TestConfig from config import TestConfig
DEPT = TestConfig.DEPT_TEST DEPT = TestConfig.DEPT_TEST
@ -133,7 +133,11 @@ def test_but_jury_GCCD_CY(test_client):
assert parcour_BAT assert parcour_BAT
# check le nombre d'UE dans chaque semestre BUT: # check le nombre d'UE dans chaque semestre BUT:
assert [ assert [
len(formation.query_ues_parcour(parcour_BAT).filter_by(semestre_idx=i).all()) len(
formation.query_ues_parcour(parcour_BAT)
.filter(UniteEns.semestre_idx == i)
.all()
)
for i in range(1, 7) for i in range(1, 7)
] == [5, 5, 5, 5, 3, 3] ] == [5, 5, 5, 5, 3, 3]
# Vérifie les UEs du parcours TP # Vérifie les UEs du parcours TP
@ -141,6 +145,10 @@ def test_but_jury_GCCD_CY(test_client):
assert parcour_TP assert parcour_TP
# check le nombre d'UE dans chaque semestre BUT: # check le nombre d'UE dans chaque semestre BUT:
assert [ assert [
len(formation.query_ues_parcour(parcour_TP).filter_by(semestre_idx=i).all()) len(
formation.query_ues_parcour(parcour_TP)
.filter(UniteEns.semestre_idx == i)
.all()
)
for i in range(1, 7) for i in range(1, 7)
] == [5, 5, 5, 5, 3, 3] ] == [5, 5, 5, 5, 3, 3]

View File

@ -30,6 +30,9 @@ REF_MLT_XML = open(
REF_GCCD_XML = open( REF_GCCD_XML = open(
"ressources/referentiels/but2022/competences/but-GCCD-05012022-081630.xml" "ressources/referentiels/but2022/competences/but-GCCD-05012022-081630.xml"
).read() ).read()
REF_INFO_XML = open(
"ressources/referentiels/but2022/competences/but-INFO-05012022-081701.xml"
).read()
def test_but_refcomp(test_client): def test_but_refcomp(test_client):
@ -125,20 +128,20 @@ def test_refcomp_niveaux_mlt(test_client):
# Vérifier les niveaux_by_parcours # Vérifier les niveaux_by_parcours
parcour = ref_comp.parcours.first() parcour = ref_comp.parcours.first()
# BUT 1 # BUT 1
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(1, parcour) parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(1, [parcour])
assert parcours == [parcour] # le parcours indiqué assert parcours == [parcour] # le parcours indiqué
assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC")
assert niveaux_by_parcours[parcour.id] == [] # tout en tronc commun en BUT1 MLT assert niveaux_by_parcours[parcour.id] == [] # tout en tronc commun en BUT1 MLT
assert niveaux_by_parcours["TC"][0].competence.titre == "Transporter" assert niveaux_by_parcours["TC"][0].competence.titre == "Transporter"
assert len(niveaux_by_parcours["TC"]) == 3 assert len(niveaux_by_parcours["TC"]) == 3
# BUT 2 # BUT 2
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(2, parcour) parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(2, [parcour])
assert parcours == [parcour] # le parcours indiqué assert parcours == [parcour] # le parcours indiqué
assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC")
assert len(niveaux_by_parcours[parcour.id]) == 1 assert len(niveaux_by_parcours[parcour.id]) == 1
assert len(niveaux_by_parcours["TC"]) == 3 assert len(niveaux_by_parcours["TC"]) == 3
# BUT 3 # BUT 3
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(3, parcour) parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(3, [parcour])
assert parcours == [parcour] # le parcours indiqué assert parcours == [parcour] # le parcours indiqué
assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC")
assert len(niveaux_by_parcours[parcour.id]) == 1 assert len(niveaux_by_parcours[parcour.id]) == 1
@ -182,13 +185,13 @@ def test_refcomp_niveaux_gccd(test_client):
# Vérifier les niveaux_by_parcours # Vérifier les niveaux_by_parcours
parcour = ref_comp.parcours.first() parcour = ref_comp.parcours.first()
# BUT 1 # BUT 1
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(1, parcour) parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(1, [parcour])
assert parcours == [parcour] # le parcours indiqué assert parcours == [parcour] # le parcours indiqué
assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC")
assert len(niveaux_by_parcours[parcour.id]) == 0 assert len(niveaux_by_parcours[parcour.id]) == 0
assert len(niveaux_by_parcours["TC"]) == 5 assert len(niveaux_by_parcours["TC"]) == 5
# BUT 3 # BUT 3
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(3, parcour) parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(3, [parcour])
assert parcours == [parcour] # le parcours indiqué assert parcours == [parcour] # le parcours indiqué
assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC") assert (tuple(niveaux_by_parcours.keys())) == (parcour.id, "TC")
assert len(niveaux_by_parcours[parcour.id]) == 3 assert len(niveaux_by_parcours[parcour.id]) == 3

View File

@ -81,11 +81,23 @@ def associe_ues_et_parcours(formation: Formation, formation_infos: dict):
assert ue is not None # l'UE doit exister dans la formation avec cet acronyme assert ue is not None # l'UE doit exister dans la formation avec cet acronyme
# Parcours: # Parcours:
if ue_infos.get("parcours", False): if ue_infos.get("parcours", False):
parcour = referentiel_competence.parcours.filter_by( # On peut spécifier un seul parcours (cas le plus fréquent) ou une liste
if isinstance(ue_infos["parcours"], list):
parcours = [
referentiel_competence.parcours.filter_by(code=code_parcour).first()
for code_parcour in ue_infos["parcours"]
]
assert (
None not in parcours
) # les parcours indiqués pour cette UE doivent exister
else:
parcours = referentiel_competence.parcours.filter_by(
code=ue_infos["parcours"] code=ue_infos["parcours"]
).first() ).all()
assert parcour is not None # le parcours indiqué pour cette UE doit exister assert (
ue.set_parcour(parcour) len(parcours) == 1
) # le parcours indiqué pour cette UE doit exister
ue.set_parcours(parcours)
# Niveaux compétences: # Niveaux compétences:
competence = referentiel_competence.competences.filter_by( competence = referentiel_competence.competences.filter_by(
@ -258,12 +270,19 @@ def check_deca_fields(formsemestre: FormSemestre, etud: Identite = None):
assert deca.validation is None # pas encore de validation enregistrée assert deca.validation is None # pas encore de validation enregistrée
assert False is deca.recorded assert False is deca.recorded
assert deca.code_valide is None assert deca.code_valide is None
parcour = deca.parcour
formation: Formation = formsemestre.formation
ues = (
formation.query_ues_parcour(parcour)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.all()
)
if formsemestre.semestre_id % 2: if formsemestre.semestre_id % 2:
assert deca.formsemestre_impair == formsemestre assert deca.formsemestre_impair == formsemestre
assert formsemestre.query_ues_parcours_etud(etud.id).all() == deca.ues_impair assert ues == deca.ues_impair
else: else:
assert deca.formsemestre_pair == formsemestre assert deca.formsemestre_pair == formsemestre
assert formsemestre.query_ues_parcours_etud(etud.id).all() == deca.ues_pair assert ues == deca.ues_pair
assert deca.inscription_etat == scu.INSCRIT assert deca.inscription_etat == scu.INSCRIT
assert deca.inscription_etat_impair == scu.INSCRIT assert deca.inscription_etat_impair == scu.INSCRIT
assert (deca.parcour is None) or ( assert (deca.parcour is None) or (
@ -271,24 +290,27 @@ def check_deca_fields(formsemestre: FormSemestre, etud: Identite = None):
) )
nb_ues = ( nb_ues = (
len(deca.formsemestre_pair.query_ues_parcours_etud(etud.id).all()) len(
formation.query_ues_parcour(parcour)
.filter(UniteEns.semestre_idx == deca.formsemestre_pair.semestre_id)
.all()
)
if deca.formsemestre_pair if deca.formsemestre_pair
else 0 else 0
) )
nb_ues += ( nb_ues += (
len(deca.formsemestre_impair.query_ues_parcours_etud(etud.id).all()) len(
formation.query_ues_parcour(parcour)
.filter(UniteEns.semestre_idx == deca.formsemestre_impair.semestre_id)
.all()
)
if deca.formsemestre_impair if deca.formsemestre_impair
else 0 else 0
) )
assert len(deca.decisions_ues) == nb_ues assert len(deca.decisions_ues) == nb_ues
nb_ues_un_sem = ( assert len(deca.niveaux_competences) == len(ues)
len(deca.formsemestre_impair.query_ues_parcours_etud(etud.id).all()) assert deca.nb_competences == len(ues)
if deca.formsemestre_impair
else len(deca.formsemestre_pair.query_ues_parcours_etud(etud.id).all())
)
assert len(deca.niveaux_competences) == nb_ues_un_sem
assert deca.nb_competences == nb_ues_un_sem
def but_test_jury(formsemestre: FormSemestre, doc: dict): def but_test_jury(formsemestre: FormSemestre, doc: dict):

View File

@ -262,7 +262,7 @@ def saisie_notes_evaluations(formsemestre: FormSemestre, user: User):
date_debut = formsemestre.date_debut date_debut = formsemestre.date_debut
date_fin = formsemestre.date_fin date_fin = formsemestre.date_fin
list_ues = formsemestre.query_ues() list_ues = formsemestre.get_ues()
def saisir_notes(evaluation_id: int, condition: int): def saisir_notes(evaluation_id: int, condition: int):
""" """