diff --git a/app/api/jury.py b/app/api/jury.py
index 2a86766c..450edde7 100644
--- a/app/api/jury.py
+++ b/app/api/jury.py
@@ -8,25 +8,32 @@
ScoDoc 9 API : jury WIP à compléter avec enregistrement décisions
"""
-from flask import g, url_for
+import datetime
+
+from flask import flash, g, request, url_for
from flask_json import as_json
from flask_login import current_user, login_required
import app
from app import db, log
-from app.api import api_bp as bp, api_web_bp, tools
+from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR, tools
from app.decorators import scodoc, permission_required
from app.scodoc.sco_exceptions import ScoException
from app.but import jury_but_results
from app.models import (
+ ApcParcours,
ApcValidationAnnee,
ApcValidationRCUE,
+ Formation,
FormSemestre,
Identite,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
ScolarNews,
+ Scolog,
+ UniteEns,
)
+from app.scodoc import codes_cursus
from app.scodoc import sco_cache
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@@ -161,6 +168,136 @@ def autorisation_inscription_delete(etudid: int, validation_id: int):
return "ok"
+@bp.route(
+ "/etudiant//jury/validation_rcue/record",
+ methods=["POST"],
+)
+@api_web_bp.route(
+ "/etudiant//jury/validation_rcue/record",
+ methods=["POST"],
+)
+@login_required
+@scodoc
+@permission_required(Permission.ScoEtudInscrit)
+@as_json
+def validation_rcue_record(etudid: int):
+ """Enregistre une validation de RCUE.
+ Si une validation existe déjà pour ce RCUE, la remplace.
+ The request content type should be "application/json":
+ {
+ "code" : str,
+ "ue1_id" : int,
+ "ue2_id" : int,
+ // Optionnel:
+ "formsemestre_id" : int,
+ "date" : date_iso, // si non spécifié, now()
+ "parcours_id" :int,
+ }
+ """
+ etud = tools.get_etud(etudid)
+ if etud is None:
+ return json_error(404, "étudiant inconnu")
+ data = request.get_json(force=True) # may raise 400 Bad Request
+ code = data.get("code")
+ if code is None:
+ return json_error(API_CLIENT_ERROR, "missing argument: code")
+ if code not in codes_cursus.CODES_JURY_RCUE:
+ return json_error(API_CLIENT_ERROR, "invalid code value")
+ ue1_id = data.get("ue1_id")
+ if ue1_id is None:
+ return json_error(API_CLIENT_ERROR, "missing argument: ue1_id")
+ try:
+ ue1_id = int(ue1_id)
+ except ValueError:
+ return json_error(API_CLIENT_ERROR, "invalid value for ue1_id")
+ ue2_id = data.get("ue2_id")
+ if ue2_id is None:
+ return json_error(API_CLIENT_ERROR, "missing argument: ue2_id")
+ try:
+ ue2_id = int(ue2_id)
+ except ValueError:
+ return json_error(API_CLIENT_ERROR, "invalid value for ue2_id")
+ formsemestre_id = data.get("formsemestre_id")
+ date_validation_str = data.get("date", datetime.datetime.now().isoformat())
+ parcours_id = data.get("parcours_id")
+ #
+ query = UniteEns.query.filter_by(id=ue1_id)
+ if g.scodoc_dept:
+ query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
+ ue1: UniteEns = query.first_or_404()
+ query = UniteEns.query.filter_by(id=ue2_id)
+ if g.scodoc_dept:
+ query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
+ ue2: UniteEns = query.first_or_404()
+ if ue1.niveau_competence_id != ue2.niveau_competence_id:
+ return json_error(
+ API_CLIENT_ERROR, "UEs non associees au meme niveau de competence"
+ )
+ if formsemestre_id is not None:
+ query = FormSemestre.query.filter_by(id=formsemestre_id)
+ if g.scodoc_dept:
+ query = query.filter_by(dept_id=g.scodoc_dept_id)
+ formsemestre: FormSemestre = query.first_or_404()
+ if (formsemestre.formation_id != ue1.formation_id) or (
+ formsemestre.formation_id != ue2.formation_id
+ ):
+ return json_error(
+ API_CLIENT_ERROR, "ues et semestre ne sont pas de la meme formation"
+ )
+ else:
+ formsemestre = None
+ try:
+ date_validation = datetime.datetime.fromisoformat(date_validation_str)
+ except ValueError:
+ return json_error(API_CLIENT_ERROR, "invalid date string")
+ if parcours_id is not None:
+ parcours: ApcParcours = ApcParcours.query.get_or_404(parcours_id)
+ if parcours.referentiel_id != ue1.niveau_competence.competence.referentiel_id:
+ return json_error(API_CLIENT_ERROR, "niveau et parcours incompatibles")
+
+ # Une validation pour ce niveau de compétence existe-elle ?
+ validation = (
+ ApcValidationRCUE.query.filter_by(etudid=etudid)
+ .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
+ .filter_by(niveau_competence_id=ue2.niveau_competence_id)
+ .first()
+ )
+ if validation:
+ validation.code = code
+ validation.date = date_validation
+ validation.formsemestre_id = formsemestre_id
+ validation.parcours_id = parcours_id
+ validation.ue1_id = ue1_id
+ validation.ue2_id = ue2_id
+ log(f"updating {validation}")
+ Scolog.logdb(
+ method="validation_rcue_record",
+ etudid=etudid,
+ msg=f"Mise à jour {validation}",
+ commit=False,
+ )
+ else:
+ validation = ApcValidationRCUE(
+ code=code,
+ date=date_validation,
+ etudid=etudid,
+ formsemestre_id=formsemestre_id,
+ parcours_id=parcours_id,
+ ue1_id=ue1_id,
+ ue2_id=ue2_id,
+ )
+ log(f"recording {validation}")
+ Scolog.logdb(
+ method="validation_rcue_record",
+ etudid=etudid,
+ msg=f"Enregistrement {validation}",
+ commit=False,
+ )
+ db.session.add(validation)
+ db.session.commit()
+ return validation.to_dict()
+
+
@bp.route(
"/etudiant//jury/validation_rcue//delete",
methods=["POST"],
diff --git a/app/api/partitions.py b/app/api/partitions.py
index 4d752b65..2be45abc 100644
--- a/app/api/partitions.py
+++ b/app/api/partitions.py
@@ -12,6 +12,7 @@ from operator import attrgetter
from flask import g, request
from flask_json import as_json
from flask_login import login_required
+import sqlalchemy as sa
from sqlalchemy.exc import IntegrityError
import app
@@ -248,19 +249,25 @@ def partition_remove_etud(partition_id: int, etudid: int):
partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
- groups = (
- GroupDescr.query.filter_by(partition_id=partition_id)
- .join(group_membership)
- .filter_by(etudid=etudid)
+
+ db.session.execute(
+ sa.text(
+ """DELETE FROM group_membership
+ WHERE etudid=:etudid
+ and group_id IN (
+ SELECT id FROM group_descr WHERE partition_id = :partition_id
+ );
+ """
+ ),
+ {"etudid": etudid, "partition_id": partition_id},
+ )
+
+ Scolog.logdb(
+ method="partition_remove_etud",
+ etudid=etud.id,
+ msg=f"Retrait de la partition {partition.partition_name}",
+ commit=False,
)
- for group in groups:
- group.etuds.remove(etud)
- Scolog.logdb(
- method="partition_remove_etud",
- etudid=etud.id,
- msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
- commit=True,
- )
db.session.commit()
# Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups()
@@ -275,7 +282,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
@scodoc
@permission_required(Permission.ScoEtudChangeGroups)
@as_json
-def group_create(partition_id: int):
+def group_create(partition_id: int): # partition-group-create
"""Création d'un groupe dans une partition
The request content type should be "application/json":
diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py
index 748908be..f8b28675 100644
--- a/app/but/cursus_but.py
+++ b/app/but/cursus_but.py
@@ -18,7 +18,7 @@ from operator import attrgetter
from flask import g, url_for
-from app import db
+from app import db, log
from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
@@ -41,7 +41,8 @@ from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus as sco_codes
-from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES
+from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
+
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
@@ -479,3 +480,122 @@ def formsemestre_warning_apc_setup(
"""
+
+
+def ue_associee_au_niveau_du_parcours(
+ ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
+) -> UniteEns:
+ "L'UE associée à ce niveau, ou None"
+ ues = [ue for ue in ues_possibles if ue.niveau_competence_id == niveau.id]
+ if len(ues) > 1:
+ # plusieurs UEs associées à ce niveau: élimine celles sans parcours
+ ues_pair_avec_parcours = [ue for ue in ues if ue.parcours]
+ if ues_pair_avec_parcours:
+ ues = ues_pair_avec_parcours
+ if len(ues) > 1:
+ log(f"_niveau_ues: {len(ues)} associées au niveau {niveau} / {sem_name}")
+ return ues[0] if ues else None
+
+
+def parcour_formation_competences(parcour: ApcParcours, formation: Formation) -> list:
+ """
+ [
+ {
+ 'competence' : ApcCompetence,
+ 'niveaux' : {
+ 1 : { ... },
+ 2 : { ... },
+ 3 : {
+ 'niveau' : ApcNiveau,
+ 'ue_impair' : UniteEns, # actuellement associée
+ 'ues_impair' : list[UniteEns], # choix possibles
+ 'ue_pair' : UniteEns,
+ 'ues_pair' : list[UniteEns],
+ }
+ }
+ }
+ ]
+ """
+ refcomp: ApcReferentielCompetences = formation.referentiel_competence
+
+ def _niveau_ues(competence: ApcCompetence, annee: int) -> dict:
+ """niveau et ues pour cette compétence de cette année du parcours.
+ Si parcour est None, les niveaux du tronc commun
+ """
+ if parcour is not None:
+ # L'étudiant est inscrit à un parcours: cherche les niveaux
+ niveaux = ApcNiveau.niveaux_annee_de_parcours(
+ parcour, annee, competence=competence
+ )
+ else:
+ # sans parcours, on cherche les niveaux du Tronc Commun de cette année
+ niveaux = [
+ niveau
+ for niveau in refcomp.get_niveaux_by_parcours(annee)[1]["TC"]
+ if niveau.competence_id == competence.id
+ ]
+
+ if len(niveaux) > 0:
+ if len(niveaux) > 1:
+ log(
+ f"""_niveau_ues: plus d'un niveau pour {competence}
+ annee {annee} {("parcours " + parcour.code) if parcour else ""}"""
+ )
+ niveau = niveaux[0]
+ elif len(niveaux) == 0:
+ return {
+ "niveau": None,
+ "ue_pair": None,
+ "ue_impair": None,
+ "ues_pair": [],
+ "ues_impair": [],
+ }
+ # Toutes les UEs de la formation dans ce parcours ou tronc commun
+ ues = [
+ ue
+ for ue in formation.ues
+ if (
+ (not ue.parcours)
+ or (parcour is not None and (parcour.id in (p.id for p in ue.parcours)))
+ )
+ and ue.type == UE_STANDARD
+ ]
+ ues_pair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee)]
+ ues_impair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee - 1)]
+
+ # UE associée au niveau dans ce parcours
+ ue_pair = ue_associee_au_niveau_du_parcours(
+ ues_pair_possibles, niveau, f"S{2*annee}"
+ )
+ ue_impair = ue_associee_au_niveau_du_parcours(
+ ues_impair_possibles, niveau, f"S{2*annee-1}"
+ )
+
+ return {
+ "niveau": niveau,
+ "ue_pair": ue_pair,
+ "ues_pair": [
+ ue
+ for ue in ues_pair_possibles
+ if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
+ ],
+ "ue_impair": ue_impair,
+ "ues_impair": [
+ ue
+ for ue in ues_impair_possibles
+ if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id
+ ],
+ }
+
+ competences = [
+ {
+ "competence": competence,
+ "niveaux": {annee: _niveau_ues(competence, annee) for annee in (1, 2, 3)},
+ }
+ for competence in (
+ parcour.query_competences()
+ if parcour
+ else refcomp.competences.order_by(ApcCompetence.numero)
+ )
+ ]
+ return competences
diff --git a/app/but/validations_view.py b/app/but/validations_view.py
new file mode 100644
index 00000000..344214a5
--- /dev/null
+++ b/app/but/validations_view.py
@@ -0,0 +1,117 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Jury édition manuelle des décisions (correction d'erreurs, parcours hors normes)
+
+Non spécifique au BUT.
+"""
+
+from flask import render_template
+import sqlalchemy as sa
+
+from app import log
+from app.but import cursus_but
+from app.models import (
+ ApcCompetence,
+ ApcNiveau,
+ ApcReferentielCompetences,
+ # ApcValidationAnnee, # TODO
+ ApcValidationRCUE,
+ Formation,
+ FormSemestre,
+ Identite,
+ UniteEns,
+ # ScolarAutorisationInscription,
+ ScolarFormSemestreValidation,
+)
+from app.scodoc import codes_cursus
+from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
+from app.views import ScoData
+
+
+def validation_rcues(etud: Identite, formsemestre: FormSemestre, edit: bool = False):
+ """Page de saisie des décisions de RCUEs "antérieures"
+ On peut l'utiliser pour saisir la validation de n'importe quel RCUE
+ d'une année antérieure et de la formation du formsemestre indiqué.
+ """
+ formation: Formation = formsemestre.formation
+ refcomp = formation.referentiel_competence
+ if refcomp is None:
+ raise ScoNoReferentielCompetences(formation=formation)
+ parcour = formsemestre.etuds_inscriptions[etud.id].parcour
+ # Si non inscrit à un parcours, prend toutes les compétences
+ competences_parcour = cursus_but.parcour_formation_competences(parcour, formation)
+
+ ue_validation_by_niveau = get_ue_validation_by_niveau(refcomp, etud)
+ rcue_validation_by_niveau = get_rcue_validation_by_niveau(refcomp, etud)
+ ects_total = sum((v.ects() for v in ue_validation_by_niveau.values()))
+ return render_template(
+ "but/validation_rcues.j2",
+ competences_parcour=competences_parcour,
+ edit=edit,
+ ects_total=ects_total,
+ formation=formation,
+ parcour=parcour,
+ rcue_validation_by_niveau=rcue_validation_by_niveau,
+ rcue_codes=sorted(codes_cursus.CODES_JURY_RCUE),
+ sco=ScoData(formsemestre=formsemestre, etud=etud),
+ title=f"{formation.acronyme} - Niveaux et UEs",
+ ue_validation_by_niveau=ue_validation_by_niveau,
+ )
+
+
+def get_ue_validation_by_niveau(
+ refcomp: ApcReferentielCompetences, etud: Identite
+) -> dict[tuple[int, str], ScolarFormSemestreValidation]:
+ """Les validations d'UEs de cet étudiant liées à ce référentiel de compétences.
+ Pour chaque niveau / pair ou impair, choisi la "meilleure" validation
+ """
+ validations: list[ScolarFormSemestreValidation] = (
+ ScolarFormSemestreValidation.query.filter_by(etudid=etud.id)
+ .join(UniteEns)
+ .join(ApcNiveau)
+ .join(ApcCompetence)
+ .filter_by(referentiel_id=refcomp.id)
+ .all()
+ )
+ # La meilleure validation pour chaque UE
+ ue_validation_by_niveau = {} # { (niveau_id, pair|impair) : validation }
+ for validation in validations:
+ if validation.ue.niveau_competence is None:
+ log(
+ f"""validation_rcues: ignore validation d'UE {
+ validation.ue.id} pas de niveau de competence"""
+ )
+ key = (
+ validation.ue.niveau_competence.id,
+ "impair" if validation.ue.semestre_idx % 2 else "pair",
+ )
+ existing = ue_validation_by_niveau.get(key, None)
+ if (not existing) or (
+ codes_cursus.BUT_CODES_ORDER[existing.code]
+ < codes_cursus.BUT_CODES_ORDER[validation.code]
+ ):
+ ue_validation_by_niveau[key] = validation
+ return ue_validation_by_niveau
+
+
+def get_rcue_validation_by_niveau(
+ refcomp: ApcReferentielCompetences, etud: Identite
+) -> dict[int, ApcValidationRCUE]:
+ """Les validations d'UEs de cet étudiant liées à ce référentiel de compétences.
+ Pour chaque niveau / pair ou impair, choisi la "meilleure" validation
+ """
+ validations: list[ApcValidationRCUE] = (
+ ApcValidationRCUE.query.filter_by(etudid=etud.id)
+ .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id)
+ .join(ApcNiveau, UniteEns.niveau_competence_id == ApcNiveau.id)
+ .join(ApcCompetence)
+ .filter_by(referentiel_id=refcomp.id)
+ .all()
+ )
+ return {
+ validation.ue2.niveau_competence.id: validation for validation in validations
+ }
diff --git a/app/models/validations.py b/app/models/validations.py
index d4ca5bb0..14e7a5b7 100644
--- a/app/models/validations.py
+++ b/app/models/validations.py
@@ -10,6 +10,7 @@ from app.models import CODE_STR_LEN
from app.models.events import Scolog
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
+from app.scodoc.codes_cursus import CODES_UE_VALIDES
class ScolarFormSemestreValidation(db.Model):
@@ -122,6 +123,14 @@ class ScolarFormSemestreValidation(db.Model):
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
"""
+ def ects(self) -> float:
+ "Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)"
+ return (
+ self.ue.ects
+ if (self.ue is not None) and (self.code in CODES_UE_VALIDES)
+ else 0.0
+ )
+
class ScolarAutorisationInscription(db.Model):
"""Autorisation d'inscription dans un semestre"""
diff --git a/app/scodoc/codes_cursus.py b/app/scodoc/codes_cursus.py
index dff4ead7..70e1e829 100644
--- a/app/scodoc/codes_cursus.py
+++ b/app/scodoc/codes_cursus.py
@@ -204,6 +204,7 @@ CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR, ADSUP}
CODES_UE_CAPITALISANTS = {ADM}
"UE capitalisée"
+CODES_JURY_RCUE = CODES_JURY_UE # tous les codes d'UEs sont utilisables pour les RCUEs
CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP}
CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ, ADSUP}
"Niveau RCUE validé"
diff --git a/app/scodoc/sco_formation_versions.py b/app/scodoc/sco_formation_versions.py
index e4a5937e..8ffcb39c 100644
--- a/app/scodoc/sco_formation_versions.py
+++ b/app/scodoc/sco_formation_versions.py
@@ -224,7 +224,7 @@ def do_formsemestres_associate_new_version(
_reassociate_moduleimpls(formsemestre, ues_old2new, modules_old2new)
db.session.commit()
- return formation_id
+ return new_formation_id
def _reassociate_moduleimpls(
diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py
index 17b76f2c..c5f68fe0 100644
--- a/app/scodoc/sco_groups.py
+++ b/app/scodoc/sco_groups.py
@@ -560,10 +560,10 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
x_group = Element(
"group",
partition_id=str(partition_id),
- partition_name=partition["partition_name"],
+ partition_name=partition["partition_name"] or "",
groups_editable=str(int(partition["groups_editable"])),
group_id=str(group["group_id"]),
- group_name=group["group_name"],
+ group_name=group["group_name"] or "",
)
x_response.append(x_group)
for e in get_group_members(group["group_id"]):
@@ -572,10 +572,10 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
Element(
"etud",
etudid=str(e["etudid"]),
- civilite=etud["civilite_str"],
- sexe=etud["civilite_str"], # compat
- nom=sco_etud.format_nom(etud["nom"]),
- prenom=sco_etud.format_prenom(etud["prenom"]),
+ civilite=etud["civilite_str"] or "",
+ sexe=etud["civilite_str"] or "", # compat
+ nom=sco_etud.format_nom(etud["nom"] or ""),
+ prenom=sco_etud.format_prenom(etud["prenom"] or ""),
origin=_comp_etud_origin(etud, formsemestre),
)
)
@@ -792,7 +792,7 @@ def setGroups(
return xml_error(msg, code=404)
# Place dans ce groupe les etudiants indiqués:
for etudid in fs[1:-1]:
- change_etud_group_in_partition(etudid, group.id)
+ change_etud_group_in_partition(etudid, group)
# Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups()
@@ -950,10 +950,20 @@ def edit_partition_form(formsemestre_id=None):
}
""",
- r"""Partitions du semestre
+ f"""Partitions du semestre
+
+ 👉💡 vous pourriez essayer le nouvel éditeur
+
+