Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into table

This commit is contained in:
Emmanuel Viennet 2023-01-24 19:07:23 -03:00
commit 83b47db3dc
34 changed files with 373 additions and 180 deletions

View File

@ -550,3 +550,22 @@ def scodoc_flash_status_messages():
f"Mode test: mails redirigés vers {email_test_mode_address}", f"Mode test: mails redirigés vers {email_test_mode_address}",
category="warning", category="warning",
) )
def critical_error(msg):
"""Handle a critical error: flush all caches, display message to the user"""
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
clear_scodoc_cache()
raise ScoValueError(
f"""
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
{msg}
"""
)

View File

@ -19,6 +19,7 @@ from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition from app.models import GroupDescr, Partition
from app.models.groups import group_membership from app.models.groups import group_membership
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -170,24 +171,15 @@ def set_etud_group(etudid: int, group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
) )
group = query.first_or_404() group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if etud.id not in {e.id for e in group.partition.formsemestre.etuds}: if etud.id not in {e.id for e in group.partition.formsemestre.etuds}:
return json_error(404, "etud non inscrit au formsemestre du groupe") return json_error(404, "etud non inscrit au formsemestre du groupe")
groups = (
GroupDescr.query.filter_by(partition_id=group.partition.id) sco_groups.change_etud_group_in_partition(
.join(group_membership) etudid, group_id, group.partition.to_dict()
.filter_by(etudid=etudid)
) )
ok = False
for other_group in groups:
if other_group.id == group_id:
ok = True
else:
other_group.etuds.remove(etud)
if not ok:
group.etuds.append(etud)
log(f"set_etud_group({etud}, {group})")
db.session.commit()
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return jsonify({"group_id": group_id, "etudid": etudid}) return jsonify({"group_id": group_id, "etudid": etudid})
@ -207,6 +199,8 @@ def group_remove_etud(group_id: int, etudid: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
) )
group = query.first_or_404() group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if etud in group.etuds: if etud in group.etuds:
group.etuds.remove(etud) group.etuds.remove(etud)
db.session.commit() db.session.commit()
@ -232,6 +226,8 @@ def partition_remove_etud(partition_id: int, etudid: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition = query.first_or_404() partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
groups = ( groups = (
GroupDescr.query.filter_by(partition_id=partition_id) GroupDescr.query.filter_by(partition_id=partition_id)
.join(group_membership) .join(group_membership)
@ -262,8 +258,10 @@ def group_create(partition_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404() partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.groups_editable: if not partition.groups_editable:
return json_error(404, "partition non editable") return json_error(403, "partition non editable")
data = request.get_json(force=True) # may raise 400 Bad Request data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name") group_name = data.get("group_name")
if group_name is None: if group_name is None:
@ -294,8 +292,10 @@ def group_delete(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
) )
group: GroupDescr = query.first_or_404() group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable: if not group.partition.groups_editable:
return json_error(404, "partition non editable") return json_error(403, "partition non editable")
formsemestre_id = group.partition.formsemestre_id formsemestre_id = group.partition.formsemestre_id
log(f"deleting {group}") log(f"deleting {group}")
db.session.delete(group) db.session.delete(group)
@ -318,8 +318,10 @@ def group_edit(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
) )
group: GroupDescr = query.first_or_404() group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable: if not group.partition.groups_editable:
return json_error(404, "partition non editable") return json_error(403, "partition non editable")
data = request.get_json(force=True) # may raise 400 Bad Request data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name") group_name = data.get("group_name")
if group_name is not None: if group_name is not None:
@ -358,6 +360,8 @@ def partition_create(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)
if not formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
data = request.get_json(force=True) # may raise 400 Bad Request data = request.get_json(force=True) # may raise 400 Bad Request
partition_name = data.get("partition_name") partition_name = data.get("partition_name")
if partition_name is None: if partition_name is None:
@ -406,6 +410,8 @@ def formsemestre_order_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)
if not formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
partition_ids = request.get_json(force=True) # may raise 400 Bad Request partition_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(partition_ids, int) and not all( if not isinstance(partition_ids, int) and not all(
isinstance(x, int) for x in partition_ids isinstance(x, int) for x in partition_ids
@ -443,6 +449,8 @@ def partition_order_groups(partition_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404() partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
group_ids = request.get_json(force=True) # may raise 400 Bad Request group_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(group_ids, int) and not all( if not isinstance(group_ids, int) and not all(
isinstance(x, int) for x in group_ids isinstance(x, int) for x in group_ids
@ -484,6 +492,8 @@ def partition_edit(partition_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404() partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
data = request.get_json(force=True) # may raise 400 Bad Request data = request.get_json(force=True) # may raise 400 Bad Request
modified = False modified = False
partition_name = data.get("partition_name") partition_name = data.get("partition_name")
@ -542,6 +552,8 @@ def partition_delete(partition_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404() partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.partition_name: if not partition.partition_name:
return json_error(404, "ne peut pas supprimer la partition par défaut") return json_error(404, "ne peut pas supprimer la partition par défaut")
is_parcours = partition.is_parcours() is_parcours = partition.is_parcours()

View File

@ -102,7 +102,7 @@ class EtudCursusBUT:
) )
"Liste des inscriptions aux sem. de la formation, triées par indice et chronologie" "Liste des inscriptions aux sem. de la formation, triées par indice et chronologie"
self.parcour: ApcParcours = self.inscriptions[-1].parcour self.parcour: ApcParcours = self.inscriptions[-1].parcour
"Le parcour à valider: celui du DERNIER semestre suivi (peut être None)" "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee = {} self.niveaux_by_annee = {}
"{ annee : liste des niveaux à valider }" "{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {} self.niveaux: dict[int, ApcNiveau] = {}
@ -142,7 +142,7 @@ class EtudCursusBUT:
self.validation_par_competence_et_annee[niveau.competence.id] = {} self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get( previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id niveau.competence.id
) ).get(validation_rcue.annee())
# prend la "meilleure" validation # prend la "meilleure" validation
if (not previous_validation) or ( if (not previous_validation) or (
sco_codes.BUT_CODES_ORDERED[validation_rcue.code] sco_codes.BUT_CODES_ORDERED[validation_rcue.code]

View File

@ -693,20 +693,20 @@ class DecisionsProposeesAnnee(DecisionsProposees):
db.session.commit() db.session.commit()
def record(self, code: str, no_overwrite=False): def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code de l'année, et au besoin l'autorisation d'inscription. """Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
Si no_overwrite, ne fait rien si un code est déjà enregistré. Si no_overwrite, ne fait rien si un code est déjà enregistré.
Si l'étudiant est DEM ou DEF, ne fait rien. Si l'étudiant est DEM ou DEF, ne fait rien.
""" """
if self.inscription_etat != scu.INSCRIT: if self.inscription_etat != scu.INSCRIT:
return return False
if code and not code in self.codes: if code and not code in self.codes:
raise ScoValueError( raise ScoValueError(
f"code annee <tt>{html.escape(code)}</tt> invalide pour formsemestre {html.escape(self.formsemestre)}" f"code annee <tt>{html.escape(code)}</tt> invalide pour formsemestre {html.escape(self.formsemestre)}"
) )
if code == self.code_valide or (self.code_valide is not None and no_overwrite): if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True self.recorded = True
return # no change return False # no change
if self.validation: if self.validation:
db.session.delete(self.validation) db.session.delete(self.validation)
db.session.commit() db.session.commit()
@ -746,9 +746,10 @@ class DecisionsProposeesAnnee(DecisionsProposees):
next_semestre_id, next_semestre_id,
) )
self.recorded = True
db.session.commit() db.session.commit()
self.recorded = True
self.invalidate_formsemestre_cache() self.invalidate_formsemestre_cache()
return True
def invalidate_formsemestre_cache(self): def invalidate_formsemestre_cache(self):
"invalide le résultats des deux formsemestres" "invalide le résultats des deux formsemestres"
@ -759,13 +760,20 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if self.formsemestre_pair is not None: if self.formsemestre_pair is not None:
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id) sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
def record_all(self, no_overwrite: bool = True): def record_all(
self, no_overwrite: bool = True, only_validantes: bool = False
) -> bool:
"""Enregistre les codes qui n'ont pas été spécifiés par le formulaire, """Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
et sont donc en mode "automatique". et sont donc en mode "automatique".
- Si "à cheval", ne modifie pas les codes UE de l'année scolaire précédente. - Si "à cheval", ne modifie pas les codes UE de l'année scolaire précédente.
- Pour les RCUE: n'enregistre que si la nouvelle décision est plus favorable que l'ancienne. - Pour les RCUE: n'enregistre que si la nouvelle décision est plus favorable que l'ancienne.
Si only_validantes, n'enregistre que des décisions "validantes" de droit: ADM ou CMP.
Return: True si au moins un code modifié et enregistré.
""" """
# Toujours valider dans l'ordre UE, RCUE, Année: modif = False
# Toujours valider dans l'ordre UE, RCUE, Année
annee_scolaire = self.formsemestre.annee_scolaire() annee_scolaire = self.formsemestre.annee_scolaire()
# UEs # UEs
for dec_ue in self.decisions_ues.values(): for dec_ue in self.decisions_ues.values():
@ -774,25 +782,40 @@ class DecisionsProposeesAnnee(DecisionsProposees):
) and dec_ue.formsemestre.annee_scolaire() == annee_scolaire: ) and dec_ue.formsemestre.annee_scolaire() == annee_scolaire:
# rappel: le code par défaut est en tête # rappel: le code par défaut est en tête
code = dec_ue.codes[0] if dec_ue.codes else None code = dec_ue.codes[0] if dec_ue.codes else None
if (not only_validantes) or code in sco_codes.CODES_UE_VALIDES_DE_DROIT:
# enregistre le code jury seulement s'il n'y a pas déjà de code # enregistre le code jury seulement s'il n'y a pas déjà de code
# (no_overwrite=True) sauf en mode test yaml # (no_overwrite=True) sauf en mode test yaml
dec_ue.record(code, no_overwrite=no_overwrite) modif |= dec_ue.record(code, no_overwrite=no_overwrite)
# RCUE : enregistre seulement si pas déjà validé "mieux" # RCUE :
for dec_rcue in self.decisions_rcue_by_niveau.values(): for dec_rcue in self.decisions_rcue_by_niveau.values():
code = dec_rcue.codes[0] if dec_rcue.codes else None code = dec_rcue.codes[0] if dec_rcue.codes else None
if (not dec_rcue.recorded) and ( if (
(not dec_rcue.recorded)
and ( # enregistre seulement si pas déjà validé "mieux"
(not dec_rcue.validation) (not dec_rcue.validation)
or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0) or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0)
< BUT_CODES_ORDERED.get(code, 0) < BUT_CODES_ORDERED.get(code, 0)
)
and ( # décision validante de droit ?
(
(not only_validantes)
or code in sco_codes.CODES_RCUE_VALIDES_DE_DROIT
)
)
): ):
dec_rcue.record(code, no_overwrite=no_overwrite) modif |= dec_rcue.record(code, no_overwrite=no_overwrite)
# Année: # Année:
if not self.recorded: if not self.recorded:
# rappel: le code par défaut est en tête # rappel: le code par défaut est en tête
code = self.codes[0] if self.codes else None code = self.codes[0] if self.codes else None
# enregistre le code jury seulement s'il n'y a pas déjà de code # enregistre le code jury seulement s'il n'y a pas déjà de code
# (no_overwrite=True) sauf en mode test yaml # (no_overwrite=True) sauf en mode test yaml
self.record(code, no_overwrite=no_overwrite) if (
not only_validantes
) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT:
modif |= self.record(code, no_overwrite=no_overwrite)
return modif
def erase(self, only_one_sem=False): def erase(self, only_one_sem=False):
"""Efface les décisions de jury de cet étudiant """Efface les décisions de jury de cet étudiant
@ -1005,23 +1028,23 @@ class DecisionsProposeesRCUE(DecisionsProposees):
return f"""<{self.__class__.__name__} rcue={self.rcue} valid={self.code_valide return f"""<{self.__class__.__name__} rcue={self.rcue} valid={self.code_valide
} codes={self.codes} explanation={self.explanation}""" } codes={self.codes} explanation={self.explanation}"""
def record(self, code: str, no_overwrite=False): def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code RCUE. """Enregistre le code RCUE.
Note: Note:
- si le RCUE est ADJ, les UE non validées sont passées à ADJ - si le RCUE est ADJ, les UE non validées sont passées à ADJ
XXX on pourra imposer ici d'autres règles de cohérence XXX on pourra imposer ici d'autres règles de cohérence
""" """
if self.rcue is None: if self.rcue is None:
return # pas de RCUE a enregistrer return False # pas de RCUE a enregistrer
if self.inscription_etat != scu.INSCRIT: if self.inscription_etat != scu.INSCRIT:
return return False
if code and not code in self.codes: if code and not code in self.codes:
raise ScoValueError( raise ScoValueError(
f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
) )
if code == self.code_valide or (self.code_valide is not None and no_overwrite): if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True self.recorded = True
return # no change return False # no change
parcours_id = self.parcour.id if self.parcour is not None else None parcours_id = self.parcour.id if self.parcour is not None else None
if self.validation: if self.validation:
db.session.delete(self.validation) db.session.delete(self.validation)
@ -1057,7 +1080,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
flash( flash(
f"""UEs du RCUE "{dec_ue.ue.niveau_competence.competence.titre}" passées en ADJR""" f"""UEs du RCUE "{dec_ue.ue.niveau_competence.competence.titre}" passées en ADJR"""
) )
dec_ue.record("ADJR") dec_ue.record(sco_codes.ADJR)
# Valide les niveaux inférieurs de la compétence (code ADSUP) # Valide les niveaux inférieurs de la compétence (code ADSUP)
# TODO # TODO
@ -1072,6 +1095,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
) )
self.code_valide = code # mise à jour état self.code_valide = code # mise à jour état
self.recorded = True self.recorded = True
return True
def erase(self): def erase(self):
"""Efface la décision de jury de cet étudiant pour cet RCUE""" """Efface la décision de jury de cet étudiant pour cet RCUE"""
@ -1203,9 +1227,10 @@ class DecisionsProposeesUE(DecisionsProposees):
self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
self.explanation = "notes insuffisantes" self.explanation = "notes insuffisantes"
def record(self, code: str, no_overwrite=False): def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code jury pour cette UE. """Enregistre le code jury pour cette UE.
Si no_overwrite, n'enregistre pas s'il y a déjà un code. Si no_overwrite, n'enregistre pas s'il y a déjà un code.
Return: True si code enregistré (modifié)
""" """
if code and not code in self.codes: if code and not code in self.codes:
raise ScoValueError( raise ScoValueError(
@ -1213,7 +1238,7 @@ class DecisionsProposeesUE(DecisionsProposees):
) )
if code == self.code_valide or (self.code_valide is not None and no_overwrite): if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True self.recorded = True
return # no change return False # no change
self.erase() self.erase()
if code is None: if code is None:
self.validation = None self.validation = None
@ -1244,6 +1269,7 @@ class DecisionsProposeesUE(DecisionsProposees):
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id) sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)
self.code_valide = code # mise à jour self.code_valide = code # mise à jour
self.recorded = True self.recorded = True
return True
def erase(self): def erase(self):
"""Efface la décision de jury de cet étudiant pour cette UE""" """Efface la décision de jury de cet étudiant pour cette UE"""

View File

@ -284,6 +284,10 @@ class RowCollector:
self["_nom_disp_order"] = etud.sort_key self["_nom_disp_order"] = etud.sort_key
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail") self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court") self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court")
self["_nom_short_data"] = {
"etudid": etud.id,
"nomprenom": etud.nomprenom,
}
if with_links: if with_links:
self["_nom_short_order"] = etud.sort_key self["_nom_short_order"] = etud.sort_key
self["_nom_short_target"] = url_for( self["_nom_short_target"] = url_for(
@ -368,10 +372,6 @@ class RowCollector:
+ ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""), + ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
"col_rcue col_rcues_validables" + klass, "col_rcue col_rcues_validables" + klass,
) )
self["_rcues_validables_data"] = {
"etudid": deca.etud.id,
"nomprenom": deca.etud.nomprenom,
}
if len(deca.rcues_annee) > 0: if len(deca.rcues_annee) > 0:
# permet un tri par nb de niveaux validables + moyenne gen indicative S_pair # permet un tri par nb de niveaux validables + moyenne gen indicative S_pair
if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen: if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:

View File

@ -18,29 +18,29 @@ from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but( def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True
) -> int: ) -> int:
"""Calcul automatique des décisions de jury sur une année BUT. """Calcul automatique des décisions de jury sur une "année" BUT.
Ne modifie jamais de décisions de l'année scolaire précédente, même
- N'enregistre jamais de décisions de l'année scolaire précédente, même
si on a des RCUE "à cheval". si on a des RCUE "à cheval".
Normalement, only_adm est True et on n'enregistre que les décisions ADM (de droit). - Ne modifie jamais de décisions déjà enregistrées (sauf si no_overwrite est faux,
Si only_adm est faux, on enregistre la première décision proposée par ScoDoc ce qui est utilisé pour certains tests unitaires).
(mode à n'utiliser que pour les tests) - Normalement, only_adm est True et on n'enregistre que les décisions validantes
de droit: ADM ou CMP.
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
(mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
Si no_overwrite est vrai (défaut), ne -écrit jamais les codes déjà enregistrés Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
(utiliser faux pour certains tests)
Returns: nombre d'étudiants "admis"
""" """
if not formsemestre.formation.is_apc(): if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT") raise ScoValueError("fonction réservée aux formations BUT")
nb_admis = 0 nb_etud_modif = 0
with sco_cache.DeferredSemCacheManager(): with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions: for etudid in formsemestre.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid) etud: Identite = Identite.query.get(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if deca.admis: # année réussie nb_etud_modif += deca.record_all(
nb_admis += 1 no_overwrite=no_overwrite, only_validantes=only_adm
if deca.admis or not only_adm: )
deca.record_all(no_overwrite=no_overwrite)
db.session.commit() db.session.commit()
return nb_admis return nb_etud_modif

View File

@ -39,6 +39,7 @@ from dataclasses import dataclass
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import app
from app import db from app import db
from app.models import Evaluation, EvaluationUEPoids, ModuleImpl from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache from app.scodoc import sco_cache
@ -484,7 +485,8 @@ class ModuleImplResultsClassic(ModuleImplResults):
if nb_etuds == 0: if nb_etuds == 0:
return pd.Series() return pd.Series()
evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1) evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1)
assert evals_coefs.shape == (nb_evals,) if evals_coefs.shape != (nb_evals,):
app.critical_error("compute_module_moy: vals_coefs.shape != nb_evals")
evals_notes_20 = self.get_eval_notes_sur_20(modimpl) evals_notes_20 = self.get_eval_notes_sur_20(modimpl)
# Les coefs des évals pour chaque étudiant: là où il a des notes # Les coefs des évals pour chaque étudiant: là où il a des notes
# non neutralisées # non neutralisées

View File

@ -553,7 +553,11 @@ class ResultatsSemestre(ResultatsCache):
"Nom", "Nom",
etud.nom_short, etud.nom_short,
"identite_court", "identite_court",
data={"order": etud.sort_key}, data={
"order": etud.sort_key,
"etudid": etud.id,
"nomprenom": etud.nomprenom,
},
target=url_bulletin, target=url_bulletin,
target_attrs=f'class="etudinfo" id="{etudid}"', target_attrs=f'class="etudinfo" id="{etudid}"',
) )

View File

@ -71,6 +71,11 @@ class ApcValidationRCUE(db.Model):
<em>enregistrée le {self.date.strftime("%d/%m/%Y")} <em>enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>""" à {self.date.strftime("%Hh%M")}</em>"""
def annee(self) -> str:
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
niveau = self.niveau()
return niveau.annee if niveau else None
def niveau(self) -> ApcNiveau: def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE.""" """Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE # Par convention, il est donné par la seconde UE

View File

@ -63,51 +63,51 @@ class FormSemestre(db.Model):
"False si verrouillé" "False si verrouillé"
modalite = db.Column( modalite = db.Column(
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite") db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
) # "FI", "FAP", "FC", ... )
# gestion compensation sem DUT: "Modalité de formation: 'FI', 'FAP', 'FC', ..."
gestion_compensation = db.Column( gestion_compensation = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" db.Boolean(), nullable=False, default=False, server_default="false"
) )
# ne publie pas le bulletin XML ou JSON: "gestion compensation sem DUT (inutilisé en APC)"
bul_hide_xml = db.Column( bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" db.Boolean(), nullable=False, default=False, server_default="false"
) )
# Bloque le calcul des moyennes (générale et d'UE) "ne publie pas le bulletin XML ou JSON"
block_moyennes = db.Column( block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" db.Boolean(), nullable=False, default=False, server_default="false"
) )
# Bloque le calcul de la moyenne générale (utile pour BUT) "Bloque le calcul des moyennes (générale et d'UE)"
block_moyenne_generale = db.Column( block_moyenne_generale = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" db.Boolean(), nullable=False, default=False, server_default="false"
) )
"Si vrai, la moyenne générale indicative BUT n'est pas calculée" "Si vrai, la moyenne générale indicative BUT n'est pas calculée"
# semestres decales (pour gestion jurys):
gestion_semestrielle = db.Column( gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" db.Boolean(), nullable=False, default=False, server_default="false"
) )
# couleur fond bulletins HTML: "Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
bul_bgcolor = db.Column( bul_bgcolor = db.Column(
db.String(SHORT_STR_LEN), db.String(SHORT_STR_LEN),
default="white", default="white",
server_default="white", server_default="white",
nullable=False, nullable=False,
) )
# autorise resp. a modifier semestre: "couleur fond bulletins HTML"
resp_can_edit = db.Column( resp_can_edit = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" db.Boolean(), nullable=False, default=False, server_default="false"
) )
# autorise resp. a modifier slt les enseignants: "autorise resp. à modifier le formsemestre"
resp_can_change_ens = db.Column( resp_can_change_ens = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true" db.Boolean(), nullable=False, default=True, server_default="true"
) )
# autorise les ens a creer des evals: "autorise resp. a modifier slt les enseignants"
ens_can_edit_eval = db.Column( ens_can_edit_eval = db.Column(
db.Boolean(), nullable=False, default=False, server_default="False" db.Boolean(), nullable=False, default=False, server_default="False"
) )
# code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...' "autorise les enseignants à créer des évals dans leurs modimpls"
elt_sem_apo = db.Column(db.Text()) # peut être fort long ! elt_sem_apo = db.Column(db.Text()) # peut être fort long !
# code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...' "code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
elt_annee_apo = db.Column(db.Text()) elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
# Relations: # Relations:
etapes = db.relationship( etapes = db.relationship(

View File

@ -87,6 +87,7 @@ class Partition(db.Model):
def to_dict(self, with_groups=False) -> dict: def to_dict(self, with_groups=False) -> dict:
"""as a dict, with or without groups""" """as a dict, with or without groups"""
d = dict(self.__dict__) d = dict(self.__dict__)
d["partition_id"] = self.id
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
d.pop("formsemestre", None) d.pop("formsemestre", None)

View File

@ -219,6 +219,8 @@ class UniteEns(db.Model):
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
# Invalidation du cache
self.formation.invalidate_cached_sems()
log(f"ue.set_niveau_competence( {self}, {niveau} )") log(f"ue.set_niveau_competence( {self}, {niveau} )")
def set_parcour(self, parcour: ApcParcours): def set_parcour(self, parcour: ApcParcours):
@ -246,6 +248,8 @@ class UniteEns(db.Model):
self.niveau_competence = None self.niveau_competence = None
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
# Invalidation du cache
self.formation.invalidate_cached_sems()
log(f"ue.set_parcour( {self}, {parcour} )") log(f"ue.set_parcour( {self}, {parcour} )")

View File

@ -459,8 +459,7 @@ class JuryPE(object):
etud = self.get_cache_etudInfo_d_un_etudiant(etudid) etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
(_, parcours) = sco_report.get_codeparcoursetud(etud) (_, parcours) = sco_report.get_codeparcoursetud(etud)
if ( if (
len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values())) len(sco_codes_parcours.CODES_SEM_REO & set(parcours.values())) > 0
> 0
): # Eliminé car NAR apparait dans le parcours ): # Eliminé car NAR apparait dans le parcours
reponse = True reponse = True
if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2:
@ -563,9 +562,8 @@ class JuryPE(object):
dec = nt.get_etud_decision_sem( dec = nt.get_etud_decision_sem(
etudid etudid
) # quelle est la décision du jury ? ) # quelle est la décision du jury ?
if dec and dec["code"] in list( if dec and (dec["code"] in sco_codes_parcours.CODES_SEM_VALIDES):
sco_codes_parcours.CODES_SEM_VALIDES.keys() # isinstance( sesMoyennes[i+1], float) and
): # isinstance( sesMoyennes[i+1], float) and
# mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide" # mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide"
leFid = sem["formsemestre_id"] leFid = sem["formsemestre_id"]
else: else:

View File

@ -187,20 +187,23 @@ CODES_EXPL = {
# Les codes de semestres: # Les codes de semestres:
CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT} CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT}
CODES_SEM_VALIDES = {ADM: True, ADC: True, ADJ: True} # semestre validé CODES_SEM_VALIDES_DE_DROIT = {ADM, ADC}
CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente CODES_SEM_VALIDES = CODES_SEM_VALIDES_DE_DROIT | {ADJ} # semestre validé
CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente
CODES_SEM_REO = {NAR: 1} # reorientation CODES_SEM_REO = {NAR} # reorientation
CODES_UE_VALIDES = {ADM: True, CMP: True, ADJ: True, ADJR: True} CODES_UE_VALIDES_DE_DROIT = {ADM, CMP} # validation "de droit"
CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR}
"UE validée" "UE validée"
CODES_RCUE_VALIDES = {ADM, CMP, ADJ} CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP}
CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ}
"Niveau RCUE validé" "Niveau RCUE validé"
# Pour le BUT: # Pour le BUT:
CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD}
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL} CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
CODES_RCUE = {ADM, AJ, CMP}
BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE
BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE
@ -230,17 +233,17 @@ BUT_CODES_ORDERED = {
def code_semestre_validant(code: str) -> bool: def code_semestre_validant(code: str) -> bool:
"Vrai si ce CODE entraine la validation du semestre" "Vrai si ce CODE entraine la validation du semestre"
return CODES_SEM_VALIDES.get(code, False) return code in CODES_SEM_VALIDES
def code_semestre_attente(code: str) -> bool: def code_semestre_attente(code: str) -> bool:
"Vrai si ce CODE est un code d'attente (semestre validable plus tard par jury ou compensation)" "Vrai si ce CODE est un code d'attente (semestre validable plus tard par jury ou compensation)"
return CODES_SEM_ATTENTES.get(code, False) return code in CODES_SEM_ATTENTES
def code_ue_validant(code: str) -> bool: def code_ue_validant(code: str) -> bool:
"Vrai si ce code d'UE est validant (ie attribue les ECTS)" "Vrai si ce code d'UE est validant (ie attribue les ECTS)"
return CODES_UE_VALIDES.get(code, False) return code in CODES_UE_VALIDES
DEVENIR_EXPL = { DEVENIR_EXPL = {

View File

@ -890,7 +890,7 @@ def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite)
car ils ne dépendent que de la note d'UE et de la validation ou non du semestre. car ils ne dépendent que de la note d'UE et de la validation ou non du semestre.
Les UE des semestres NON ASSIDUS ne sont jamais validées (code AJ). Les UE des semestres NON ASSIDUS ne sont jamais validées (code AJ).
""" """
valid_semestre = CODES_SEM_VALIDES.get(code_etat_sem, False) valid_semestre = code_etat_sem in CODES_SEM_VALIDES
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
formsemestre = FormSemestre.query.get_or_404(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)

View File

@ -1185,7 +1185,10 @@ def do_formsemestre_clone(
"""Clone a semestre: make copy, same modules, same options, same resps, same partitions. """Clone a semestre: make copy, same modules, same options, same resps, same partitions.
New dates, responsable_id New dates, responsable_id
""" """
log("cloning %s" % orig_formsemestre_id) log(f"cloning orig_formsemestre_id")
formsemestre_orig: FormSemestre = FormSemestre.query.get_or_404(
orig_formsemestre_id
)
orig_sem = sco_formsemestre.get_formsemestre(orig_formsemestre_id) orig_sem = sco_formsemestre.get_formsemestre(orig_formsemestre_id)
cnx = ndb.GetDBConnexion() cnx = ndb.GetDBConnexion()
# 1- create sem # 1- create sem
@ -1196,7 +1199,8 @@ def do_formsemestre_clone(
args["date_fin"] = date_fin args["date_fin"] = date_fin
args["etat"] = 1 # non verrouillé args["etat"] = 1 # non verrouillé
formsemestre_id = sco_formsemestre.do_formsemestre_create(args) formsemestre_id = sco_formsemestre.do_formsemestre_create(args)
log("created formsemestre %s" % formsemestre_id) log(f"created formsemestre {formsemestre_id}")
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
# 2- create moduleimpls # 2- create moduleimpls
mods_orig = sco_moduleimpl.moduleimpl_list(formsemestre_id=orig_formsemestre_id) mods_orig = sco_moduleimpl.moduleimpl_list(formsemestre_id=orig_formsemestre_id)
for mod_orig in mods_orig: for mod_orig in mods_orig:
@ -1258,7 +1262,12 @@ def do_formsemestre_clone(
args["formsemestre_id"] = formsemestre_id args["formsemestre_id"] = formsemestre_id
_ = sco_compute_moy.formsemestre_ue_computation_expr_create(cnx, args) _ = sco_compute_moy.formsemestre_ue_computation_expr_create(cnx, args)
# 5- Copy partitions and groups # 6- Copie les parcours
formsemestre.parcours = formsemestre_orig.parcours
db.session.add(formsemestre)
db.session.commit()
# 7- Copy partitions and groups
if clone_partitions: if clone_partitions:
sco_groups_copy.clone_partitions_and_groups( sco_groups_copy.clone_partitions_and_groups(
orig_formsemestre_id, formsemestre_id orig_formsemestre_id, formsemestre_id

View File

@ -643,12 +643,12 @@ def formsemestre_description_table(
titles = {title: title for title in columns_ids} titles = {title: title for title in columns_ids}
titles.update({f"ue_{ue.id}": ue.acronyme for ue in ues}) titles.update({f"ue_{ue.id}": ue.acronyme for ue in ues})
titles["ects"] = "ECTS" titles["ects"] = "ECTS"
titles["jour"] = "Evaluation" titles["jour"] = "Évaluation"
titles["description"] = "" titles["description"] = ""
titles["coefficient"] = "Coef. éval." titles["coefficient"] = "Coef. éval."
titles["evalcomplete_str"] = "Complète" titles["evalcomplete_str"] = "Complète"
titles["parcours"] = "Parcours" titles["parcours"] = "Parcours"
titles["publish_incomplete_str"] = "Toujours Utilisée" titles["publish_incomplete_str"] = "Toujours utilisée"
title = f"{parcours.SESSION_NAME.capitalize()} {formsemestre.titre_mois()}" title = f"{parcours.SESSION_NAME.capitalize()} {formsemestre.titre_mois()}"
R = [] R = []
@ -727,6 +727,8 @@ def formsemestre_description_table(
evals.reverse() # ordre chronologique evals.reverse() # ordre chronologique
# Ajoute etat: # Ajoute etat:
for e in evals: for e in evals:
e["_jour_order"] = e["jour"].isoformat()
e["jour"] = e["jour"].strftime("%d/%m/%Y") if e["jour"] else ""
e["UE"] = l["UE"] e["UE"] = l["UE"]
e["_UE_td_attrs"] = l["_UE_td_attrs"] e["_UE_td_attrs"] = l["_UE_td_attrs"]
e["Code"] = l["Code"] e["Code"] = l["Code"]

View File

@ -781,8 +781,8 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None
) )
# Choix code semestre: # Choix code semestre:
codes = list(sco_codes_parcours.CODES_JURY_SEM) codes = sorted(sco_codes_parcours.CODES_JURY_SEM)
codes.sort() # fortuitement, cet ordre convient bien ! # fortuitement, cet ordre convient bien !
H.append( H.append(
'<tr><td>Code semestre: </td><td><select name="code_etat"><option value="" selected>Choisir...</option>' '<tr><td>Code semestre: </td><td><select name="code_etat"><option value="" selected>Choisir...</option>'

View File

@ -664,8 +664,10 @@ def set_group(etudid: int, group_id: int) -> bool:
return True return True
def change_etud_group_in_partition(etudid, group_id, partition=None): def change_etud_group_in_partition(etudid: int, group_id: int, partition: dict = None):
"""Inscrit etud au groupe de cette partition, et le desinscrit d'autres groupes de cette partition.""" """Inscrit etud au groupe de cette partition,
et le desinscrit d'autres groupes de cette partition.
"""
log("change_etud_group_in_partition: etudid=%s group_id=%s" % (etudid, group_id)) log("change_etud_group_in_partition: etudid=%s group_id=%s" % (etudid, group_id))
# 0- La partition # 0- La partition
@ -706,7 +708,7 @@ def change_etud_group_in_partition(etudid, group_id, partition=None):
cnx.commit() cnx.commit()
# 5- Update parcours # 5- Update parcours
formsemestre = FormSemestre.query.get(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
formsemestre.update_inscriptions_parcours_from_groups() formsemestre.update_inscriptions_parcours_from_groups()
# 6- invalidate cache # 6- invalidate cache
@ -1558,11 +1560,14 @@ def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):
def do_evaluation_listeetuds_groups( def do_evaluation_listeetuds_groups(
evaluation_id, groups=None, getallstudents=False, include_demdef=False evaluation_id: int,
): groups=None,
"""Donne la liste des etudids inscrits a cette evaluation dans les getallstudents: bool = False,
include_demdef: bool = False,
) -> list[tuple[int, str]]:
"""Donne la liste non triée des etudids inscrits à cette évaluation dans les
groupes indiqués. groupes indiqués.
Si getallstudents==True, donne tous les etudiants inscrits a cette Si getallstudents==True, donne tous les étudiants inscrits à cette
evaluation. evaluation.
Si include_demdef, compte aussi les etudiants démissionnaires et défaillants Si include_demdef, compte aussi les etudiants démissionnaires et défaillants
(sinon, par défaut, seulement les 'I') (sinon, par défaut, seulement les 'I')

View File

@ -36,6 +36,7 @@ from flask import url_for, g, request
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app import log from app import log
from app.models import FormSemestre
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
@ -175,6 +176,8 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
(la liste doit avoir été vérifiée au préalable) (la liste doit avoir été vérifiée au préalable)
En option: inscrit aux mêmes groupes que dans le semestre origine En option: inscrit aux mêmes groupes que dans le semestre origine
""" """
formsemestre: FormSemestre = FormSemestre.query.get(sem["formsemestre_id"])
formsemestre.setup_parcours_groups()
log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}") log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}")
for etudid in etudids: for etudid in etudids:
sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
@ -190,7 +193,6 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
# du nom de la partition: évidemment, cela ne marche pas si on a les # du nom de la partition: évidemment, cela ne marche pas si on a les
# même noms de groupes dans des partitions différentes) # même noms de groupes dans des partitions différentes)
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
log("cherche groupes de %(nom)s" % etud)
# recherche le semestre origine (il serait plus propre de l'avoir conservé!) # recherche le semestre origine (il serait plus propre de l'avoir conservé!)
if len(etud["sems"]) < 2: if len(etud["sems"]) < 2:
@ -201,13 +203,11 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
prev_formsemestre["formsemestre_id"] if prev_formsemestre else None, prev_formsemestre["formsemestre_id"] if prev_formsemestre else None,
) )
cursem_groups_by_name = dict( cursem_groups_by_name = {
[ g["group_name"]: g
(g["group_name"], g)
for g in sco_groups.get_sem_groups(sem["formsemestre_id"]) for g in sco_groups.get_sem_groups(sem["formsemestre_id"])
if g["group_name"] if g["group_name"]
] }
)
# forme la liste des groupes présents dans les deux semestres: # forme la liste des groupes présents dans les deux semestres:
partition_groups = [] # [ partition+group ] (ds nouveau sem.) partition_groups = [] # [ partition+group ] (ds nouveau sem.)
@ -217,9 +217,8 @@ def do_inscrit(sem, etudids, inscrit_groupes=False):
new_group = cursem_groups_by_name[prev_group_name] new_group = cursem_groups_by_name[prev_group_name]
partition_groups.append(new_group) partition_groups.append(new_group)
# inscrit aux groupes # Inscrit aux groupes
for partition_group in partition_groups: for partition_group in partition_groups:
if partition_group["groups_editable"]:
sco_groups.change_etud_group_in_partition( sco_groups.change_etud_group_in_partition(
etudid, etudid,
partition_group["group_id"], partition_group["group_id"],
@ -481,11 +480,12 @@ def build_page(
def formsemestre_inscr_passage_help(sem): def formsemestre_inscr_passage_help(sem):
return ( return f"""<div class="pas_help"><h3><a name="help">Explications</a></h3>
"""<div class="pas_help"><h3><a name="help">Explications</a></h3>
<p>Cette page permet d'inscrire des étudiants dans le semestre destination <p>Cette page permet d'inscrire des étudiants dans le semestre destination
<a class="stdlink" <a class="stdlink"
href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titreannee)s</a>, href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept, formsemestre_id=sem["formsemestre_id"] )
}">{sem['titreannee']}</a>,
et d'en désincrire si besoin. et d'en désincrire si besoin.
</p> </p>
<p>Les étudiants sont groupés par semestres d'origines. Ceux qui sont en caractères <p>Les étudiants sont groupés par semestres d'origines. Ceux qui sont en caractères
@ -495,10 +495,13 @@ def formsemestre_inscr_passage_help(sem):
<p>Au départ, les étudiants déjà inscrits sont sélectionnés; vous pouvez ajouter d'autres <p>Au départ, les étudiants déjà inscrits sont sélectionnés; vous pouvez ajouter d'autres
étudiants à inscrire dans le semestre destination.</p> étudiants à inscrire dans le semestre destination.</p>
<p>Si vous -selectionnez un étudiant déjà inscrit (en gras), il sera désinscrit.</p> <p>Si vous -selectionnez un étudiant déjà inscrit (en gras), il sera désinscrit.</p>
<p>Le bouton <em>inscrire aux mêmes groupes</em> ne prend en compte que les groupes qui existent
dans les deux semestres: pensez à créer les partitions et groupes que vous souhaitez conserver
<b>avant</b> d'inscrire les étudiants.
</p>
<p class="help">Aucune action ne sera effectuée si vous n'appuyez pas sur le bouton "Appliquer les modifications" !</p> <p class="help">Aucune action ne sera effectuée si vous n'appuyez pas sur le bouton "Appliquer les modifications" !</p>
</div>""" </div>
% sem """
)
def etuds_select_boxes( def etuds_select_boxes(
@ -574,13 +577,13 @@ def etuds_select_boxes(
if with_checkbox: if with_checkbox:
H.append( H.append(
""" (Select. """ (Select.
<a href="#" onclick="sem_select('%(id)s', true);">tous</a> <a href="#" class="stdlink" onclick="sem_select('%(id)s', true);">tous</a>
<a href="#" onclick="sem_select('%(id)s', false );">aucun</a>""" # " <a href="#" class="stdlink" onclick="sem_select('%(id)s', false );">aucun</a>""" # "
% infos % infos
) )
if sel_inscrits: if sel_inscrits:
H.append( H.append(
"""<a href="#" onclick="sem_select_inscrits('%(id)s');">inscrits</a>""" """<a href="#" class="stdlink" onclick="sem_select_inscrits('%(id)s');">inscrits</a>"""
% infos % infos
) )
if with_checkbox or sel_inscrits: if with_checkbox or sel_inscrits:

View File

@ -41,6 +41,7 @@ from app import log
from app.scodoc.scolog import logdb from app.scodoc.scolog import logdb
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import htmlutils from app.scodoc import htmlutils
from app.scodoc import sco_cache
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
from app.scodoc import sco_edit_module from app.scodoc import sco_edit_module
from app.scodoc import sco_edit_ue from app.scodoc import sco_edit_ue

View File

@ -459,7 +459,7 @@ def ficheEtud(etudid=None):
# XXX dev # XXX dev
info["but_cursus_mkup"] = "" info["but_cursus_mkup"] = ""
if info["sems"]: if info["sems"]:
last_sem = FormSemestre.query.get_or_404(info["sems"][-1]["formsemestre_id"]) last_sem = FormSemestre.query.get_or_404(info["sems"][0]["formsemestre_id"])
if last_sem.formation.is_apc(): if last_sem.formation.is_apc():
but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation) but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation)
info["but_cursus_mkup"] = render_template( info["but_cursus_mkup"] = render_template(

View File

@ -967,31 +967,35 @@ def has_existing_decision(M, E, etudid):
# Nouveau formulaire saisie notes (2016) # Nouveau formulaire saisie notes (2016)
def saisie_notes(evaluation_id, group_ids=[]): def saisie_notes(evaluation_id: int, group_ids: list = None):
"""Formulaire saisie notes d'une évaluation pour un groupe""" """Formulaire saisie notes d'une évaluation pour un groupe"""
if not isinstance(evaluation_id, int): if not isinstance(evaluation_id, int):
raise ScoInvalidParamError() raise ScoInvalidParamError()
group_ids = [int(group_id) for group_id in group_ids] group_ids = [int(group_id) for group_id in (group_ids or [])]
evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})
if not evals: if not evals:
raise ScoValueError("évaluation inexistante") raise ScoValueError("évaluation inexistante")
E = evals[0] E = evals[0]
M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0]
formsemestre_id = M["formsemestre_id"] formsemestre_id = M["formsemestre_id"]
moduleimpl_status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=E["moduleimpl_id"],
)
# Check access # Check access
# (admin, respformation, and responsable_id) # (admin, respformation, and responsable_id)
if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]): if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]):
return ( return f"""
html_sco_header.sco_header() {html_sco_header.sco_header()}
+ "<h2>Modification des notes impossible pour %s</h2>" <h2>Modification des notes impossible pour {current_user.user_name}</h2>
% current_user.user_name
+ """<p>(vérifiez que le semestre n'est pas verrouillé et que vous <p>(vérifiez que le semestre n'est pas verrouillé et que vous
avez l'autorisation d'effectuer cette opération)</p> avez l'autorisation d'effectuer cette opération)</p>
<p><a href="moduleimpl_status?moduleimpl_id=%s">Continuer</a></p> <p><a href="{ moduleimpl_status_url }">Continuer</a>
</p>
{html_sco_header.sco_footer()}
""" """
% E["moduleimpl_id"]
+ html_sco_header.sco_footer()
)
# Informations sur les groupes à afficher: # Informations sur les groupes à afficher:
groups_infos = sco_groups_view.DisplayedGroupsInfos( groups_infos = sco_groups_view.DisplayedGroupsInfos(
@ -1049,8 +1053,14 @@ def saisie_notes(evaluation_id, group_ids=[]):
alone=True, alone=True,
) )
) )
H.append("""</td><td style="padding-left: 35px;"><button class="btn_masquer_DEM">Masquer les DEM</button></td></tr></table></div>""") H.append(
H.append("""<style> """
</td>
<td style="padding-left: 35px;"><button class="btn_masquer_DEM">Masquer les DEM</button></td>
</tr>
</table>
</div>
<style>
.btn_masquer_DEM{ .btn_masquer_DEM{
font-size: 12px; font-size: 12px;
} }
@ -1061,19 +1071,14 @@ def saisie_notes(evaluation_id, group_ids=[]):
body.masquer_DEM .etud_dem{ body.masquer_DEM .etud_dem{
display: none !important; display: none !important;
} }
</style>""") </style>
"""
# Le formulaire de saisie des notes:
destination = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=E["moduleimpl_id"],
) )
form = _form_saisie_notes(E, M, groups_infos, destination=destination) # Le formulaire de saisie des notes:
form = _form_saisie_notes(E, M, groups_infos, destination=moduleimpl_status_url)
if form is None: if form is None:
log(f"redirecting to {destination}") return flask.redirect(moduleimpl_status_url)
return flask.redirect(destination)
H.append(form) H.append(form)
# #
H.append("</div>") # /saisie_notes H.append("</div>") # /saisie_notes
@ -1104,6 +1109,9 @@ def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int):
for etudid in etudids: for etudid in etudids:
# infos identite etudiant # infos identite etudiant
e = sco_etud.etudident_list(cnx, {"etudid": etudid})[0] e = sco_etud.etudident_list(cnx, {"etudid": etudid})[0]
etud: Identite = Identite.query.get(etudid)
# TODO: refactor et eliminer etudident_list.
e["etud"] = etud # utilisé seulement pour le tri -- a refactorer
sco_etud.format_etud_ident(e) sco_etud.format_etud_ident(e)
etuds.append(e) etuds.append(e)
# infos inscription dans ce semestre # infos inscription dans ce semestre
@ -1155,7 +1163,7 @@ def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int):
e["val"] = "DEM" e["val"] = "DEM"
e["explanation"] = "Démission" e["explanation"] = "Démission"
etuds.sort(key=lambda x: (x["nom"], x["prenom"])) etuds.sort(key=lambda x: x["etud"].sort_key)
return etuds return etuds

View File

@ -26,8 +26,10 @@ function change_menu_code(elt) {
let ue_selects = elt.parentElement.parentElement.parentElement.querySelectorAll( let ue_selects = elt.parentElement.parentElement.parentElement.querySelectorAll(
"select.ue_rcue_" + elt.dataset.niveau_id); "select.ue_rcue_" + elt.dataset.niveau_id);
ue_selects.forEach(select => { ue_selects.forEach(select => {
if (select.value != "ADM") {
select.value = "ADJR"; select.value = "ADJR";
change_menu_code(select); // pour changer les styles change_menu_code(select); // pour changer les styles
}
}); });
} }
} }

View File

@ -219,11 +219,11 @@ $(function () {
localStorage.setItem(order_info_key, order_info); localStorage.setItem(order_info_key, order_info);
} }
let etudids = []; let etudids = [];
document.querySelectorAll("td.col_rcues_validables").forEach(e => { document.querySelectorAll("td.identite_court").forEach(e => {
etudids.push(e.dataset.etudid); etudids.push(e.dataset.etudid);
}); });
let noms = []; let noms = [];
document.querySelectorAll("td.col_rcues_validables").forEach(e => { document.querySelectorAll("td.identite_court").forEach(e => {
noms.push(e.dataset.nomprenom); noms.push(e.dataset.nomprenom);
}); });
const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]); const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]);

View File

@ -8,14 +8,22 @@
{% block app_content %} {% block app_content %}
<h2>Calcul automatique des décisions de jury annuelle BUT</h2> <h2>Calcul automatique des décisions de jury du BUT</h2>
<ul> <ul>
<li>Seuls les étudiants qui valident l'année seront affectés: <li>N'enregistre jamais de décisions de l'année scolaire précédente, même
tous les niveaux de compétences (RCUE) validables si on a des RCUE "à cheval" sur deux années.
(moyenne annuelle au dessus de 10); </li>
<li>Ne modifie jamais de décisions déjà enregistrées.
</li>
<li>N'enregistre que les décisions <b>validantes de droit: ADM ou CMP</b>.
</li>
<li>L'assiduité n'est <b>pas</b> prise en compte.
</li> </li>
<li>l'assiduité n'est <b>pas</b> prise en compte;</li>
</ul> </ul>
<p>
En conséquence, saisir ensuite <b>manuellement les décisions manquantes</b>,
notamment sur les UEs en dessous de 10.
</p>
<p class="warning"> <p class="warning">
Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure ! Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure !
</p> </p>

View File

@ -2350,7 +2350,7 @@ def formsemestre_validation_but(
etud: Identite = Identite.query.filter_by( etud: Identite = Identite.query.filter_by(
id=etudid, dept_id=g.scodoc_dept_id id=etudid, dept_id=g.scodoc_dept_id
).first_or_404() ).first_or_404()
nb_etuds = formsemestre.etuds.count()
# la route ne donne pas le type d'etudid pour pouvoir construire des URLs # la route ne donne pas le type d'etudid pour pouvoir construire des URLs
# provisoires avec NEXT et PREV # provisoires avec NEXT et PREV
try: try:
@ -2360,16 +2360,24 @@ def formsemestre_validation_but(
read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) read_only = not sco_permissions_check.can_validate_sem(formsemestre_id)
# --- Navigation # --- Navigation
prev_lnk = f"""{scu.EMO_PREV_ARROW}&nbsp;<a href="{url_for( prev_lnk = (
f"""{scu.EMO_PREV_ARROW}&nbsp;<a href="{url_for(
"notes.formsemestre_validation_but", scodoc_dept=g.scodoc_dept, "notes.formsemestre_validation_but", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id, etudid="PREV" formsemestre_id=formsemestre_id, etudid="PREV"
)}" class="stdlink"">précédent</a> )}" class="stdlink"">précédent</a>
""" """
next_lnk = f"""<a href="{url_for( if nb_etuds > 1
else ""
)
next_lnk = (
f"""<a href="{url_for(
"notes.formsemestre_validation_but", scodoc_dept=g.scodoc_dept, "notes.formsemestre_validation_but", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id, etudid="NEXT" formsemestre_id=formsemestre_id, etudid="NEXT"
)}" class="stdlink"">suivant</a>&nbsp;{scu.EMO_NEXT_ARROW} )}" class="stdlink"">suivant</a>&nbsp;{scu.EMO_NEXT_ARROW}
""" """
if nb_etuds > 1
else ""
)
navigation_div = f""" navigation_div = f"""
<div class="but_navigation"> <div class="but_navigation">
<div class="prev"> <div class="prev">
@ -2548,10 +2556,10 @@ def formsemestre_validation_auto_but(formsemestre_id: int = None):
form = jury_but_forms.FormSemestreValidationAutoBUTForm() form = jury_but_forms.FormSemestreValidationAutoBUTForm()
if request.method == "POST": if request.method == "POST":
if not form.cancel.data: if not form.cancel.data:
nb_admis = jury_but_validation_auto.formsemestre_validation_auto_but( nb_etud_modif = jury_but_validation_auto.formsemestre_validation_auto_but(
formsemestre formsemestre
) )
flash(f"Décisions enregistrées ({nb_admis} admis)") flash(f"Décisions enregistrées ({nb_etud_modif} étudiants modifiés)")
return redirect( return redirect(
url_for( url_for(
"notes.formsemestre_saisie_jury", "notes.formsemestre_saisie_jury",
@ -2563,7 +2571,7 @@ def formsemestre_validation_auto_but(formsemestre_id: int = None):
"but/formsemestre_validation_auto_but.html", "but/formsemestre_validation_auto_but.html",
form=form, form=form,
sco=ScoData(formsemestre=formsemestre), sco=ScoData(formsemestre=formsemestre),
title=f"Calcul automatique jury BUT", title="Calcul automatique jury BUT",
) )
@ -2641,7 +2649,17 @@ def formsemestre_validation_auto(formsemestre_id):
message="<p>Opération non autorisée pour %s</h2>" % current_user, message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(), dest_url=scu.ScoURL(),
) )
formsemestre: FormSemestre = FormSemestre.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404()
if formsemestre.formation.is_apc():
return redirect(
url_for(
"notes.formsemestre_validation_auto_but",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
)
)
return sco_formsemestre_validation.formsemestre_validation_auto(formsemestre_id) return sco_formsemestre_validation.formsemestre_validation_auto(formsemestre_id)

View File

@ -935,7 +935,7 @@ def partition_editor(formsemestre_id: int):
def create_partition_parcours(formsemestre_id): def create_partition_parcours(formsemestre_id):
"""Création d'une partitions nommée "Parcours" (PARTITION_PARCOURS) """Création d'une partitions nommée "Parcours" (PARTITION_PARCOURS)
avec un groupe par parcours.""" avec un groupe par parcours."""
formsemestre = FormSemestre.query.get_or_404(formsemestre_id) formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre.setup_parcours_groups() formsemestre.setup_parcours_groups()
return flask.redirect( return flask.redirect(
url_for( url_for(

View File

@ -4,4 +4,5 @@ markers =
but_gb but_gb
lemans lemans
lyon lyon
test_test

View File

@ -1,7 +1,7 @@
# -*- mode: python -*- # -*- mode: python -*-
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
SCOVERSION = "9.4.29" SCOVERSION = "9.4.31"
SCONAME = "ScoDoc" SCONAME = "ScoDoc"

View File

@ -25,7 +25,7 @@ from app import models
from app.auth.models import User, Role, UserRole from app.auth.models import User, Role, UserRole
from app.entreprises.models import entreprises_reset_database from app.entreprises.models import entreprises_reset_database
from app.models import departements from app.models import Departement, departements
from app.models import Formation, UniteEns, Matiere, Module from app.models import Formation, UniteEns, Matiere, Module
from app.models import FormSemestre, FormSemestreInscription from app.models import FormSemestre, FormSemestreInscription
from app.models import GroupDescr from app.models import GroupDescr
@ -73,6 +73,7 @@ def make_shell_context():
"ctx": app.test_request_context(), "ctx": app.test_request_context(),
"current_app": flask.current_app, "current_app": flask.current_app,
"current_user": current_user, "current_user": current_user,
"Departement": Departement,
"db": db, "db": db,
"Evaluation": Evaluation, "Evaluation": Evaluation,
"flask": flask, "flask": flask,

View File

@ -34,7 +34,11 @@ def test_list_users(api_admin_headers):
# Tous les utilisateurs, vus par SuperAdmin: # Tous les utilisateurs, vus par SuperAdmin:
users = GET("/users/query", headers=admin_h) users = GET("/users/query", headers=admin_h)
assert len(users) > 2
# Les utilisateurs du dept. TAPI
users_TAPI = GET("/users/query?departement=TAPI", headers=admin_h)
nb_TAPI = len(users_TAPI)
assert nb_TAPI > 1
# Les utilisateurs de chaque département (+ ceux sans département) # Les utilisateurs de chaque département (+ ceux sans département)
all_users = [] all_users = []
for acronym in [dept["acronym"] for dept in depts] + [""]: for acronym in [dept["acronym"] for dept in depts] + [""]:
@ -59,9 +63,8 @@ def test_list_users(api_admin_headers):
for i, u in enumerate(u for u in u_users if u["dept"] != "TAPI"): for i, u in enumerate(u for u in u_users if u["dept"] != "TAPI"):
headers = get_auth_headers(u["user_name"], "test") headers = get_auth_headers(u["user_name"], "test")
users_by_u = GET("/users/query", headers=headers) users_by_u = GET("/users/query", headers=headers)
assert len(users_by_u) == 4 + i assert len(users_by_u) == nb_TAPI + 1 + i
# explication: tous ont le droit de voir les 3 users de TAPI # explication: tous ont le droit de voir les users de TAPI
# (test, other et u_TAPI)
# plus l'utilisateur de chaque département jusqu'au leur # plus l'utilisateur de chaque département jusqu'au leur
# (u_AA voit AA, u_BB voit AA et BB, etc) # (u_AA voit AA, u_BB voit AA et BB, etc)
@ -90,6 +93,10 @@ def test_edit_users(api_admin_headers):
) )
assert user["dept"] == "TAPI" assert user["dept"] == "TAPI"
assert user["active"] is False assert user["active"] is False
user = GET(f"/user/{user['id']}", headers=admin_h)
assert user["nom"] == "Toto"
assert user["dept"] == "TAPI"
assert user["active"] is False
def test_roles(api_admin_headers): def test_roles(api_admin_headers):
@ -229,3 +236,10 @@ def test_modif_users_depts(api_admin_headers):
ok = True ok = True
assert ok assert ok
# Nettoyage: # Nettoyage:
# on ne peut pas supprimer l'utilisateur lambda, mais on
# le rend inactif et on le retire de son département
u = POST_JSON(
f"/user/{u_lambda['id']}/edit",
{"active": False, "dept": None},
headers=admin_h,
)

47
tests/api/test_test.py Normal file
View File

@ -0,0 +1,47 @@
# -*- coding: UTF-8 -*
"""Unit tests for... tests
Ensure test DB is in the expected initial state.
Usage: pytest tests/unit/test_test.py
"""
import pytest
from tests.api.setup_test_api import (
api_headers,
GET,
)
@pytest.mark.test_test
def test_test_db(api_headers):
"""Check that we indeed have: 2 users, 1 dept, 3 formsemestres.
Juste après init, les ensembles seront ceux donnés ci-dessous.
Les autres tests peuvent ajouter des éléments, c'edt pourquoi on utilise issubset().
"""
headers = api_headers
assert {
"admin_api",
"admin",
"lecteur_api",
"other",
"test",
"u_AA",
"u_BB",
"u_CC",
"u_DD",
"u_TAPI",
}.issubset({u["user_name"] for u in GET("/users/query", headers=headers)})
assert {
"AA",
"BB",
"CC",
"DD",
"TAPI",
}.issubset({d["acronym"] for d in GET("/departements", headers=headers)})
assert 1 in (
formsemestre["semestre_id"]
for formsemestre in GET("/formsemestres/query", headers=headers)
)

View File

@ -129,7 +129,7 @@ FormSemestres:
Etudiants: Etudiants:
Aaaaa: Aïaaa: # avec un i trema
prenom: Étudiant_SEE prenom: Étudiant_SEE
civilite: M civilite: M
formsemestres: formsemestres:
@ -196,7 +196,7 @@ Etudiants:
S3: S3:
parcours: SEE parcours: SEE
Bbbbb: Azbbbb: # Az devrait être trié après Aï.
prenom: Étudiante_BMB prenom: Étudiante_BMB
civilite: F civilite: F
formsemestres: formsemestres: