diff --git a/app/__init__.py b/app/__init__.py
index 6a1ea6d04..d6e0be82f 100755
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -637,14 +637,12 @@ def critical_error(msg):
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
- send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
+ subject = f"CRITICAL ERROR: {msg}".strip()[:68]
+ send_scodoc_alarm(subject, 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}
+ Une erreur est survenue, veuillez ré-essayer.
{msg}
"""
diff --git a/app/api/assiduites.py b/app/api/assiduites.py
index 3f3aee5d3..7d46ddfe4 100644
--- a/app/api/assiduites.py
+++ b/app/api/assiduites.py
@@ -161,8 +161,17 @@ def count_assiduites(
query?est_just=f
query?est_just=t
-
-
+ QUERY
+ -----
+ user_id:
+ est_just:
+ moduleimpl_id:
+ date_debut:
+ date_fin:
+ etat:
+ formsemestre_id:
+ metric:
+ split:
"""
@@ -253,6 +262,15 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
query?est_just=f
query?est_just=t
+ QUERY
+ -----
+ user_id:
+ est_just:
+ moduleimpl_id:
+ date_debut:
+ date_fin:
+ etat:
+ formsemestre_id:
"""
@@ -329,6 +347,16 @@ def assiduites_group(with_query: bool = False):
query?est_just=f
query?est_just=t
+ QUERY
+ -----
+ user_id:
+ est_just:
+ moduleimpl_id:
+ date_debut:
+ date_fin:
+ etat:
+ etudids:
"""
@@ -388,7 +416,16 @@ def assiduites_group(with_query: bool = False):
@as_json
@permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
- """Retourne toutes les assiduités du formsemestre"""
+ """Retourne toutes les assiduités du formsemestre
+ QUERY
+ -----
+ user_id:
+ est_just:
+ moduleimpl_id:
+ date_debut:
+ date_fin:
+ etat:
+ """
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
@@ -438,7 +475,20 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False
):
- """Comptage des assiduités du formsemestre"""
+ """Comptage des assiduités du formsemestre
+
+ QUERY
+ -----
+ user_id:
+ est_just:
+ moduleimpl_id:
+ date_debut:
+ date_fin:
+ etat:
+ formsemestre_id:
+ metric:
+ split:
+ """
# Récupération du formsemestre à partir du formsemestre_id
formsemestre: FormSemestre = None
diff --git a/app/api/formations.py b/app/api/formations.py
index 53d821a05..7c6bd4930 100644
--- a/app/api/formations.py
+++ b/app/api/formations.py
@@ -15,12 +15,14 @@ from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
+from app.models import APO_CODE_STR_LEN
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import (
ApcNiveau,
ApcParcours,
Formation,
+ Module,
UniteEns,
)
from app.scodoc import sco_formations
@@ -336,3 +338,166 @@ def desassoc_ue_niveau(ue_id: int):
# "usage web"
flash(f"UE {ue.acronyme} dé-associée")
return {"status": 0}
+
+
+@bp.route("/ue/", methods=["GET"])
+@api_web_bp.route("/ue/", methods=["GET"])
+@login_required
+@scodoc
+@permission_required(Permission.ScoView)
+def get_ue(ue_id: int):
+ """Renvoie l'UE"""
+ 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()
+ return ue.to_dict(convert_objects=True)
+
+
+@bp.route("/module/", methods=["GET"])
+@api_web_bp.route("/module/", methods=["GET"])
+@login_required
+@scodoc
+@permission_required(Permission.ScoView)
+def get_module(module_id: int):
+ """Renvoie le module"""
+ query = Module.query.filter_by(id=module_id)
+ if g.scodoc_dept:
+ query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
+ module: Module = query.first_or_404()
+ return module.to_dict(convert_objects=True)
+
+
+@bp.route("/ue//set_code_apogee/", methods=["POST"])
+@api_web_bp.route(
+ "/ue//set_code_apogee/", methods=["POST"]
+)
+@bp.route(
+ "/ue//set_code_apogee", defaults={"code_apogee": ""}, methods=["POST"]
+)
+@api_web_bp.route(
+ "/ue//set_code_apogee", defaults={"code_apogee": ""}, methods=["POST"]
+)
+@login_required
+@scodoc
+@permission_required(Permission.EditFormation)
+def ue_set_code_apogee(ue_id: int, code_apogee: str = ""):
+ """Change le code Apogée de l'UE.
+ Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
+ par des virgules.
+ (Ce changement peut être fait sur formation verrouillée)
+
+ Si code_apogee n'est pas spécifié ou vide,
+ utilise l'argument value du POST (utilisé par jinplace.js)
+
+ Le retour est une chaîne (le code enregistré), pas json.
+ """
+ if not code_apogee:
+ code_apogee = request.form.get("value", "")
+ 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()
+
+ code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
+
+ log(f"API ue_set_code_apogee: ue_id={ue.id} code_apogee={code_apogee}")
+
+ ue.code_apogee = code_apogee
+ db.session.add(ue)
+ db.session.commit()
+ return code_apogee or ""
+
+
+@bp.route("/ue//set_code_apogee_rcue/", methods=["POST"])
+@api_web_bp.route(
+ "/ue//set_code_apogee_rcue/", methods=["POST"]
+)
+@bp.route(
+ "/ue//set_code_apogee_rcue",
+ defaults={"code_apogee": ""},
+ methods=["POST"],
+)
+@api_web_bp.route(
+ "/ue//set_code_apogee_rcue",
+ defaults={"code_apogee": ""},
+ methods=["POST"],
+)
+@login_required
+@scodoc
+@permission_required(Permission.EditFormation)
+def ue_set_code_apogee_rcue(ue_id: int, code_apogee: str = ""):
+ """Change le code Apogée du RCUE de l'UE.
+ Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
+ par des virgules.
+ (Ce changement peut être fait sur formation verrouillée)
+
+ Si code_apogee n'est pas spécifié ou vide,
+ utilise l'argument value du POST (utilisé par jinplace.js)
+
+ Le retour est une chaîne (le code enregistré), pas json.
+ """
+ if not code_apogee:
+ code_apogee = request.form.get("value", "")
+ 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()
+
+ code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
+
+ log(f"API ue_set_code_apogee_rcue: ue_id={ue.id} code_apogee={code_apogee}")
+
+ ue.code_apogee_rcue = code_apogee
+ db.session.add(ue)
+ db.session.commit()
+ return code_apogee or ""
+
+
+@bp.route(
+ "/module//set_code_apogee/",
+ methods=["POST"],
+)
+@api_web_bp.route(
+ "/module//set_code_apogee/",
+ methods=["POST"],
+)
+@bp.route(
+ "/module//set_code_apogee",
+ defaults={"code_apogee": ""},
+ methods=["POST"],
+)
+@api_web_bp.route(
+ "/module//set_code_apogee",
+ defaults={"code_apogee": ""},
+ methods=["POST"],
+)
+@login_required
+@scodoc
+@permission_required(Permission.EditFormation)
+def module_set_code_apogee(module_id: int, code_apogee: str = ""):
+ """Change le code Apogée du module.
+ Le code est une chaîne, avec éventuellement plusieurs valeurs séparées
+ par des virgules.
+ (Ce changement peut être fait sur formation verrouillée)
+
+ Si code_apogee n'est pas spécifié ou vide,
+ utilise l'argument value du POST (utilisé par jinplace.js)
+
+ Le retour est une chaîne (le code enregistré), pas json.
+ """
+ if not code_apogee:
+ code_apogee = request.form.get("value", "")
+ query = Module.query.filter_by(id=module_id)
+ if g.scodoc_dept:
+ query = query.join(Formation).filter_by(dept_id=g.scodoc_dept_id)
+ module: Module = query.first_or_404()
+
+ code_apogee = code_apogee.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque
+
+ log(f"API module_set_code_apogee: module_id={module.id} code_apogee={code_apogee}")
+
+ module.code_apogee = code_apogee
+ db.session.add(module)
+ db.session.commit()
+ return code_apogee or ""
diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py
index b3528362a..ad77f54cd 100644
--- a/app/api/justificatifs.py
+++ b/app/api/justificatifs.py
@@ -3,8 +3,8 @@
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
-"""ScoDoc 9 API : Justificatifs
-"""
+"""ScoDoc 9 API : Justificatifs"""
+
from datetime import datetime
from flask_json import as_json
@@ -113,6 +113,16 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
+ QUERY
+ -----
+ user_id:
+ est_just:
+ date_debut:
+ date_fin:
+ etat:
+ order:
+ courant:
+ group_id:
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
@@ -154,6 +164,17 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False):
"""
Renvoie tous les justificatifs d'un département
(en ajoutant un champ "formsemestre" si possible)
+
+ QUERY
+ -----
+ user_id:
+ est_just:
+ date_debut:
+ date_fin:
+ etat:
+ order:
+ courant:
+ group_id:
"""
# Récupération du département et des étudiants du département
@@ -225,7 +246,19 @@ def _set_sems(justi: Justificatif, restrict: bool) -> dict:
@as_json
@permission_required(Permission.ScoView)
def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
- """Retourne tous les justificatifs du formsemestre"""
+ """Retourne tous les justificatifs du formsemestre
+
+ QUERY
+ -----
+ user_id:
+ est_just:
+ date_debut:
+ date_fin:
+ etat:
+ order:
+ courant:
+ group_id:
+ """
# Récupération du formsemestre
formsemestre: FormSemestre = None
diff --git a/app/auth/models.py b/app/auth/models.py
index eb278dd13..7e9642ddf 100644
--- a/app/auth/models.py
+++ b/app/auth/models.py
@@ -14,6 +14,15 @@ import cracklib # pylint: disable=import-error
from flask import current_app, g
from flask_login import UserMixin, AnonymousUserMixin
+from sqlalchemy.exc import (
+ IntegrityError,
+ DataError,
+ DatabaseError,
+ OperationalError,
+ ProgrammingError,
+ StatementError,
+ InterfaceError,
+)
from werkzeug.security import generate_password_hash, check_password_hash
@@ -48,13 +57,13 @@ def is_valid_password(cleartxt) -> bool:
return False
-def invalid_user_name(user_name: str) -> bool:
- "Check that user_name (aka login) is invalid"
+def is_valid_user_name(user_name: str) -> bool:
+ "Check that user_name (aka login) is valid"
return (
- not user_name
- or (len(user_name) < 2)
- or (len(user_name) >= USERNAME_STR_LEN)
- or not VALID_LOGIN_EXP.match(user_name)
+ user_name
+ and (len(user_name) >= 2)
+ and (len(user_name) < USERNAME_STR_LEN)
+ and VALID_LOGIN_EXP.match(user_name)
)
@@ -123,7 +132,7 @@ class User(UserMixin, ScoDocModel):
# check login:
if not "user_name" in kwargs:
raise ValueError("missing user_name argument")
- if invalid_user_name(kwargs["user_name"]):
+ if not is_valid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
kwargs["nom"] = kwargs.get("nom", "") or ""
kwargs["prenom"] = kwargs.get("prenom", "") or ""
@@ -329,7 +338,8 @@ class User(UserMixin, ScoDocModel):
if new_user:
if "user_name" in data:
# never change name of existing users
- if invalid_user_name(data["user_name"]):
+ # (see change_user_name method to do that)
+ if not is_valid_user_name(data["user_name"]):
raise ValueError(f"invalid user_name: {data['user_name']}")
self.user_name = data["user_name"]
if "password" in data:
@@ -522,6 +532,64 @@ class User(UserMixin, ScoDocModel):
# nomnoacc était le nom en minuscules sans accents (inutile)
+ def change_user_name(self, new_user_name: str):
+ """Modify user name, update all relevant tables.
+ commit session.
+ """
+ # Safety check
+ new_user_name = new_user_name.strip()
+ if (
+ not is_valid_user_name(new_user_name)
+ or User.query.filter_by(user_name=new_user_name).count() > 0
+ ):
+ raise ValueError("invalid user_name")
+ # Le user_name est utilisé dans d'autres tables (sans être une clé)
+ # BulAppreciations.author
+ # EntrepriseHistorique.authenticated_user
+ # EtudAnnotation.author
+ # ScolarNews.authenticated_user
+ # Scolog.authenticated_user
+ from app.models import (
+ BulAppreciations,
+ EtudAnnotation,
+ ScolarNews,
+ Scolog,
+ )
+ from app.entreprises.models import EntrepriseHistorique
+
+ try:
+ # Update all instances of EtudAnnotation
+ db.session.query(BulAppreciations).filter(
+ BulAppreciations.author == self.user_name
+ ).update({BulAppreciations.author: new_user_name})
+ db.session.query(EntrepriseHistorique).filter(
+ EntrepriseHistorique.authenticated_user == self.user_name
+ ).update({EntrepriseHistorique.authenticated_user: new_user_name})
+ db.session.query(EtudAnnotation).filter(
+ EtudAnnotation.author == self.user_name
+ ).update({EtudAnnotation.author: new_user_name})
+ db.session.query(ScolarNews).filter(
+ ScolarNews.authenticated_user == self.user_name
+ ).update({ScolarNews.authenticated_user: new_user_name})
+ db.session.query(Scolog).filter(
+ Scolog.authenticated_user == self.user_name
+ ).update({Scolog.authenticated_user: new_user_name})
+ # And update ourself:
+ self.user_name = new_user_name
+ db.session.add(self)
+ db.session.commit()
+ except (
+ IntegrityError,
+ DataError,
+ DatabaseError,
+ OperationalError,
+ ProgrammingError,
+ StatementError,
+ InterfaceError,
+ ) as exc:
+ db.session.rollback()
+ raise exc
+
class AnonymousUser(AnonymousUserMixin):
"Notre utilisateur anonyme"
diff --git a/app/auth/routes.py b/app/auth/routes.py
index c9f5f7a0a..778cf8e5e 100644
--- a/app/auth/routes.py
+++ b/app/auth/routes.py
@@ -18,7 +18,7 @@ from app.auth.forms import (
ResetPasswordRequestForm,
UserCreationForm,
)
-from app.auth.models import Role, User, invalid_user_name
+from app.auth.models import Role, User, is_valid_user_name
from app.auth.email import send_password_reset_email
from app.decorators import admin_required
from app.forms.generic import SimpleConfirmationForm
@@ -35,10 +35,12 @@ def _login_form():
form = LoginForm()
if form.validate_on_submit():
# note: ceci est la première requête SQL déclenchée par un utilisateur arrivant
- if invalid_user_name(form.user_name.data):
- user = None
- else:
- user = User.query.filter_by(user_name=form.user_name.data).first()
+ user = (
+ User.query.filter_by(user_name=form.user_name.data).first()
+ if is_valid_user_name(form.user_name.data)
+ else None
+ )
+
if user is None or not user.check_password(form.password.data):
current_app.logger.info("login: invalid (%s)", form.user_name.data)
flash(_("Nom ou mot de passe invalide"))
diff --git a/app/but/bulletin_but_court.py b/app/but/bulletin_but_court.py
index a4fc6ec13..41f989a11 100644
--- a/app/but/bulletin_but_court.py
+++ b/app/but/bulletin_but_court.py
@@ -124,7 +124,9 @@ def _build_bulletin_but_infos(
formsemestre, bulletins_sem.res
)
if warn_html:
- raise ScoValueError("Formation mal configurée pour le BUT" + warn_html)
+ raise ScoValueError(
+ "Formation mal configurée pour le BUT" + warn_html, safe=True
+ )
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)
diff --git a/app/but/bulletin_but_pdf.py b/app/but/bulletin_but_pdf.py
index f849cebc7..e76aa62a5 100644
--- a/app/but/bulletin_but_pdf.py
+++ b/app/but/bulletin_but_pdf.py
@@ -73,6 +73,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
html_class="notes_bulletin",
html_class_ignore_default=True,
html_with_td_classes=True,
+ table_id="bul-table",
)
table_objects = table.gen(fmt=fmt)
objects += table_objects
diff --git a/app/but/cursus_but.py b/app/but/cursus_but.py
index 4d7f70d48..455355450 100644
--- a/app/but/cursus_but.py
+++ b/app/but/cursus_but.py
@@ -29,7 +29,7 @@ from app.models.but_refcomp import (
ApcReferentielCompetences,
)
from app.models.ues import UEParcours
-from app.models.but_validations import ApcValidationRCUE
+from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
@@ -42,9 +42,9 @@ from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
- """Pour compat ScoDoc 7: à revoir pour le BUT"""
+ """Pour compat ScoDoc 7"""
- def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT):
+ def __init__(self, etud: Identite, formsemestre_id: int, res: ResultatsSemestreBUT):
super().__init__(etud, formsemestre_id, res)
# Ajustements pour le BUT
self.can_compensate_with_prev = False # jamais de compensation à la mode DUT
@@ -54,8 +54,22 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
return False
def parcours_validated(self):
- "True si le parcours est validé"
- return False # XXX TODO
+ "True si le parcours (ici diplôme BUT) est validé"
+ return but_parcours_validated(
+ self.etud.id, self.cur_sem.formation.referentiel_competence_id
+ )
+
+
+def but_parcours_validated(etudid: int, referentiel_competence_id: int) -> bool:
+ """Détermine si le parcours BUT est validé:
+ ne regarde que si une validation BUT3 est enregistrée
+ """
+ return any(
+ sco_codes.code_annee_validant(v.code)
+ for v in ApcValidationAnnee.query.filter_by(
+ etudid=etudid, ordre=3, referentiel_competence_id=referentiel_competence_id
+ )
+ )
class EtudCursusBUT:
@@ -287,81 +301,81 @@ class FormSemestreCursusBUT:
)
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_ORDER[validation_rcue.code]
- > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
- ):
- self.validation_par_competence_et_annee[niveau.competence.id][
- niveau.annee
- ] = validation_rcue
- return validation_par_competence_et_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_ORDER[validation_rcue.code]
+ # > sco_codes.BUT_CODES_ORDER[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] if self.parcour else None # XXX WIP
- )[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]}
- )
+ # 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] if self.parcour else None # XXX WIP
+ # )[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_ORDER[validation_rcue.code]
- > sco_codes.BUT_CODES_ORDER[previous_validation["code"]]
- ):
- self.validation_par_competence_et_annee[niveau.competence.id][
- niveau.annee
- ] = validation_rcue
+ # 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_ORDER[validation_rcue.code]
+ # > sco_codes.BUT_CODES_ORDER[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 }"
+ # 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 but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float:
diff --git a/app/but/jury_but.py b/app/but/jury_but.py
index 5bd71c7ec..8c19239f9 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -1034,8 +1034,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
return messages
def valide_diplome(self) -> bool:
- "Vrai si l'étudiant à validé son diplôme"
- return False # TODO XXX
+ "Vrai si l'étudiant a validé son diplôme (décision enregistrée)"
+ return self.annee_but == 3 and sco_codes.code_annee_validant(self.code_valide)
def list_ue_parcour_etud(
diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py
index 56e45d412..c20000fb4 100644
--- a/app/but/jury_but_pv.py
+++ b/app/but/jury_but_pv.py
@@ -155,6 +155,7 @@ def pvjury_table_but(
deca = None
ects_but_valides = but_ects_valides(etud, referentiel_competence_id)
+ has_diplome = deca.valide_diplome()
row = {
"nom_pv": (
etud.code_ine or etud.code_nip or etud.id
@@ -181,10 +182,15 @@ def pvjury_table_but(
),
"decision_but": deca.code_valide if deca else "",
"devenir": (
- ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
- if deca
- else ""
+ "Diplôme obtenu"
+ if has_diplome
+ else (
+ ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
+ if deca
+ else ""
+ )
),
+ "diplome": "ADM" if has_diplome else "",
# pour exports excel seulement:
"civilite": etud.civilite_etat_civil_str,
"nom": etud.nom,
diff --git a/app/comp/df_cache.py b/app/comp/df_cache.py
index fb4c6cade..59dded343 100644
--- a/app/comp/df_cache.py
+++ b/app/comp/df_cache.py
@@ -27,6 +27,7 @@
"""caches pour tables APC
"""
+from flask import g
from app.scodoc import sco_cache
@@ -47,3 +48,27 @@ class EvaluationsPoidsCache(sco_cache.ScoDocCache):
"""
prefix = "EPC"
+
+ @classmethod
+ def invalidate_all(cls):
+ "delete all cached evaluations poids (in current dept)"
+ from app.models.formsemestre import FormSemestre
+ from app.models.moduleimpls import ModuleImpl
+
+ moduleimpl_ids = [
+ mi.id
+ for mi in ModuleImpl.query.join(FormSemestre).filter_by(
+ dept_id=g.scodoc_dept_id
+ )
+ ]
+ cls.delete_many(moduleimpl_ids)
+
+ @classmethod
+ def invalidate_sem(cls, formsemestre_id):
+ "delete cached evaluations poids for this formsemestre from cache"
+ from app.models.moduleimpls import ModuleImpl
+
+ moduleimpl_ids = [
+ mi.id for mi in ModuleImpl.query.filter_by(formsemestre_id=formsemestre_id)
+ ]
+ cls.delete_many(moduleimpl_ids)
diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py
index 9418f96d5..aa315ffd3 100644
--- a/app/comp/moy_mod.py
+++ b/app/comp/moy_mod.py
@@ -45,7 +45,6 @@ from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
-from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType
@@ -113,6 +112,8 @@ class ModuleImplResults:
"""
self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
+ self.evals_type = {}
+ """Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
self.load_notes(etudids, etudids_actifs)
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
@@ -164,7 +165,10 @@ class ModuleImplResults:
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty
+ self.evals_type = {}
+ evaluation: Evaluation
for evaluation in moduleimpl.evaluations:
+ self.evals_type[evaluation.id] = evaluation.evaluation_type
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi
# tous les inscrits (non dem) au module ont une note
@@ -270,6 +274,24 @@ class ModuleImplResults:
* self.evaluations_completes
).reshape(-1, 1)
+ def get_evaluations_special_coefs(
+ self, modimpl: ModuleImpl, evaluation_type=Evaluation.EVALUATION_SESSION2
+ ) -> np.array:
+ """Coefficients des évaluations de session 2 ou rattrapage.
+ Les évals de session 2 et rattrapage sont réputées "complètes": elles sont toujours
+ prises en compte mais seules les notes numériques et ABS sont utilisées.
+ Résultat: 2d-array of floats, shape (nb_evals, 1)
+ """
+ return (
+ np.array(
+ [
+ (e.coefficient if e.evaluation_type == evaluation_type else 0.0)
+ for e in modimpl.evaluations
+ ],
+ dtype=float,
+ )
+ ).reshape(-1, 1)
+
# was _list_notes_evals_titles
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes"
@@ -296,32 +318,26 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items()
}
- def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None:
- """L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
+ def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
+ """Les évaluations de rattrapage de ce module.
Rattrapage: la moyenne du module est la meilleure note entre moyenne
- des autres évals et la note eval rattrapage.
+ des autres évals et la moyenne des notes de rattrapage.
"""
- eval_list = [
+ return [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
]
- if eval_list:
- return eval_list[0]
- return None
- def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None:
- """L'évaluation de deuxième session de ce module, ou None s'il n'en a pas.
- Session 2: remplace la note de moyenne des autres évals.
+ def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
+ """Les évaluations de deuxième session de ce module, ou None s'il n'en a pas.
+ La moyenne des notes de Session 2 remplace la note de moyenne des autres évals.
"""
- eval_list = [
+ return [
e
for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
]
- if eval_list:
- return eval_list[0]
- return None
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
@@ -344,12 +360,13 @@ class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT"
def compute_module_moy(
- self,
- evals_poids_df: pd.DataFrame,
+ self, evals_poids_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> pd.DataFrame:
"""Calcule les moyennes des étudiants dans ce module
- Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
+ Argument:
+ evals_poids: DataFrame, colonnes: UEs, lignes: EVALs
+ modimpl_coefs_df: DataFrame, colonnes: modimpl_id, lignes: ue_id
Résultat: DataFrame, colonnes UE, lignes etud
= la note de l'étudiant dans chaque UE pour ce module.
@@ -370,6 +387,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0:
return pd.DataFrame(index=self.evals_notes.index, columns=[])
+ # coefs des évals complètes normales (pas rattr., session 2 ni bonus):
evals_coefs = self.get_evaluations_coefs(modimpl)
evals_poids = evals_poids_df.values * evals_coefs
# -> evals_poids shape : (nb_evals, nb_ues)
@@ -398,6 +416,47 @@ class ModuleImplResultsAPC(ModuleImplResults):
) / np.sum(evals_poids_etuds, axis=1)
# etuds_moy_module shape: nb_etuds x nb_ues
+ evals_session2 = self.get_evaluations_session2(modimpl)
+ evals_rat = self.get_evaluations_rattrapage(modimpl)
+ if evals_session2:
+ # Session2 : quand elle existe, remplace la note de module
+ # Calcul moyenne notes session2 et remplace (si la note session 2 existe)
+ etuds_moy_module_s2 = self._compute_moy_special(
+ modimpl,
+ evals_notes_stacked,
+ evals_poids_df,
+ Evaluation.EVALUATION_SESSION2,
+ )
+
+ # Vrai si toutes les UEs avec coef non nul ont bien une note de session 2 calculée:
+ mod_coefs = modimpl_coefs_df[modimpl.id]
+ etuds_use_session2 = np.all(
+ np.isfinite(etuds_moy_module_s2[:, mod_coefs != 0]), axis=1
+ )
+ etuds_moy_module = np.where(
+ etuds_use_session2[:, np.newaxis],
+ etuds_moy_module_s2,
+ etuds_moy_module,
+ )
+ self.etuds_use_session2 = pd.Series(
+ etuds_use_session2, index=self.evals_notes.index
+ )
+ elif evals_rat:
+ etuds_moy_module_rat = self._compute_moy_special(
+ modimpl,
+ evals_notes_stacked,
+ evals_poids_df,
+ Evaluation.EVALUATION_RATTRAPAGE,
+ )
+ etuds_ue_use_rattrapage = (
+ etuds_moy_module_rat > etuds_moy_module
+ ) # etud x UE
+ etuds_moy_module = np.where(
+ etuds_ue_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
+ )
+ self.etuds_use_rattrapage = pd.Series(
+ np.any(etuds_ue_use_rattrapage, axis=1), index=self.evals_notes.index
+ )
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
@@ -405,47 +464,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
evals_poids_df,
evals_notes_stacked,
)
-
- # Session2 : quand elle existe, remplace la note de module
- eval_session2 = self.get_evaluation_session2(modimpl)
- if eval_session2:
- notes_session2 = self.evals_notes[eval_session2.id].values
- # n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
- etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
- etuds_moy_module = np.where(
- etuds_use_session2[:, np.newaxis],
- np.tile(
- (notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis],
- nb_ues,
- ),
- etuds_moy_module,
- )
- self.etuds_use_session2 = pd.Series(
- etuds_use_session2, index=self.evals_notes.index
- )
- else:
- # Rattrapage: remplace la note de module ssi elle est supérieure
- eval_rat = self.get_evaluation_rattrapage(modimpl)
- if eval_rat:
- notes_rat = self.evals_notes[eval_rat.id].values
- # remplace les notes invalides (ATT, EXC...) par des NaN
- notes_rat = np.where(
- notes_rat > scu.NOTES_ABSENCE,
- notes_rat / (eval_rat.note_max / 20.0),
- np.nan,
- )
- # "Étend" le rattrapage sur les UE: la note de rattrapage est la même
- # pour toutes les UE mais ne remplace que là où elle est supérieure
- notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
- # prend le max
- etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
- etuds_moy_module = np.where(
- etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
- )
- # Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
- self.etuds_use_rattrapage = pd.Series(
- etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
- )
self.etuds_moy_module = pd.DataFrame(
etuds_moy_module,
index=self.evals_notes.index,
@@ -453,6 +471,34 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
return self.etuds_moy_module
+ def _compute_moy_special(
+ self,
+ modimpl: ModuleImpl,
+ evals_notes_stacked: np.array,
+ evals_poids_df: pd.DataFrame,
+ evaluation_type: int,
+ ) -> np.array:
+ """Calcul moyenne APC sur évals rattrapage ou session2"""
+ nb_etuds = self.evals_notes.shape[0]
+ nb_ues = evals_poids_df.shape[1]
+ evals_coefs_s2 = self.get_evaluations_special_coefs(
+ modimpl, evaluation_type=evaluation_type
+ )
+ evals_poids_s2 = evals_poids_df.values * evals_coefs_s2
+ poids_stacked_s2 = np.stack(
+ [evals_poids_s2] * nb_etuds
+ ) # nb_etuds, nb_evals, nb_ues
+ evals_poids_etuds_s2 = np.where(
+ np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
+ poids_stacked_s2,
+ 0,
+ )
+ with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
+ etuds_moy_module_s2 = np.sum(
+ evals_poids_etuds_s2 * evals_notes_stacked, axis=1
+ ) / np.sum(evals_poids_etuds_s2, axis=1)
+ return etuds_moy_module_s2
+
def apply_bonus(
self,
etuds_moy_module: pd.DataFrame,
@@ -525,6 +571,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
return evals_poids, ues
+# appelé par ModuleImpl.check_apc_conformity()
def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool:
@@ -546,12 +593,12 @@ def moduleimpl_is_conforme(
if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre()
- raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
+ return app.critical_error("moduleimpl_is_conforme: err 1")
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
- raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
+ return app.critical_error("moduleimpl_is_conforme: err 2")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
@@ -593,46 +640,43 @@ class ModuleImplResultsClassic(ModuleImplResults):
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
+ evals_session2 = self.get_evaluations_session2(modimpl)
+ evals_rat = self.get_evaluations_rattrapage(modimpl)
+ if evals_session2:
+ # Session2 : quand elle existe, remplace la note de module
+ # Calcule la moyenne des évaluations de session2
+ etuds_moy_module_s2 = self._compute_moy_special(
+ modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2
+ )
+ etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
+ etuds_moy_module = np.where(
+ etuds_use_session2,
+ etuds_moy_module_s2,
+ etuds_moy_module,
+ )
+ self.etuds_use_session2 = pd.Series(
+ etuds_use_session2, index=self.evals_notes.index
+ )
+ elif evals_rat:
+ # Rattrapage: remplace la note de module ssi elle est supérieure
+ # Calcule la moyenne des évaluations de rattrapage
+ etuds_moy_module_rat = self._compute_moy_special(
+ modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE
+ )
+ etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module
+ etuds_moy_module = np.where(
+ etuds_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
+ )
+ self.etuds_use_rattrapage = pd.Series(
+ etuds_use_rattrapage, index=self.evals_notes.index
+ )
+
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
-
- # Session2 : quand elle existe, remplace la note de module
- eval_session2 = self.get_evaluation_session2(modimpl)
- if eval_session2:
- notes_session2 = self.evals_notes[eval_session2.id].values
- # n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
- etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
- etuds_moy_module = np.where(
- etuds_use_session2,
- notes_session2 / (eval_session2.note_max / 20.0),
- etuds_moy_module,
- )
- self.etuds_use_session2 = pd.Series(
- etuds_use_session2, index=self.evals_notes.index
- )
- else:
- # Rattrapage: remplace la note de module ssi elle est supérieure
- eval_rat = self.get_evaluation_rattrapage(modimpl)
- if eval_rat:
- notes_rat = self.evals_notes[eval_rat.id].values
- # remplace les notes invalides (ATT, EXC...) par des NaN
- notes_rat = np.where(
- notes_rat > scu.NOTES_ABSENCE,
- notes_rat / (eval_rat.note_max / 20.0),
- np.nan,
- )
- # prend le max
- etuds_use_rattrapage = notes_rat > etuds_moy_module
- etuds_moy_module = np.where(
- etuds_use_rattrapage, notes_rat, etuds_moy_module
- )
- self.etuds_use_rattrapage = pd.Series(
- etuds_use_rattrapage, index=self.evals_notes.index
- )
self.etuds_moy_module = pd.Series(
etuds_moy_module,
index=self.evals_notes.index,
@@ -640,6 +684,28 @@ class ModuleImplResultsClassic(ModuleImplResults):
return self.etuds_moy_module
+ def _compute_moy_special(
+ self, modimpl: ModuleImpl, evals_notes_20: np.array, evaluation_type: int
+ ) -> np.array:
+ """Calcul moyenne sur évals rattrapage ou session2"""
+ # n'utilise que les notes valides et ABS (0).
+ # Même calcul que pour les évals normales, mais avec seulement les
+ # coefs des évals de session 2 ou rattrapage:
+ nb_etuds = self.evals_notes.shape[0]
+ evals_coefs = self.get_evaluations_special_coefs(
+ modimpl, evaluation_type=evaluation_type
+ ).reshape(-1)
+ coefs_stacked = np.stack([evals_coefs] * nb_etuds)
+ # zéro partout sauf si une note ou ABS:
+ evals_coefs_etuds = np.where(
+ self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0
+ )
+ with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
+ etuds_moy_module = np.sum(
+ evals_coefs_etuds * evals_notes_20, axis=1
+ ) / np.sum(evals_coefs_etuds, axis=1)
+ return etuds_moy_module # array 1d (nb_etuds)
+
def apply_bonus(
self,
etuds_moy_module: np.ndarray,
diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py
index 7e9e83cbb..24fdbd468 100644
--- a/app/comp/moy_ue.py
+++ b/app/comp/moy_ue.py
@@ -183,7 +183,9 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray:
return modimpls_notes.swapaxes(0, 1)
-def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
+def notes_sem_load_cube(
+ formsemestre: FormSemestre, modimpl_coefs_df: pd.DataFrame
+) -> tuple:
"""Construit le "cube" (tenseur) des notes du semestre.
Charge toutes les notes (sql), calcule les moyennes des modules
et assemble le cube.
@@ -207,8 +209,8 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
- evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
- etuds_moy_module = mod_results.compute_module_moy(evals_poids)
+ evals_poids = modimpl.get_evaluations_poids()
+ etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df)
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module)
diff --git a/app/comp/res_but.py b/app/comp/res_but.py
index c6d99fb95..a92c99506 100644
--- a/app/comp/res_but.py
+++ b/app/comp/res_but.py
@@ -59,16 +59,17 @@ class ResultatsSemestreBUT(NotesTableCompat):
def compute(self):
"Charge les notes et inscriptions et calcule les moyennes d'UE et gen."
+ self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
+ self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
+ )
(
self.sem_cube,
self.modimpls_evals_poids,
self.modimpls_results,
- ) = moy_ue.notes_sem_load_cube(self.formsemestre)
+ ) = moy_ue.notes_sem_load_cube(self.formsemestre, self.modimpl_coefs_df)
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
- self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
- self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
- )
+
# l'idx de la colonne du mod modimpl.id est
# modimpl_coefs_df.columns.get_loc(modimpl.id)
# idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id)
diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py
index 113faa0b2..59f3790e4 100644
--- a/app/comp/res_classic.py
+++ b/app/comp/res_classic.py
@@ -242,7 +242,8 @@ class ResultatsSemestreClassic(NotesTableCompat):
)
}">saisir le coefficient de cette UE avant de continuer
- """
+ """,
+ safe=True,
)
diff --git a/app/comp/res_common.py b/app/comp/res_common.py
index 9915c6162..267f5e7fa 100644
--- a/app/comp/res_common.py
+++ b/app/comp/res_common.py
@@ -518,7 +518,8 @@ class ResultatsSemestre(ResultatsCache):
Corrigez ou faite corriger le programme
via cette page.
- """
+ """,
+ safe=True,
)
else:
# Coefs de l'UE capitalisée en formation classique:
diff --git a/app/email.py b/app/email.py
index 5efe838fe..d4e87fad3 100644
--- a/app/email.py
+++ b/app/email.py
@@ -9,9 +9,9 @@ import datetime
from threading import Thread
from flask import current_app, g
-from flask_mail import Message
+from flask_mail import BadHeaderError, Message
-from app import mail
+from app import log, mail
from app.models.departements import Departement
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences
@@ -20,7 +20,15 @@ from app.scodoc import sco_preferences
def send_async_email(app, msg):
"Send an email, async"
with app.app_context():
- mail.send(msg)
+ try:
+ mail.send(msg)
+ except BadHeaderError:
+ log(
+ f"""send_async_email: BadHeaderError
+ msg={msg}
+ """
+ )
+ raise
def send_email(
diff --git a/app/entreprises/models.py b/app/entreprises/models.py
index 2dc825b82..b41d6b5ce 100644
--- a/app/entreprises/models.py
+++ b/app/entreprises/models.py
@@ -151,7 +151,7 @@ class EntrepriseHistorique(db.Model):
__tablename__ = "are_historique"
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
- authenticated_user = db.Column(db.Text)
+ authenticated_user = db.Column(db.Text) # user_name login sans contrainte
entreprise_id = db.Column(db.Integer)
object = db.Column(db.Text)
object_id = db.Column(db.Integer)
diff --git a/app/entreprises/routes.py b/app/entreprises/routes.py
index 529706e99..537e86b96 100644
--- a/app/entreprises/routes.py
+++ b/app/entreprises/routes.py
@@ -338,9 +338,11 @@ def add_entreprise():
if form.validate_on_submit():
entreprise = Entreprise(
nom=form.nom_entreprise.data.strip(),
- siret=form.siret.data.strip()
- if form.siret.data.strip()
- else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}", # siret provisoire
+ siret=(
+ form.siret.data.strip()
+ if form.siret.data.strip()
+ else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}"
+ ), # siret provisoire
siret_provisoire=False if form.siret.data.strip() else True,
association=form.association.data,
adresse=form.adresse.data.strip(),
@@ -352,7 +354,7 @@ def add_entreprise():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
- except:
+ except Exception:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
@@ -804,9 +806,9 @@ def add_offre(entreprise_id):
missions=form.missions.data.strip(),
duree=form.duree.data.strip(),
expiration_date=form.expiration_date.data,
- correspondant_id=form.correspondant.data
- if form.correspondant.data != ""
- else None,
+ correspondant_id=(
+ form.correspondant.data if form.correspondant.data != "" else None
+ ),
)
db.session.add(offre)
db.session.commit()
@@ -1328,9 +1330,11 @@ def add_contact(entreprise_id):
).first_or_404(description=f"entreprise {entreprise_id} inconnue")
form = ContactCreationForm(
date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}",
- utilisateur=f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
- if current_user.nom and current_user.prenom
- else "",
+ utilisateur=(
+ f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
+ if current_user.nom and current_user.prenom
+ else ""
+ ),
)
if request.method == "POST" and form.cancel.data:
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise_id))
@@ -1496,9 +1500,9 @@ def add_stage_apprentissage(entreprise_id):
date_debut=form.date_debut.data,
date_fin=form.date_fin.data,
formation_text=formation.formsemestre.titre if formation else None,
- formation_scodoc=formation.formsemestre.formsemestre_id
- if formation
- else None,
+ formation_scodoc=(
+ formation.formsemestre.formsemestre_id if formation else None
+ ),
notes=form.notes.data.strip(),
)
db.session.add(stage_apprentissage)
@@ -1802,7 +1806,7 @@ def import_donnees():
db.session.add(entreprise)
db.session.commit()
db.session.refresh(entreprise)
- except:
+ except Exception:
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
diff --git a/app/forms/assiduite/ajout_assiduite_etud.py b/app/forms/assiduite/ajout_assiduite_etud.py
index 8c3423acc..302f73776 100644
--- a/app/forms/assiduite/ajout_assiduite_etud.py
+++ b/app/forms/assiduite/ajout_assiduite_etud.py
@@ -62,6 +62,11 @@ class AjoutAssiOrJustForm(FlaskForm):
if field:
field.errors.append(err_msg)
+ def disable_all(self):
+ "Disable all fields"
+ for field in self:
+ field.render_kw = {"disabled": True}
+
date_debut = StringField(
"Date de début",
validators=[validators.Length(max=10)],
@@ -175,36 +180,3 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")
-
-
-class ChoixDateForm(FlaskForm):
- """
- Formulaire de choix de date
- (utilisé par la page de choix de date
- si la date courante n'est pas dans le semestre)
- """
-
- def __init__(self, *args, **kwargs):
- "Init form, adding a filed for our error messages"
- super().__init__(*args, **kwargs)
- self.ok = True
- self.error_messages: list[str] = [] # used to report our errors
-
- def set_error(self, err_msg, field=None):
- "Set error message both in form and field"
- self.ok = False
- self.error_messages.append(err_msg)
- if field:
- field.errors.append(err_msg)
-
- date = StringField(
- "Date",
- validators=[validators.Length(max=10)],
- render_kw={
- "class": "datepicker",
- "size": 10,
- "id": "date",
- },
- )
- submit = SubmitField("Enregistrer")
- cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
diff --git a/app/forms/assiduite/edit_assiduite_etud.py b/app/forms/assiduite/edit_assiduite_etud.py
new file mode 100644
index 000000000..cdcff62e0
--- /dev/null
+++ b/app/forms/assiduite/edit_assiduite_etud.py
@@ -0,0 +1,122 @@
+""" """
+
+from flask_wtf import FlaskForm
+from wtforms import (
+ StringField,
+ SelectField,
+ RadioField,
+ TextAreaField,
+ validators,
+ SubmitField,
+)
+from app.scodoc.sco_utils import EtatAssiduite
+
+
+class EditAssiForm(FlaskForm):
+ """
+ Formulaire de modification d'une assiduité
+ """
+
+ def __init__(self, *args, **kwargs):
+ "Init form, adding a filed for our error messages"
+ super().__init__(*args, **kwargs)
+ self.ok = True
+ self.error_messages: list[str] = [] # used to report our errors
+
+ def set_error(self, err_msg, field=None):
+ "Set error message both in form and field"
+ self.ok = False
+ self.error_messages.append(err_msg)
+ if field:
+ field.errors.append(err_msg)
+
+ def disable_all(self):
+ "Disable all fields"
+ for field in self:
+ field.render_kw = {"disabled": True}
+
+ assi_etat = RadioField(
+ "État:",
+ choices=[
+ (EtatAssiduite.ABSENT.value, EtatAssiduite.ABSENT.version_lisible()),
+ (EtatAssiduite.RETARD.value, EtatAssiduite.RETARD.version_lisible()),
+ (EtatAssiduite.PRESENT.value, EtatAssiduite.PRESENT.version_lisible()),
+ ],
+ default="absent",
+ validators=[
+ validators.DataRequired("spécifiez le type d'évènement à signaler"),
+ ],
+ )
+ modimpl = SelectField(
+ "Module",
+ choices={}, # will be populated dynamically
+ )
+ description = TextAreaField(
+ "Description",
+ render_kw={
+ "id": "description",
+ "cols": 75,
+ "rows": 4,
+ "maxlength": 500,
+ },
+ )
+ date_debut = StringField(
+ "Date de début",
+ validators=[validators.Length(max=10)],
+ render_kw={
+ "class": "datepicker",
+ "size": 10,
+ "id": "assi_date_debut",
+ },
+ )
+ heure_debut = StringField(
+ "Heure début",
+ default="",
+ validators=[validators.Length(max=5)],
+ render_kw={
+ "class": "timepicker",
+ "size": 5,
+ "id": "assi_heure_debut",
+ },
+ )
+ heure_fin = StringField(
+ "Heure fin",
+ default="",
+ validators=[validators.Length(max=5)],
+ render_kw={
+ "class": "timepicker",
+ "size": 5,
+ "id": "assi_heure_fin",
+ },
+ )
+ date_fin = StringField(
+ "Date de fin",
+ validators=[validators.Length(max=10)],
+ render_kw={
+ "class": "datepicker",
+ "size": 10,
+ "id": "assi_date_fin",
+ },
+ )
+ entry_date = StringField(
+ "Date de dépôt ou saisie",
+ validators=[validators.Length(max=10)],
+ render_kw={
+ "class": "datepicker",
+ "size": 10,
+ "id": "entry_date",
+ },
+ )
+ entry_time = StringField(
+ "Heure dépôt",
+ default="",
+ validators=[validators.Length(max=5)],
+ render_kw={
+ "class": "timepicker",
+ "size": 5,
+ "id": "assi_heure_fin",
+ },
+ )
+
+ submit = SubmitField("Enregistrer")
+ cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
diff --git a/app/forms/main/create_bug_report.py b/app/forms/main/create_bug_report.py
new file mode 100644
index 000000000..e94920f85
--- /dev/null
+++ b/app/forms/main/create_bug_report.py
@@ -0,0 +1,66 @@
+# -*- mode: python -*-
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# ScoDoc
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
+#
+# Emmanuel Viennet emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+"""
+Formulaire création de ticket de bug
+"""
+
+from flask_wtf import FlaskForm
+from wtforms import SubmitField, validators
+from wtforms.fields.simple import StringField, TextAreaField, BooleanField
+from app.scodoc import sco_preferences
+
+
+class CreateBugReport(FlaskForm):
+ """Formulaire permettant la création d'un ticket de bug"""
+
+ title = StringField(
+ label="Titre du ticket",
+ validators=[
+ validators.DataRequired("titre du ticket requis"),
+ ],
+ )
+ message = TextAreaField(
+ label="Message",
+ id="ticket_message",
+ validators=[
+ validators.DataRequired("message du ticket requis"),
+ ],
+ )
+ etab = StringField(label="Etablissement")
+ include_dump = BooleanField(
+ """Inclure une copie anonymisée de la base de données ?
+ Ces données faciliteront le traitement du problème et resteront strictement confidentielles.
+ """,
+ default=False,
+ )
+ submit = SubmitField("Envoyer")
+ cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
+
+ def __init__(self, *args, **kwargs):
+ super(CreateBugReport, self).__init__(*args, **kwargs)
+ self.etab.data = sco_preferences.get_preference("InstituteName") or ""
diff --git a/app/models/assiduites.py b/app/models/assiduites.py
index f645f5c5a..7c5c1ce26 100644
--- a/app/models/assiduites.py
+++ b/app/models/assiduites.py
@@ -353,12 +353,22 @@ class Assiduite(ScoDocModel):
elif self.external_data is not None and "module" in self.external_data:
return (
- "Tout module"
+ "Autre module (pas dans la liste)"
if self.external_data["module"] == "Autre"
else self.external_data["module"]
)
- return "Non spécifié" if traduire else None
+ return "Module non spécifié" if traduire else None
+
+ def get_moduleimpl_id(self) -> int | str | None:
+ """
+ Retourne le ModuleImpl associé à l'assiduité
+ """
+ if self.moduleimpl_id is not None:
+ return self.moduleimpl_id
+ if self.external_data is not None and "module" in self.external_data:
+ return self.external_data["module"]
+ return None
def get_saisie(self) -> str:
"""
@@ -395,6 +405,14 @@ class Assiduite(ScoDocModel):
if force:
raise ScoValueError("Module non renseigné")
+ @classmethod
+ def get_assiduite(cls, assiduite_id: int) -> "Assiduite":
+ """Assiduité ou 404, cherche uniquement dans le département courant"""
+ query = Assiduite.query.filter_by(id=assiduite_id)
+ if g.scodoc_dept:
+ query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
+ return query.first_or_404()
+
class Justificatif(ScoDocModel):
"""
@@ -685,10 +703,14 @@ def is_period_conflicting(
date_fin: datetime,
collection: Query,
collection_cls: Assiduite | Justificatif,
+ obj_id: int = -1,
) -> bool:
"""
Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes
+
+ On peut donner un objet_id pour exclure un objet de la vérification
+ (utile pour les modifications)
"""
# On s'assure que les dates soient avec TimeZone
@@ -696,7 +718,9 @@ def is_period_conflicting(
date_fin = localize_datetime(date_fin)
count: int = collection.filter(
- collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
+ collection_cls.date_debut < date_fin,
+ collection_cls.date_fin > date_debut,
+ collection_cls.id != obj_id,
).count()
return count > 0
diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py
index 22d40785a..6831f0925 100644
--- a/app/models/but_refcomp.py
+++ b/app/models/but_refcomp.py
@@ -274,6 +274,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return "type_departement mismatch"
# Table d'équivalences entre refs:
equiv = self._load_config_equivalences()
+ # Même specialité (ou alias) ?
+ if self.specialite != other.specialite and other.specialite not in equiv.get(
+ "alias", []
+ ):
+ return "specialite mismatch"
# mêmes parcours ?
eq_parcours = equiv.get("parcours", {})
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
@@ -317,6 +322,9 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def _load_config_equivalences(self) -> dict:
"""Load config file ressources/referentiels/equivalences.yaml
used to define equivalences between distinct referentiels
+ return a dict, with optional keys:
+ alias: list of equivalent names for speciality (eg SD == STID)
+ parcours: dict with equivalent parcours acronyms
"""
try:
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
diff --git a/app/models/but_validations.py b/app/models/but_validations.py
index 8c23694d3..8ec9a1107 100644
--- a/app/models/but_validations.py
+++ b/app/models/but_validations.py
@@ -113,6 +113,12 @@ class ApcValidationRCUE(db.Model):
"formsemestre_id": self.formsemestre_id,
}
+ def get_codes_apogee(self) -> set[str]:
+ """Les codes Apogée associés à cette validation RCUE.
+ Prend les codes des deux UEs
+ """
+ return self.ue1.get_codes_apogee_rcue() | self.ue2.get_codes_apogee_rcue()
+
class ApcValidationAnnee(db.Model):
"""Validation des années du BUT"""
@@ -213,6 +219,7 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
dec_rcue["code"]}"""
)
decisions["descr_decisions_rcue"] = ", ".join(titres_rcues)
+ decisions["descr_decisions_rcue_list"] = titres_rcues
decisions["descr_decisions_niveaux"] = (
"Niveaux de compétences: " + decisions["descr_decisions_rcue"]
)
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index 61fd3e238..976594300 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -199,6 +199,11 @@ class Identite(models.ScoDocModel):
@classmethod
def get_etud(cls, etudid: int) -> "Identite":
"""Etudiant ou 404, cherche uniquement dans le département courant"""
+ if not isinstance(etudid, int):
+ try:
+ etudid = int(etudid)
+ except (TypeError, ValueError):
+ abort(404, "etudid invalide")
if g.scodoc_dept:
return cls.query.filter_by(
id=etudid, dept_id=g.scodoc_dept_id
@@ -299,9 +304,10 @@ class Identite(models.ScoDocModel):
@property
def nomprenom(self, reverse=False) -> str:
- """Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
+ """DEPRECATED
+ Civilité/prénom/nom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
- Prend l'identité courant et non celle de l'état civile si elles diffèrent.
+ Prend l'identité courante et non celle de l'état civil si elles diffèrent.
"""
nom = self.nom_usuel or self.nom
prenom = self.prenom_str
@@ -309,6 +315,12 @@ class Identite(models.ScoDocModel):
return f"{nom} {prenom}".strip()
return f"{self.civilite_str} {prenom} {nom}".strip()
+ def nom_prenom(self) -> str:
+ """Civilite NOM Prénom
+ Prend l'identité courante et non celle de l'état civil si elles diffèrent.
+ """
+ return f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()} {self.prenom_str}"
+
@property
def prenom_str(self):
"""Prénom à afficher. Par exemple: "Jean-Christophe" """
@@ -347,14 +359,15 @@ class Identite(models.ScoDocModel):
"Le mail associé à la première adresse de l'étudiant, ou None"
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
- def get_formsemestres(self) -> list:
+ def get_formsemestres(self, recent_first=True) -> list:
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
- triée par date_debut
+ triée par date_debut, le plus récent d'abord (comme "sems" de scodoc7)
+ (si recent_first=False, le plus ancien en tête)
"""
return sorted(
[ins.formsemestre for ins in self.formsemestre_inscriptions],
key=attrgetter("date_debut"),
- reverse=True,
+ reverse=recent_first,
)
def get_modimpls_by_formsemestre(
@@ -393,6 +406,18 @@ class Identite(models.ScoDocModel):
modimpls_by_formsemestre[formsemestre.id] = modimpls_sem
return modimpls_by_formsemestre
+ def get_modimpls_from_formsemestre(
+ self, formsemestre: "FormSemestre"
+ ) -> list["ModuleImpl"]:
+ """
+ Liste des ModuleImpl auxquels l'étudiant est inscrit dans le formsemestre.
+ """
+ modimpls = ModuleImpl.query.join(ModuleImplInscription).filter(
+ ModuleImplInscription.etudid == self.id,
+ ModuleImpl.formsemestre_id == formsemestre.id,
+ )
+ return modimpls.all()
+
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
@@ -551,7 +576,7 @@ class Identite(models.ScoDocModel):
.all()
)
- def inscription_courante(self):
+ def inscription_courante(self) -> "FormSemestreInscription | None":
"""La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore).
"""
diff --git a/app/models/evaluations.py b/app/models/evaluations.py
index 124c3ac40..98017e0e2 100644
--- a/app/models/evaluations.py
+++ b/app/models/evaluations.py
@@ -71,6 +71,15 @@ class Evaluation(models.ScoDocModel):
EVALUATION_BONUS,
}
+ def type_abbrev(self) -> str:
+ "Le nom abrégé du type de cette éval."
+ return {
+ self.EVALUATION_NORMALE: "std",
+ self.EVALUATION_RATTRAPAGE: "rattrapage",
+ self.EVALUATION_SESSION2: "session 2",
+ self.EVALUATION_BONUS: "bonus",
+ }.get(self.evaluation_type, "?")
+
def __repr__(self):
return f""" None:
- """Set poids évaluation vers cette UE"""
+ """Set poids évaluation vers cette UE. Commit."""
self.update_ue_poids_dict({ue.id: poids})
def set_ue_poids_dict(self, ue_poids_dict: dict) -> None:
"""set poids vers les UE (remplace existants)
ue_poids_dict = { ue_id : poids }
+ Commit session.
"""
from app.models.ues import UniteEns
@@ -432,9 +442,12 @@ class Evaluation(models.ScoDocModel):
if ue is None:
raise ScoValueError("poids vers une UE inexistante")
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
- L.append(ue_poids)
db.session.add(ue_poids)
+ L.append(ue_poids)
+
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
+
+ db.session.commit()
self.moduleimpl.invalidate_evaluations_poids() # inval cache
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:
diff --git a/app/models/events.py b/app/models/events.py
index f8fd64ceb..659bf2600 100644
--- a/app/models/events.py
+++ b/app/models/events.py
@@ -27,7 +27,7 @@ class Scolog(db.Model):
method = db.Column(db.Text)
msg = db.Column(db.Text)
etudid = db.Column(db.Integer) # sans contrainte pour garder logs après suppression
- authenticated_user = db.Column(db.Text) # login, sans contrainte
+ authenticated_user = db.Column(db.Text) # user_name login, sans contrainte
# zope_remote_addr suppressed
@classmethod
@@ -76,7 +76,9 @@ class ScolarNews(db.Model):
date = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), index=True
)
- authenticated_user = db.Column(db.Text, index=True) # login, sans contrainte
+ authenticated_user = db.Column(
+ db.Text, index=True
+ ) # user_name login, sans contrainte
# type in 'INSCR', 'NOTES', 'FORM', 'SEM', 'MISC'
type = db.Column(db.String(SHORT_STR_LEN), index=True)
object = db.Column(
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index c3623bb3d..eb10f57b7 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -36,6 +36,7 @@ from app.models.config import ScoDocSiteConfig
from app.models.departements import Departement
from app.models.etudiants import Identite
from app.models.evaluations import Evaluation
+from app.models.events import ScolarNews
from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import (
@@ -207,6 +208,70 @@ class FormSemestre(models.ScoDocModel):
).first_or_404()
return cls.query.filter_by(id=formsemestre_id).first_or_404()
+ @classmethod
+ def create_formsemestre(cls, args: dict, silent=False) -> "FormSemestre":
+ """Création d'un formsemestre, avec toutes les valeurs par défaut
+ et notification (sauf si silent).
+ Crée la partition par défaut.
+ """
+ # was sco_formsemestre.do_formsemestre_create
+ if "dept_id" not in args:
+ args["dept_id"] = g.scodoc_dept_id
+ formsemestre: "FormSemestre" = cls.create_from_dict(args)
+ db.session.flush()
+ for etape in args["etapes"]:
+ formsemestre.add_etape(etape)
+ db.session.commit()
+ for u in args["responsables"]:
+ formsemestre.responsables.append(u)
+ # create default partition
+ partition = Partition(
+ formsemestre=formsemestre, partition_name=None, numero=1000000
+ )
+ db.session.add(partition)
+ partition.create_group(default=True)
+ db.session.commit()
+
+ if not silent:
+ url = url_for(
+ "notes.formsemestre_status",
+ scodoc_dept=formsemestre.departement.acronym,
+ formsemestre_id=formsemestre.id,
+ )
+ ScolarNews.add(
+ typ=ScolarNews.NEWS_SEM,
+ text=f"""Création du semestre {formsemestre.titre}""",
+ url=url,
+ max_frequency=0,
+ )
+
+ return formsemestre
+
+ @classmethod
+ def convert_dict_fields(cls, args: dict) -> dict:
+ """Convert fields in the given dict.
+ args: dict with args in application.
+ returns: dict to store in model's db.
+ """
+ if "date_debut" in args:
+ args["date_debut"] = scu.convert_fr_date(args["date_debut"])
+ if "date_fin" in args:
+ args["date_fin"] = scu.convert_fr_date(args["date_debut"])
+ if "etat" in args:
+ args["etat"] = bool(args["etat"])
+ if "bul_bgcolor" in args:
+ args["bul_bgcolor"] = args.get("bul_bgcolor") or "white"
+ if "titre" in args:
+ args["titre"] = args.get("titre") or "sans titre"
+ return args
+
+ @classmethod
+ def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
+ """Returns a copy of dict with only the keys belonging to the Model and not in excluded.
+ Add 'etapes' to excluded."""
+ # on ne peut pas affecter directement etapes
+ return super().filter_model_attributes(data, (excluded or set()) | {"etapes"})
+
def sort_key(self) -> tuple:
"""clé pour tris par ordre de date_debut, le plus ancien en tête
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
@@ -610,6 +675,41 @@ class FormSemestre(models.ScoDocModel):
)
)
+ @classmethod
+ def est_in_semestre_scolaire(
+ cls,
+ date_debut: datetime.date,
+ year=False,
+ periode=None,
+ mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
+ mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
+ ) -> bool:
+ """Vrai si la date_debut est dans la période indiquée (1,2,0)
+ du semestre `periode` de l'année scolaire indiquée
+ (ou, à défaut, de celle en cours).
+
+ La période utilise les même conventions que semset["sem_id"];
+ * 1 : première période
+ * 2 : deuxième période
+ * 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
+ )
+ """
+ if not year:
+ year = scu.annee_scolaire()
+ # n'utilise pas le jour pivot
+ jour_pivot_annee = jour_pivot_periode = 1
+ # calcule l'année universitaire et la période
+ sem_annee, sem_periode = cls.comp_periode(
+ date_debut,
+ mois_pivot_annee,
+ mois_pivot_periode,
+ jour_pivot_annee,
+ jour_pivot_periode,
+ )
+ if periode is None or periode == 0:
+ return sem_annee == year
+ return sem_annee == year and sem_periode == periode
+
def est_terminal(self) -> bool:
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
return (self.semestre_id < 0) or (
@@ -694,7 +794,7 @@ class FormSemestre(models.ScoDocModel):
FormSemestre.titre,
)
- def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
+ def etapes_apo_vdi(self) -> list["ApoEtapeVDI"]:
"Liste des vdis"
# was read_formsemestre_etapes
return [e.as_apovdi() for e in self.etapes if e.etape_apo]
@@ -707,9 +807,9 @@ class FormSemestre(models.ScoDocModel):
return ""
return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
- def add_etape(self, etape_apo: str):
+ def add_etape(self, etape_apo: str | ApoEtapeVDI):
"Ajoute une étape"
- etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
+ etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=str(etape_apo))
db.session.add(etape)
def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
@@ -938,7 +1038,7 @@ class FormSemestre(models.ScoDocModel):
def etudids_actifs(self) -> tuple[list[int], set[int]]:
"""Liste les etudids inscrits (incluant DEM et DEF),
- qui ser al'index des dataframes de notes
+ qui sera l'index des dataframes de notes
et donne l'ensemble des inscrits non DEM ni DEF.
"""
return [inscr.etudid for inscr in self.inscriptions], {
@@ -1225,10 +1325,18 @@ class FormSemestreEtape(db.Model):
"Etape False if code empty"
return self.etape_apo is not None and (len(self.etape_apo) > 0)
+ def __eq__(self, other):
+ if isinstance(other, ApoEtapeVDI):
+ return self.as_apovdi() == other
+ return str(self) == str(other)
+
def __repr__(self):
return f""
- def as_apovdi(self) -> ApoEtapeVDI:
+ def __str__(self):
+ return self.etape_apo or ""
+
+ def as_apovdi(self) -> "ApoEtapeVDI":
return ApoEtapeVDI(self.etape_apo)
@@ -1381,8 +1489,9 @@ class FormSemestreInscription(db.Model):
def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
- self.formsemestre_id} etat={self.etat} {
- ('parcours='+str(self.parcour)) if self.parcour else ''}>"""
+ self.formsemestre_id} (S{self.formsemestre.semestre_id}) etat={self.etat} {
+ ('parcours="'+str(self.parcour.code)+'"') if self.parcour else ''
+ } {('etape="'+self.etape+'"') if self.etape else ''}>"""
class NotesSemSet(db.Model):
diff --git a/app/models/groups.py b/app/models/groups.py
index 7250f1e67..68c7156b8 100644
--- a/app/models/groups.py
+++ b/app/models/groups.py
@@ -93,6 +93,10 @@ class Partition(ScoDocModel):
):
group.remove_etud(etud)
+ def is_default(self) -> bool:
+ "vrai si partition par défault (tous les étudiants)"
+ return not self.partition_name
+
def is_parcours(self) -> bool:
"Vrai s'il s'agit de la partition de parcours"
return self.partition_name == scu.PARTITION_PARCOURS
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index 97dc82e32..2b9313fad 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -6,6 +6,7 @@ from flask import abort, g
from flask_login import current_user
from flask_sqlalchemy.query import Query
+import app
from app import db
from app.auth.models import User
from app.comp import df_cache
@@ -78,7 +79,9 @@ class ModuleImpl(ScoDocModel):
] or self.module.get_edt_ids()
def get_evaluations_poids(self) -> pd.DataFrame:
- """Les poids des évaluations vers les UE (accès via cache)"""
+ """Les poids des évaluations vers les UEs (accès via cache redis).
+ Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
+ """
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
if evaluations_poids is None:
from app.comp import moy_mod
@@ -108,20 +111,37 @@ class ModuleImpl(ScoDocModel):
"""Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id)
- def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
- """true si les poids des évaluations du module permettent de satisfaire
- les coefficients du PN.
+ def check_apc_conformity(
+ self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE
+ ) -> bool:
+ """true si les poids des évaluations du type indiqué (normales par défaut)
+ du module permettent de satisfaire les coefficients du PN.
"""
+ # appelé par formsemestre_status, liste notes, et moduleimpl_status
if not self.module.formation.get_cursus().APC_SAE or (
- self.module.module_type != scu.ModuleType.RESSOURCE
- and self.module.module_type != scu.ModuleType.SAE
+ self.module.module_type
+ not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
):
return True # Non BUT, toujours conforme
from app.comp import moy_mod
+ mod_results = res.modimpls_results.get(self.id)
+ if mod_results is None:
+ app.critical_error("check_apc_conformity: err 1")
+
+ selected_evaluations_ids = [
+ eval_id
+ for eval_id, eval_type in mod_results.evals_type.items()
+ if eval_type == evaluation_type
+ ]
+ if not selected_evaluations_ids:
+ return True # conforme si pas d'évaluations
+ selected_evaluations_poids = self.get_evaluations_poids().loc[
+ selected_evaluations_ids
+ ]
return moy_mod.moduleimpl_is_conforme(
self,
- self.get_evaluations_poids(),
+ selected_evaluations_poids,
res.modimpl_coefs_df,
)
@@ -233,6 +253,27 @@ class ModuleImpl(ScoDocModel):
return False
return True
+ def can_change_inscriptions(self, user: User | None = None, raise_exc=True) -> bool:
+ """check si user peut inscrire/désinsincrire des étudiants à ce module.
+ Autorise ScoEtudInscrit ou responsables semestre.
+ """
+ user = current_user if user is None else user
+ if not self.formsemestre.etat:
+ if raise_exc:
+ raise ScoLockedSemError("Modification impossible: semestre verrouille")
+ return False
+ # -- check access
+ # resp. module ou ou perm. EtudInscrit ou resp. semestre
+ if (
+ user.id != self.responsable_id
+ and not user.has_permission(Permission.EtudInscrit)
+ and user.id not in (u.id for u in self.formsemestre.responsables)
+ ):
+ if raise_exc:
+ raise AccessDenied(f"Modification impossible pour {user}")
+ return False
+ return True
+
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).
diff --git a/app/models/modules.py b/app/models/modules.py
index 5abb5340f..f05e66a8c 100644
--- a/app/models/modules.py
+++ b/app/models/modules.py
@@ -340,6 +340,21 @@ class Module(models.ScoDocModel):
# Liste seulement les coefs définis:
return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()]
+ def get_ue_coefs_descr(self) -> str:
+ """Description des coefficients vers les UEs (APC)"""
+ coefs_descr = ", ".join(
+ [
+ f"{ue.acronyme}: {co}"
+ for ue, co in self.ue_coefs_list()
+ if isinstance(co, float) and co > 0
+ ]
+ )
+ if coefs_descr:
+ descr = "Coefs: " + coefs_descr
+ else:
+ descr = "(pas de coefficients) "
+ return descr
+
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")"""
if self.code_apogee:
diff --git a/app/models/ues.py b/app/models/ues.py
index d88cc7e67..d6282fc7d 100644
--- a/app/models/ues.py
+++ b/app/models/ues.py
@@ -46,6 +46,8 @@ class UniteEns(models.ScoDocModel):
# coef UE, utilise seulement si l'option use_ue_coefs est activée:
coefficient = db.Column(db.Float)
+ # id de l'élément Apogée du RCUE (utilisé pour les UEs de sem. pair du BUT)
+ code_apogee_rcue = db.Column(db.String(APO_CODE_STR_LEN))
# coef. pour le calcul de moyennes de RCUE. Par défaut, 1.
coef_rcue = db.Column(db.Float, nullable=False, default=1.0, server_default="1.0")
@@ -274,6 +276,12 @@ class UniteEns(models.ScoDocModel):
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
+ def get_codes_apogee_rcue(self) -> set[str]:
+ """Les codes Apogée RCUE (codés en base comme "VRT1,VRT2")"""
+ if self.code_apogee_rcue:
+ return {x.strip() for x in self.code_apogee_rcue.split(",") if x}
+ return set()
+
def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
"""set des ids de niveaux communs à tous les parcours listés"""
return set.intersection(
diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py
index 3c08326cd..83752b5e8 100644
--- a/app/scodoc/gen_tables.py
+++ b/app/scodoc/gen_tables.py
@@ -176,6 +176,7 @@ class GenTable:
self.xml_link = xml_link
# HTML parameters:
if not table_id: # random id
+ log("Warning: GenTable() called without table_id")
self.table_id = "gt_" + str(random.randint(0, 1000000))
else:
self.table_id = table_id
@@ -312,9 +313,12 @@ class GenTable:
T.append(l + [self.bottom_titles.get(cid, "") for cid in self.columns_ids])
return T
- def get_titles_list(self):
+ def get_titles_list(self, with_lines_titles=True):
"list of titles"
- return [self.titles.get(cid, "") for cid in self.columns_ids]
+ titles = [self.titles.get(cid, "") for cid in self.columns_ids]
+ if with_lines_titles:
+ titles.insert(0, "")
+ return titles
def gen(self, fmt="html", columns_ids=None):
"""Build representation of the table in the specified format.
diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py
index 6362d4db3..5d42e3d9d 100644
--- a/app/scodoc/html_sco_header.py
+++ b/app/scodoc/html_sco_header.py
@@ -25,8 +25,7 @@
#
##############################################################################
-"""HTML Header/Footer for ScoDoc pages
-"""
+"""HTML Header/Footer for ScoDoc pages"""
import html
@@ -101,7 +100,7 @@ _HTML_BEGIN = f"""
@@ -218,7 +217,7 @@ def sco_header(
{% endblock scripts %}
diff --git a/app/templates/assiduites/pages/bilan_dept.j2 b/app/templates/assiduites/pages/bilan_dept.j2
index c66ff1f18..1407067cc 100644
--- a/app/templates/assiduites/pages/bilan_dept.j2
+++ b/app/templates/assiduites/pages/bilan_dept.j2
@@ -12,7 +12,7 @@
Traitement de l'assiduité
-Pour saisir l'assiduité ou consulter les états, il est recommandé de passer par
+Pour saisir l'assiduité ou consulter les états, passer par
le semestre concerné (saisie par jour ou saisie différée).
diff --git a/app/templates/assiduites/pages/bilan_etud.j2 b/app/templates/assiduites/pages/bilan_etud.j2
index fb586556b..74586c5ff 100644
--- a/app/templates/assiduites/pages/bilan_etud.j2
+++ b/app/templates/assiduites/pages/bilan_etud.j2
@@ -86,9 +86,6 @@ Bilan assiduité de {{sco.etud.nomprenom}}
-
Le tableau n'affiche que les assiduités non justifiées
- et les justificatifs soumis / modifiés
-
{{tableau | safe }}
@@ -99,6 +96,9 @@ Bilan assiduité de {{sco.etud.nomprenom}}
département)
Les statistiques sont calculées entre les deux dates sélectionnées. Après modification des dates,
appuyer sur le bouton "Actualiser"
+
+ {% include "assiduites/explication_etats_justifs.j2" %}
+
@@ -111,7 +111,13 @@ Bilan assiduité de {{sco.etud.nomprenom}}
diff --git a/app/templates/assiduites/pages/edit_assiduite_etud.j2 b/app/templates/assiduites/pages/edit_assiduite_etud.j2
new file mode 100644
index 000000000..beee768b5
--- /dev/null
+++ b/app/templates/assiduites/pages/edit_assiduite_etud.j2
@@ -0,0 +1,180 @@
+{# Ajout d'une "assiduité" sur un étudiant #}
+
+{% extends "sco_page.j2" %}
+{% import 'wtf.j2' as wtf %}
+
+
+{% block styles %}
+{{super()}}
+
+
+
+{% endblock %}
+
+{% block app_content %}
+
+
+ {% for jour in hebdo_jours %}
+
+ {% if not jour[0] or jour[1][0] not in ['Samedi', 'Dimanche'] %}
+
{{ jour[1][0] }} {{jour[1][1] }}
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% for jour in hebdo_jours %}
+
+ {% if not jour[0] or jour[1][0] not in ['Samedi', 'Dimanche'] %}
+
Matin
+
Après-midi
+ {% endif %}
+ {% endfor %}
+
+ {% if not readonly and not non_present %}
+
+ {# Ne pas afficher si preference "non presences" / "readonly" #}
+
+ {% for jour in hebdo_jours %}
+ {% if not jour[0] or jour[1][0] not in ['Samedi', 'Dimanche'] %}
+
+
+
+
+
+
+ {% endif %}
+ {% endfor %}
+
+ {% endif %}
+
+
+ {% for etud in etudiants %}
+
+
{{ etud.nom_prenom() }}
+ {# Sera rempli en JS #}
+ {# Ne pas afficher bouton présent si pref "non présences" #}
+ {#
+
+
+
+
+
+
+
+
+
#}
+
+ {% endfor %}
+
+
+
+
+
+ ×
+
Choisissez les horaires
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% include "assiduites/widgets/alert.j2" %}
+{% include "assiduites/widgets/toast.j2" %}
+{% endblock app_content %}
\ No newline at end of file
diff --git a/app/templates/assiduites/pages/traitement_justificatifs.j2 b/app/templates/assiduites/pages/traitement_justificatifs.j2
index ea2ae094c..5833a7f2b 100644
--- a/app/templates/assiduites/pages/traitement_justificatifs.j2
+++ b/app/templates/assiduites/pages/traitement_justificatifs.j2
@@ -86,87 +86,7 @@
}
- .sco-drop {
- border: 1px solid #e1e1e1;
- /* Couleur de bordure plus douce */
- border-radius: 8px;
- /* Coins plus arrondis */
- background-color: #fafafa;
- /* Couleur de fond légère */
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- /* Ombre douce pour de la profondeur */
- width: 100%;
- /* Adaptation à la largeur de son conteneur */
- max-width: 600px;
- /* Largeur maximale pour une meilleure apparence sur grands écrans */
- margin: 10px auto;
- /* Centrage avec une marge */
- position: relative;
- z-index: 1;
- }
-
- .sco-drop[open] {
- z-index: 2;
- /* Empilement au-dessus des autres détails */
- }
-
- .sco-drop summary {
- font-weight: 600;
- /* Texte plus épais */
- color: #333;
- /* Couleur de texte plus foncée pour le contraste */
- padding: 7px 10px;
- /* Plus de padding pour une meilleure ergonomie */
- cursor: pointer;
- list-style: none;
- /* Enlève les puces */
- outline: none;
- /* Supprime la bordure de focus par défaut pour un look plus net */
- user-select: none;
- /* Empêche la sélection du texte */
- text-align: center;
- }
-
- .sco-drop summary::-webkit-details-marker {
- display: none;
- /* Cache le triangle par défaut sur Chrome/Safari */
- }
-
- .sco-drop summary:focus {
- outline: none;
- /* Plus propre sans contour lors du focus */
- }
-
- .sco-drop ul {
- list-style: none;
- /* Enlève les puces */
- margin: 5px 0;
- padding: 0;
- background-color: #fff;
- /* Arrière-plan blanc pour le contenu */
- position: absolute;
- border-radius: 8px;
- z-index: 1000;
- border: 1px solid #e1e1e1;
- /* Bordure plus douce */
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
- /* Ombre douce pour de la profondeur */
- overflow-y: scroll;
- max-height: 150px;
- /* Hauteur maximale pour une meilleure apparence sur grands écrans */
- }
-
- .sco-drop li {
- padding: 10px 20px;
- /* Espacement intérieur pour les éléments de liste */
- border-top: 1px solid #e1e1e1;
- /* Séparateur subtil entre les éléments */
- }
-
- .sco-drop li:first-child {
- border-top: none;
- /* Pas de bordure en haut du premier élément */
- }
+
{% endblock styles %}
diff --git a/app/templates/assiduites/pages/visu_assi_group.j2 b/app/templates/assiduites/pages/visu_assi_group.j2
index 2b99c8a1f..3dc48d265 100644
--- a/app/templates/assiduites/pages/visu_assi_group.j2
+++ b/app/templates/assiduites/pages/visu_assi_group.j2
@@ -44,10 +44,15 @@ label.stats_checkbox {
const date_fin = "{{date_fin}}";
const group_ids = "{{group_ids}}";
+ // Changement de la date de début ou de fin des statitiques
+ // Recharge la page avec les nouvelles dates
function stats() {
const deb = Date.fromFRA(document.querySelector('#stats_date_debut').value);
const fin = Date.fromFRA(document.querySelector('#stats_date_fin').value);
- location.href = `visu_assi_group?group_ids=${group_ids}&date_debut=${deb}&date_fin=${fin}`;
+ let url = new URL(window.location.href);
+ url.searchParams.set('date_debut', deb);
+ url.searchParams.set('date_fin', fin);
+ location.href = url.href;
}
window.addEventListener('load', () => {
diff --git a/app/templates/assiduites/widgets/alert.j2 b/app/templates/assiduites/widgets/alert.j2
index bf1f0bfec..7aa527ba6 100644
--- a/app/templates/assiduites/widgets/alert.j2
+++ b/app/templates/assiduites/widgets/alert.j2
@@ -127,19 +127,29 @@
{% endblock promptModal %}
\ No newline at end of file
diff --git a/app/templates/assiduites/widgets/tableau.j2 b/app/templates/assiduites/widgets/tableau.j2
index 1517ebcb9..a392b7f87 100644
--- a/app/templates/assiduites/widgets/tableau.j2
+++ b/app/templates/assiduites/widgets/tableau.j2
@@ -154,7 +154,7 @@
-
diff --git a/app/templates/assiduites/widgets/timeline.j2 b/app/templates/assiduites/widgets/timeline.j2
index 5ff68ee4d..eaac2b845 100644
--- a/app/templates/assiduites/widgets/timeline.j2
+++ b/app/templates/assiduites/widgets/timeline.j2
@@ -17,30 +17,35 @@
const timelineContainer = document.querySelector(".timeline-container");
const periodTimeLine = document.querySelector(".period");
const t_start = {{ t_start }};
+ const t_mid = {{ t_mid }};
const t_end = {{ t_end }};
const tick_time = 60 / {{ tick_time }};
const tick_delay = 1 / tick_time;
- const period_default = {{ periode_defaut }};
+ const period_default = 2;
let handleMoving = false;
+ // Création des graduations de la timeline
+ // On créé des grandes graduations pour les heures
+ // On créé des petites graduations pour les "tick"
function createTicks() {
let i = t_start;
while (i <= t_end) {
+ // création d'un tick Heure (grand)
const hourTick = document.createElement("div");
hourTick.classList.add("tick", "hour");
hourTick.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
timelineContainer.appendChild(hourTick);
-
+ // on ajoute un label pour l'heure (ex : 12:00)
const tickLabel = document.createElement("div");
tickLabel.classList.add("tick-label");
tickLabel.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
tickLabel.textContent = numberToTime(i);
timelineContainer.appendChild(tickLabel);
-
+ // Si on est pas à la fin, on ajoute les graduations intermédiaires
if (i < t_end) {
let j = Math.floor(i + 1);
@@ -48,6 +53,7 @@
i += tick_delay;
if (i <= t_end) {
+ // création d'un tick (petit)
const quarterTick = document.createElement("div");
quarterTick.classList.add("tick", "quarter");
quarterTick.style.left = `${computePercentage(i, t_start)}%`;
@@ -61,7 +67,8 @@
}
}
}
-
+ // Convertit un nombre en heure
+ // ex : 12.5 => "12:30"
function numberToTime(num) {
const integer = Math.floor(num);
const decimal = Math.round((num % 1) * 60);
@@ -79,13 +86,12 @@
return int + dec;
}
-
+ // Arrondi un nombre au tick le plus proche
function snapToQuarter(value) {
-
-
return Math.round(value * tick_time) / tick_time;
}
-
+ // Mise à jour des valeurs des timepickers
+ // En fonction des valeurs de la timeline
function updatePeriodTimeLabel() {
const values = getPeriodValues();
const deb = numberToTime(values[0])
@@ -101,94 +107,112 @@
}
+ // Gestion des évènements de la timeline
+ // - Déplacement des poignées
+ // - Déplacement de la période
function timelineMainEvent(event) {
+ // Position de départ de l'événement (souris ou tactile)
const startX = (event.clientX || event.changedTouches[0].clientX);
+ // Vérifie si l'événement concerne une poignée de période
if (event.target.classList.contains("period-handle")) {
+ // Initialisation des valeurs de départ
const startWidth = parseFloat(periodTimeLine.style.width);
const startLeft = parseFloat(periodTimeLine.style.left);
const isLeftHandle = event.target.classList.contains("left");
- handleMoving = true
+ handleMoving = true;
+
+ // Fonction de déplacement de la poignée
const onMouseMove = (moveEvent) => {
if (!handleMoving) return;
+ // Calcul du déplacement en pixels
const deltaX = (moveEvent.clientX || moveEvent.changedTouches[0].clientX) - startX;
const containerWidth = timelineContainer.clientWidth;
- const newWidth =
- startWidth + ((isLeftHandle ? -deltaX : deltaX) / containerWidth) * 100;
+ // Calcul de la nouvelle largeur en pourcentage
+ const newWidth = startWidth + ((isLeftHandle ? -deltaX : deltaX) / containerWidth) * 100;
if (isLeftHandle) {
+ // Si la poignée gauche est déplacée, ajuste également la position gauche
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, newWidth);
} else {
adjustPeriodPosition(parseFloat(periodTimeLine.style.left), newWidth);
}
+ // Met à jour l'étiquette de temps de la période
updatePeriodTimeLabel();
};
+
+ // Fonction de relâchement de la souris ou du tactile
+ // - Alignement des poignées sur les ticks
+ // - Appel des callbacks
+ // - Sauvegarde des valeurs dans le local storage
+ // - Réinitialisation de la variable de déplacement des poignées
const mouseUp = () => {
snapHandlesToQuarters();
timelineContainer.removeEventListener("mousemove", onMouseMove);
handleMoving = false;
func_call();
+ savePeriodInLocalStorage();
+ };
- }
+ // Ajoute les écouteurs d'événement pour le déplacement et le relâchement
timelineContainer.addEventListener("mousemove", onMouseMove);
timelineContainer.addEventListener("touchmove", onMouseMove);
- document.addEventListener(
- "mouseup",
- mouseUp,
- { once: true }
- );
- document.addEventListener(
- "touchend",
- mouseUp,
- { once: true }
+ document.addEventListener("mouseup", mouseUp, { once: true });
+ document.addEventListener("touchend", mouseUp, { once: true });
- );
+ // Vérifie si l'événement concerne la période elle-même
} else if (event.target === periodTimeLine) {
const startLeft = parseFloat(periodTimeLine.style.left);
+ // Fonction de déplacement de la période
const onMouseMove = (moveEvent) => {
if (handleMoving) return;
const deltaX = (moveEvent.clientX || moveEvent.changedTouches[0].clientX) - startX;
const containerWidth = timelineContainer.clientWidth;
+ // Calcul de la nouvelle position gauche en pourcentage
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, parseFloat(periodTimeLine.style.width));
-
updatePeriodTimeLabel();
};
+
+ // Fonction de relâchement de la souris ou du tactile
+ // - Alignement des poignées sur les ticks
+ // - Appel des callbacks
+ // - Sauvegarde des valeurs dans le local storage
const mouseUp = () => {
snapHandlesToQuarters();
timelineContainer.removeEventListener("mousemove", onMouseMove);
func_call();
- }
+ savePeriodInLocalStorage();
+ };
+
+ // Ajoute les écouteurs d'événement pour le déplacement et le relâchement
timelineContainer.addEventListener("mousemove", onMouseMove);
timelineContainer.addEventListener("touchmove", onMouseMove);
- document.addEventListener(
- "mouseup",
- mouseUp,
- { once: true }
- );
- document.addEventListener(
- "touchend",
- mouseUp,
- { once: true }
- );
+ document.addEventListener("mouseup", mouseUp, { once: true });
+ document.addEventListener("touchend", mouseUp, { once: true });
}
}
+
let func_call = () => { };
+ // Fonction initialisant la timeline
+ // La fonction "callback" est appelée à chaque modification de la période
function setupTimeLine(callback) {
func_call = callback;
timelineContainer.addEventListener("mousedown", (e) => { timelineMainEvent(e) });
timelineContainer.addEventListener("touchstart", (e) => { timelineMainEvent(e) });
+ // Initialisation des timepickers (à gauche de la timeline)
+ // lors d'un changement, cela met à jour la timeline
const updateFromInputs = ()=>{
let deb = $('#deb').val();
let fin = $('#fin').val();
@@ -206,9 +230,11 @@
$('#deb').data('TimePicker').options.change = updateFromInputs;
$('#fin').data('TimePicker').options.change = updateFromInputs;
+ // actualise l'affichage des inputs avec les valeurs de la timeline
updatePeriodTimeLabel();
}
-
+ // Ajuste la position de la période en fonction de la nouvelle position et largeur
+ // Vérifie que la période ne dépasse pas les limites de la timeline
function adjustPeriodPosition(newLeft, newWidth) {
const snappedLeft = snapToQuarter(newLeft);
@@ -221,30 +247,36 @@
periodTimeLine.style.left = `${clampedLeft}%`;
periodTimeLine.style.width = `${snappedWidth}%`;
}
-
+ // Récupère les valeurs de la période
function getPeriodValues() {
+ // On prend les pourcentages
const leftPercentage = parseFloat(periodTimeLine.style.left);
const widthPercentage = parseFloat(periodTimeLine.style.width);
+ // On calcule l'inverse des pourcentages pour obtenir les heures
const startHour = (leftPercentage / 100) * (t_end - t_start) + t_start;
const endHour = ((leftPercentage + widthPercentage) / 100) * (t_end - t_start) + t_start;
-
+ // On les arrondit aux ticks les plus proches
const startValue = snapToQuarter(startHour);
const endValue = snapToQuarter(endHour);
-
+
+ // on verifie que les valeurs sont bien dans les bornes
const computedValues = [Math.max(startValue, t_start), Math.min(t_end, endValue)];
-
+
+ // si les valeurs sont hors des bornes, on les ajuste
if (computedValues[0] > t_end || computedValues[1] < t_start) {
return [t_start, Math.min(t_end, t_start + period_default)];
}
-
+ // Si la période est trop petite, on l'agrandit artificiellement (il faut au moins 1 tick de largeur)
if (computedValues[1] - computedValues[0] <= tick_delay && computedValues[1] < t_end - tick_delay) {
computedValues[1] += tick_delay;
}
return computedValues;
}
-
+ // Met à jour les valeurs de la période
+ // Met à jour l'affichage de la timeline
+ // Appelle les callbacks associés
function setPeriodValues(deb, fin) {
if (fin < deb) {
throw new RangeError(`le paramètre 'deb' doit être inférieur au paramètre 'fin' ([${deb};${fin}])`)
@@ -256,16 +288,19 @@
deb = snapToQuarter(deb);
fin = snapToQuarter(fin);
- let leftPercentage = (deb - t_start) / (t_end - t_start) * 100;
- let widthPercentage = (fin - deb) / (t_end - t_start) * 100;
+ let leftPercentage = computePercentage(deb, t_start);
+ let widthPercentage = computePercentage(fin, deb);
periodTimeLine.style.left = `${leftPercentage}%`;
periodTimeLine.style.width = `${widthPercentage}%`;
snapHandlesToQuarters();
updatePeriodTimeLabel()
func_call();
+ savePeriodInLocalStorage();
}
-
+ // Aligne les poignées de la période sur les ticks les plus proches
+ // ex : 12h39 => 12h45 (si les ticks sont à 15min)
+ // evite aussi les dépassements de la timeline (max et min)
function snapHandlesToQuarters() {
const periodValues = getPeriodValues();
let lef = Math.min(computePercentage(Math.abs(periodValues[0]), t_start), computePercentage(Math.abs(t_end), tick_delay));
@@ -284,15 +319,20 @@
updatePeriodTimeLabel()
}
-
+ // Retourne le pourcentage d'une valeur par rapport à t_start et t_end
+ // ex : 12h par rapport à 8h et 20h => 25%
function computePercentage(a, b) {
return ((a - b) / (t_end - t_start)) * 100;
}
+ // Convertit une heure (string) en nombre
+ // ex : "12:30" => 12.5
function fromTime(time, separator = ":") {
const [hours, minutes] = time.split(separator).map((el) => Number(el))
return hours + minutes / 60
}
-
+ // Renvoie les valeurs de la période sous forme de date
+ // Les heures sont récupérées depuis la timeline
+ // la date est récupérée depuis un champ "#date" (datepicker)
function getPeriodAsDate(){
let [deb, fin] = getPeriodValues();
deb = numberToTime(deb);
@@ -301,18 +341,36 @@
const dateStr = $("#date")
.datepicker("getDate")
.format("yyyy-mm-dd")
- .substring(0, 10);
+ .substring(0, 10); // récupération que de la date, pas des heures
return {
deb: new Date(`${dateStr}T${deb}`),
fin: new Date(`${dateStr}T${fin}`)
}
}
+ // Sauvegarde les valeurs de la période dans le local storage
+ function savePeriodInLocalStorage(){
+ const dates = getPeriodValues();
+ localStorage.setItem("sco-timeline-values", JSON.stringify(dates));
+ }
- createTicks();
+ // Récupère les valeurs de la période depuis le local storage
+ // Si elles n'existent pas, on les initialise avec les valeurs par défaut
+ function loadPeriodFromLocalStorage(){
+ const dates = JSON.parse(localStorage.getItem("sco-timeline-values"));
+ if(dates){
+ setPeriodValues(...dates);
+ }else{
+ setPeriodValues(t_start, t_start + period_default);
+ }
+ }
+ // == Initialisation par défaut de la timeline ==
- setPeriodValues(t_start, t_start + period_default);
+ createTicks(); // création des graduations
+ loadPeriodFromLocalStorage(); // chargement des valeurs si disponible
+
+ // Si on donne les heures en appelant le template alors on met à jour la timeline
{% if heures %}
let [heure_deb, heure_fin] = [{{ heures | safe }}]
if (heure_deb != '' && heure_fin != '') {
diff --git a/app/templates/assiduites/widgets/toast.j2 b/app/templates/assiduites/widgets/toast.j2
index 6081d227f..b75cc1e48 100644
--- a/app/templates/assiduites/widgets/toast.j2
+++ b/app/templates/assiduites/widgets/toast.j2
@@ -68,32 +68,49 @@
\ No newline at end of file
diff --git a/app/templates/calendrier.j2 b/app/templates/calendrier.j2
index 2149a7865..7bfc803fc 100644
--- a/app/templates/calendrier.j2
+++ b/app/templates/calendrier.j2
@@ -1,10 +1,11 @@
ajouter un module de malus dans chaque UE du S{{semestre_id}}
diff --git a/app/templates/pn/form_ues.j2 b/app/templates/pn/form_ues.j2
index 437d4da2b..823579004 100644
--- a/app/templates/pn/form_ues.j2
+++ b/app/templates/pn/form_ues.j2
@@ -52,10 +52,23 @@
else 'aucun'|safe
}} ECTS
{%- endif -%}
- {%- if ue.code_apogee -%}
- {{ virg() }} Apo {{ue.code_apogee}}
- {%- endif -%}
- )
+ {{ virg() }} Apo:
+
+ {{ue.code_apogee or ''
+ }}
+ RCUE:
+ {{ue.code_apogee_rcue or ''
+ }})
diff --git a/app/templates/sco_bug_report.j2 b/app/templates/sco_bug_report.j2
new file mode 100644
index 000000000..07bd189c9
--- /dev/null
+++ b/app/templates/sco_bug_report.j2
@@ -0,0 +1,19 @@
+{# -*- mode: jinja-html -*- #}
+{% extends 'base.j2' %}
+{% import 'wtf.j2' as wtf %}
+
+{% block app_content %}
+
Assistance technique
+
+ Ce formulaire permet d'effectuer une demande d'assistance technique.
+ Son contenu sera accessible publiquement sur scodoc.org, veuillez donc ne pas y inclure d'informations sensibles.
+ L'adresse email associée à votre compte ScoDoc est automatiquement transmise avec votre demande mais ne sera pas
+ affichée publiquement.
+
+ etudid=sco.etud.id) }}" title="Les pages bilan et liste ont été fusionnées">Liste/Bilan
{% endif %}
{# /etud-insidebar #}
diff --git a/app/views/assiduites.py b/app/views/assiduites.py
index e9a32454c..00c4d3171 100644
--- a/app/views/assiduites.py
+++ b/app/views/assiduites.py
@@ -36,6 +36,7 @@ from flask_login import current_user
from flask_sqlalchemy.query import Query
from markupsafe import Markup
+from werkzeug.exceptions import HTTPException
from app import db, log
from app.comp import res_sem
@@ -48,8 +49,8 @@ from app.forms.assiduite.ajout_assiduite_etud import (
AjoutAssiOrJustForm,
AjoutAssiduiteEtudForm,
AjoutJustificatifEtudForm,
- ChoixDateForm,
)
+from app.forms.assiduite.edit_assiduite_etud import EditAssiForm
from app.models import (
Assiduite,
Departement,
@@ -65,7 +66,7 @@ from app.models import (
from app.scodoc.codes_cursus import UE_STANDARD
from app.auth.models import User
-from app.models.assiduites import get_assiduites_justif
+from app.models.assiduites import get_assiduites_justif, is_period_conflicting
from app.tables.list_etuds import RowEtud, TableEtud
import app.tables.liste_assiduites as liste_assi
@@ -225,6 +226,26 @@ def ajout_assiduite_etud() -> str | Response:
etudid: int = request.args.get("etudid", -1)
etud = Identite.get_etud(etudid)
+ formsemestre_id = request.args.get("formsemestre_id", None)
+
+ # Gestion du semestre
+ formsemestre: FormSemestre | None = None
+ sems_etud: list[FormSemestre] = etud.get_formsemestres()
+ if formsemestre_id:
+ formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+ formsemestre = formsemestre if formsemestre in sems_etud else None
+ if formsemestre is None:
+ raise ScoValueError("Etudiant non inscrit dans ce semestre")
+ else:
+ formsemestre = list(
+ sorted(sems_etud, key=lambda x: x.est_courant(), reverse=True)
+ ) # Mets le semestre courant en premier et les autres dans l'ordre
+ formsemestre = formsemestre[0] if formsemestre else None
+ if formsemestre is None:
+ raise ScoValueError(
+ "L'étudiant n'est actuellement pas inscrit: on ne peut pas saisir son assiduité"
+ )
+
# Gestion évaluations (appel à la page depuis les évaluations)
evaluation_id: int | None = request.args.get("evaluation_id")
saisie_eval = evaluation_id is not None
@@ -243,29 +264,27 @@ def ajout_assiduite_etud() -> str | Response:
form = AjoutAssiduiteEtudForm(request.form)
# On dresse la liste des modules de l'année scolaire en cours
# auxquels est inscrit l'étudiant pour peupler le menu "module"
- modimpls_by_formsemestre = etud.get_modimpls_by_formsemestre(scu.annee_scolaire())
choices: OrderedDict = OrderedDict()
choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
- for formsemestre_id in modimpls_by_formsemestre:
- formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
+ # Récupération des modulesimpl du semestre si existant.
+ if formsemestre:
# indique le nom du semestre dans le menu (optgroup)
+ modimpls_from_formsemestre = etud.get_modimpls_from_formsemestre(formsemestre)
group_name: str = formsemestre.titre_annee()
choices[group_name] = [
(m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
- for m in modimpls_by_formsemestre[formsemestre_id]
+ for m in modimpls_from_formsemestre
if m.module.ue.type == UE_STANDARD
]
- if formsemestre.est_courant():
- choices.move_to_end(group_name, last=False)
choices.move_to_end("", last=False)
form.modimpl.choices = choices
force_options: dict = None
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(redirect_url)
- ok = _record_assiduite_etud(etud, form)
+ ok = _record_assiduite_etud(etud, form, formsemestre=formsemestre)
if ok:
flash("enregistré")
return redirect(redirect_url)
@@ -293,7 +312,7 @@ def ajout_assiduite_etud() -> str | Response:
form=form,
moduleimpl_id=moduleimpl_id,
redirect_url=redirect_url,
- sco=ScoData(etud),
+ sco=ScoData(etud, formsemestre=formsemestre),
tableau=tableau,
scu=scu,
)
@@ -301,7 +320,9 @@ def ajout_assiduite_etud() -> str | Response:
def _get_dates_from_assi_form(
form: AjoutAssiOrJustForm,
+ etud: Identite,
from_justif: bool = False,
+ formsemestre: FormSemestre | None = None,
) -> tuple[
bool, datetime.datetime | None, datetime.datetime | None, datetime.datetime | None
]:
@@ -393,6 +414,41 @@ def _get_dates_from_assi_form(
dt_debut_tz_server = dt_debut_tz_server.replace(hour=0, minute=0)
dt_fin_tz_server = dt_fin_tz_server.replace(hour=23, minute=59)
+ # Vérification dates contenu dans un semestre de l'étudiant
+ dates_semestres: list[tuple[datetime.date, datetime.date]] = (
+ [(sem.date_debut, sem.date_fin) for sem in etud.get_formsemestres()]
+ if formsemestre is None
+ else [(formsemestre.date_debut, formsemestre.date_fin)]
+ )
+
+ # Vérification date début
+ if not any(
+ [
+ dt_debut_tz_server.date() >= deb and dt_debut_tz_server.date() <= fin
+ for deb, fin in dates_semestres
+ ]
+ ):
+ form.set_error(
+ "La date de début n'appartient à aucun semestre de l'étudiant"
+ if formsemestre is None
+ else "La date de début n'appartient pas au semestre",
+ form.date_debut,
+ )
+
+ # Vérification date fin
+ if form.date_fin.data and not any(
+ [
+ dt_fin_tz_server.date() >= deb and dt_fin_tz_server.date() <= fin
+ for deb, fin in dates_semestres
+ ]
+ ):
+ form.set_error(
+ "La date de fin n'appartient à aucun semestre de l'étudiant"
+ if not formsemestre
+ else "La date de fin n'appartient pas au semestre",
+ form.date_fin,
+ )
+
dt_entry_date_tz_server = (
scu.TIME_ZONE.localize(dt_entry_date) if dt_entry_date else None
)
@@ -402,6 +458,7 @@ def _get_dates_from_assi_form(
def _record_assiduite_etud(
etud: Identite,
form: AjoutAssiduiteEtudForm,
+ formsemestre: FormSemestre | None = None,
) -> bool:
"""Enregistre les données du formulaire de saisie assiduité.
Returns ok if successfully recorded, else put error info in the form.
@@ -415,7 +472,7 @@ def _record_assiduite_etud(
dt_debut_tz_server,
dt_fin_tz_server,
dt_entry_date_tz_server,
- ) = _get_dates_from_assi_form(form)
+ ) = _get_dates_from_assi_form(form, etud, formsemestre=formsemestre)
# Le module (avec "autre")
mod_data = form.modimpl.data
if mod_data:
@@ -492,10 +549,8 @@ def _record_assiduite_etud(
assi: Assiduite = conflits.first()
lien: str = url_for(
- "assiduites.tableau_assiduite_actions",
- type="assiduite",
- action="details",
- obj_id=assi.assiduite_id,
+ "assiduites.edit_assiduite_etud",
+ assiduite_id=assi.assiduite_id,
scodoc_dept=g.scodoc_dept,
)
@@ -511,51 +566,6 @@ def _record_assiduite_etud(
return False
-@bp.route("/liste_assiduites_etud")
-@scodoc
-@permission_required(Permission.ScoView)
-def liste_assiduites_etud():
- """
- liste_assiduites_etud Affichage de toutes les assiduites et justificatifs d'un etudiant
- Args:
- etudid (int): l'identifiant de l'étudiant
-
- Returns:
- str: l'html généré
- """
-
- # Récupération de l'étudiant concerné
- etudid = request.args.get("etudid", -1)
- etud: Identite = Identite.query.get_or_404(etudid)
- if etud.dept_id != g.scodoc_dept_id:
- abort(404, "étudiant inexistant dans ce département")
-
- # Gestion d'une assiduité unique (redirigé depuis le calendrier) TODO-Assiduites
- assiduite_id: int = request.args.get("assiduite_id", -1)
-
- # Préparation de la page
- tableau = _prepare_tableau(
- liste_assi.AssiJustifData.from_etudiants(
- etud,
- ),
- filename=f"assiduites-justificatifs-{etud.id}",
- afficher_etu=False,
- filtre=liste_assi.AssiFiltre(type_obj=0),
- options=liste_assi.AssiDisplayOptions(show_module=True),
- cache_key=f"tableau-etud-{etud.id}",
- )
- if not tableau[0]:
- return tableau[1]
- # Page HTML:
- return render_template(
- "assiduites/pages/liste_assiduites.j2",
- assi_id=assiduite_id,
- etud=etud,
- tableau=tableau[1],
- sco=ScoData(etud),
- )
-
-
@bp.route("/bilan_etud")
@scodoc
@permission_required(Permission.ScoView)
@@ -583,28 +593,19 @@ def bilan_etud():
sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id),
)
- # Récupération des assiduités et justificatifs de l'étudiant
- data = liste_assi.AssiJustifData(
- etud.assiduites.filter(
- Assiduite.etat != scu.EtatAssiduite.PRESENT, Assiduite.est_just == False
+ # Préparation de la page
+ tableau = _prepare_tableau(
+ liste_assi.AssiJustifData.from_etudiants(
+ etud,
),
- etud.justificatifs.filter(
- Justificatif.etat.in_(
- [scu.EtatJustificatif.ATTENTE, scu.EtatJustificatif.MODIFIE]
- )
- ),
- )
-
- table = _prepare_tableau(
- data,
+ filename=f"assiduites-justificatifs-{etud.id}",
afficher_etu=False,
- filename=f"Bilan assiduité {etud.nomprenom}",
- titre="Bilan de l'assiduité de l'étudiant",
- cache_key=f"tableau-etud-{etud.id}-bilan",
+ filtre=liste_assi.AssiFiltre(type_obj=0),
+ options=liste_assi.AssiDisplayOptions(show_module=True),
+ cache_key=f"tableau-etud-{etud.id}",
)
-
- if not table[0]:
- return table[1]
+ if not tableau[0]:
+ return tableau[1]
# Génération de la page HTML
return render_template(
@@ -614,13 +615,13 @@ def bilan_etud():
date_debut=date_debut,
date_fin=date_fin,
sco=ScoData(etud),
- tableau=table[1],
+ tableau=tableau[1],
)
@bp.route("/edit_justificatif_etud/", methods=["GET", "POST"])
@scodoc
-@permission_required(Permission.AbsChange)
+@permission_required(Permission.ScoView)
def edit_justificatif_etud(justif_id: int):
"""
Edition d'un justificatif.
@@ -632,8 +633,19 @@ def edit_justificatif_etud(justif_id: int):
Returns:
str: l'html généré
"""
- justif = Justificatif.get_justificatif(justif_id)
+ try:
+ justif = Justificatif.get_justificatif(justif_id)
+ except HTTPException:
+ flash("Justificatif invalide")
+ return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept))
+
+ readonly = not current_user.has_permission(Permission.AbsChange)
+
form = AjoutJustificatifEtudForm(obj=justif)
+
+ if readonly:
+ form.disable_all()
+
# Set the default value for the etat field
if request.method == "GET":
form.date_debut.data = justif.date_debut.strftime(scu.DATE_FMT)
@@ -654,13 +666,15 @@ def edit_justificatif_etud(justif_id: int):
back_url = request.args.get("back_url", None)
redirect_url = back_url or url_for(
- "assiduites.liste_assiduites_etud",
+ "assiduites.bilan_etud",
scodoc_dept=g.scodoc_dept,
etudid=justif.etudiant.id,
)
if form.validate_on_submit():
- if form.cancel.data: # cancel button
+ if form.cancel.data or not current_user.has_permission(
+ Permission.AbsChange
+ ): # cancel button
return redirect(redirect_url)
if _record_justificatif_etud(justif.etudiant, form, justif):
return redirect(redirect_url)
@@ -675,12 +689,13 @@ def edit_justificatif_etud(justif_id: int):
etud=justif.etudiant,
filenames=filenames,
form=form,
- justif=justif,
+ justif=_preparer_objet("justificatif", justif),
nb_files=nb_files,
title=f"Modification justificatif absence de {justif.etudiant.html_link_fiche()}",
redirect_url=redirect_url,
sco=ScoData(justif.etudiant),
scu=scu,
+ readonly=not current_user.has_permission(Permission.AbsChange),
)
@@ -736,32 +751,6 @@ def ajout_justificatif_etud():
)
-def _verif_date_form_justif(
- form: AjoutJustificatifEtudForm, deb: datetime.datetime, fin: datetime.datetime
-) -> tuple[datetime.datetime, datetime.datetime]:
- """Gère les cas suivants :
- - si on indique seulement une date de debut : journée 0h-23h59
- - si on indique date de debut et heures : journée +heure deb/fin
- (déjà géré par _get_dates_from_assi_form)
- - Si on indique une date de début et de fin sans heures : Journées 0h-23h59
- - Si on indique une date de début et de fin avec heures : On fait un objet avec
- datedeb/heuredeb + datefin/heurefin (déjà géré par _get_dates_from_assi_form)
- """
-
- cas: list[bool] = [
- # cas 1
- not form.date_fin.data and not form.heure_debut.data,
- # cas 3
- form.date_fin.data != "" and not form.heure_debut.data,
- ]
-
- if any(cas):
- deb = deb.replace(hour=0, minute=0)
- fin = fin.replace(hour=23, minute=59)
-
- return deb, fin
-
-
def _record_justificatif_etud(
etud: Identite, form: AjoutJustificatifEtudForm, justif: Justificatif | None = None
) -> bool:
@@ -778,7 +767,7 @@ def _record_justificatif_etud(
dt_debut_tz_server,
dt_fin_tz_server,
dt_entry_date_tz_server,
- ) = _get_dates_from_assi_form(form, from_justif=True)
+ ) = _get_dates_from_assi_form(form, etud, from_justif=True)
if not ok:
log("_record_justificatif_etud: dates invalides")
form.set_error("Erreur: dates invalides")
@@ -948,69 +937,6 @@ def calendrier_assi_etud():
)
-@bp.route("/choix_date", methods=["GET", "POST"])
-@scodoc
-@permission_required(Permission.AbsChange)
-def choix_date() -> str:
- """
- choix_date Choix de la date pour la saisie des assiduités
-
- Route utilisée uniquement si la date courante n'est pas dans le semestre
- concerné par la requête vers une des pages suivantes :
- - saisie_assiduites_group
- - visu_assiduites_group
-
- """
- formsemestre_id = request.args.get("formsemestre_id")
- formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
-
- group_ids = request.args.get("group_ids")
- moduleimpl_id = request.args.get("moduleimpl_id")
- form = ChoixDateForm(request.form)
-
- if form.validate_on_submit():
- if form.cancel.data:
- return redirect(url_for("scodoc.index"))
- # Vérifier si date dans semestre
- ok: bool = False
- try:
- date: datetime.date = datetime.datetime.strptime(
- form.date.data, scu.DATE_FMT
- ).date()
- if date < formsemestre.date_debut or date > formsemestre.date_fin:
- form.set_error(
- "La date sélectionnée n'est pas dans le semestre.", form.date
- )
- else:
- ok = True
- except ValueError:
- form.set_error("Date invalide", form.date)
-
- if ok:
- return redirect(
- url_for(
- (
- "assiduites.signal_assiduites_group"
- if request.args.get("readonly") is None
- else "assiduites.visu_assiduites_group"
- ),
- scodoc_dept=g.scodoc_dept,
- formsemestre_id=formsemestre_id,
- group_ids=group_ids,
- moduleimpl_id=moduleimpl_id,
- jour=date.isoformat(),
- )
- )
-
- return render_template(
- "assiduites/pages/choix_date.j2",
- form=form,
- sco=ScoData(formsemestre=formsemestre),
- deb=formsemestre.date_debut.strftime(scu.DATE_FMT),
- fin=formsemestre.date_fin.strftime(scu.DATE_FMT),
- )
-
-
@bp.route("/signal_assiduites_group")
@scodoc
@permission_required(Permission.AbsChange)
@@ -1025,7 +951,7 @@ def signal_assiduites_group():
# formsemestre_id est optionnel si modimpl est indiqué
formsemestre_id: int = request.args.get("formsemestre_id", -1)
moduleimpl_id: int = request.args.get("moduleimpl_id")
- date: str = request.args.get("jour", datetime.date.today().isoformat())
+ date: str = request.args.get("day", datetime.date.today().isoformat())
heures: list[str] = [
request.args.get("heure_deb", ""),
request.args.get("heure_fin", ""),
@@ -1065,7 +991,7 @@ def signal_assiduites_group():
)
if not groups_infos.members:
return (
- html_sco_header.sco_header(page_title="Saisie journalière de l'assiduité")
+ html_sco_header.sco_header(page_title="Saisie de l'assiduité")
+ "
Aucun étudiant !
"
+ html_sco_header.sco_footer()
)
@@ -1088,14 +1014,23 @@ def signal_assiduites_group():
if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
# Si le jour est hors semestre, renvoyer vers choix date
- return redirect(
- url_for(
- "assiduites.choix_date",
- formsemestre_id=formsemestre_id,
- group_ids=group_ids,
- moduleimpl_id=moduleimpl_id,
+ flash(
+ "La date sélectionnée n'est pas dans le semestre. Choisissez une autre date."
+ )
+
+ return sco_gen_cal.calendrier_choix_date(
+ formsemestre.date_debut,
+ formsemestre.date_fin,
+ url=url_for(
+ "assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
- )
+ formsemestre_id=formsemestre_id,
+ group_ids=",".join(group_ids),
+ moduleimpl_id=moduleimpl_id,
+ day="placeholder",
+ ),
+ mode="jour",
+ titre="Choix de la date",
)
# --- Restriction en fonction du moduleimpl_id ---
@@ -1156,137 +1091,7 @@ def signal_assiduites_group():
sco=ScoData(formsemestre=formsemestre),
sem=sem["titre_num"],
timeline=_timeline(heures=",".join([f"'{s}'" for s in heures])),
- title="Saisie journalière des assiduités",
- )
-
-
-@bp.route("/visu_assiduites_group")
-@scodoc
-@permission_required(Permission.ScoView)
-def visu_assiduites_group():
- """
- Visualisation des assiduités des groupes pour le jour donné
- dans le formsemestre_id et le moduleimpl_id
- Returns:
- str: l'html généré
- """
-
- # Récupération des paramètres de la requête
- formsemestre_id: int = request.args.get("formsemestre_id", -1)
- moduleimpl_id: int = request.args.get("moduleimpl_id")
- date: str = request.args.get("jour", datetime.date.today().isoformat())
- group_ids: list[int] = request.args.get("group_ids", None)
- if group_ids is None:
- group_ids = []
- else:
- group_ids = group_ids.split(",")
- map(str, group_ids)
-
- # Vérification du moduleimpl_id
- if moduleimpl_id is not None:
- try:
- moduleimpl_id = int(moduleimpl_id)
- except (TypeError, ValueError) as exc:
- raise ScoValueError("identifiant de moduleimpl invalide") from exc
- # Vérification du formsemestre_id
- if formsemestre_id is not None:
- try:
- formsemestre_id = int(formsemestre_id)
- except (TypeError, ValueError) as exc:
- raise ScoValueError("identifiant de formsemestre invalide") from exc
-
- # Récupérations des/du groupe(s)
- groups_infos = sco_groups_view.DisplayedGroupsInfos(
- group_ids, moduleimpl_id=moduleimpl_id, formsemestre_id=formsemestre_id
- )
- if not groups_infos.members:
- return (
- html_sco_header.sco_header(page_title="Saisie journalière de l'assiduité")
- + "
Aucun étudiant !
"
- + html_sco_header.sco_footer()
- )
-
- # --- Filtrage par formsemestre ---
- formsemestre_id = groups_infos.formsemestre_id
-
- formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
- if formsemestre.dept_id != g.scodoc_dept_id:
- abort(404, "groupes inexistants dans ce département")
-
- # Récupération des étudiants du/des groupe(s)
- etuds = [
- sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0]
- for m in groups_infos.members
- ]
-
- # --- Vérification de la date ---
- real_date = scu.is_iso_formated(date, True).date()
- if real_date < formsemestre.date_debut or real_date > formsemestre.date_fin:
- # Si le jour est hors semestre, renvoyer vers choix date
- return redirect(
- url_for(
- "assiduites.choix_date",
- formsemestre_id=formsemestre_id,
- group_ids=group_ids,
- moduleimpl_id=moduleimpl_id,
- scodoc_dept=g.scodoc_dept,
- readonly="true",
- )
- )
-
- # --- Restriction en fonction du moduleimpl_id ---
- if moduleimpl_id:
- mod_inscrits = {
- x["etudid"]
- for x in sco_moduleimpl.do_moduleimpl_inscription_list(
- moduleimpl_id=moduleimpl_id
- )
- }
- etuds_inscrits_module = [e for e in etuds if e["etudid"] in mod_inscrits]
- if etuds_inscrits_module:
- etuds = etuds_inscrits_module
- else:
- # Si aucun etudiant n'est inscrit au module choisi...
- moduleimpl_id = None
-
- # --- Génération du HTML ---
-
- if groups_infos.tous_les_etuds_du_sem:
- gr_tit = "en"
- else:
- if len(groups_infos.group_ids) > 1:
- grp = "des groupes"
- else:
- grp = "du groupe"
- gr_tit = (
- grp + ' ' + groups_infos.groups_titles + ""
- )
-
- # Récupération du semestre en dictionnaire
- sem = formsemestre.to_dict()
-
- return render_template(
- "assiduites/pages/signal_assiduites_group.j2",
- date=_dateiso_to_datefr(date),
- defdem=_get_etuds_dem_def(formsemestre),
- forcer_module=sco_preferences.get_preference(
- "forcer_module",
- formsemestre_id=formsemestre_id,
- dept_id=g.scodoc_dept_id,
- ),
- formsemestre_date_debut=str(formsemestre.date_debut),
- formsemestre_date_fin=str(formsemestre.date_fin),
- formsemestre_id=formsemestre_id,
- gr_tit=gr_tit,
- grp=sco_groups_view.menu_groups_choice(groups_infos),
- minitimeline=_mini_timeline(),
- moduleimpl_select=_module_selector(formsemestre, moduleimpl_id),
- nonworkdays=_non_work_days(),
- sem=sem["titre_num"],
- timeline=_timeline(),
- readonly="true",
- sco=ScoData(formsemestre=formsemestre),
- title="Saisie journalière de l'assiduité",
+ title="Saisie de l'assiduité",
)
@@ -1732,116 +1537,10 @@ def tableau_assiduite_actions():
flash(f"{objet_name} justifiée")
return redirect(request.referrer)
- if request.method == "GET":
- module: str | int = "" # moduleimpl_id ou chaine libre
-
- if obj_type == "assiduite":
- # Construction du menu module
- module = _module_selector_multiple(objet.etudiant, objet.moduleimpl_id)
-
- return render_template(
- "assiduites/pages/tableau_assiduite_actions.j2",
- action=action,
- can_view_justif_detail=current_user.has_permission(Permission.AbsJustifView)
- or (obj_type == "justificatif" and current_user.id == objet.user_id),
- etud=objet.etudiant,
- moduleimpl=module,
- obj_id=obj_id,
- objet_name=objet_name,
- objet=_preparer_objet(obj_type, objet),
- sco=ScoData(etud=objet.etudiant),
- title=f"Assiduité {objet.etudiant.nom_short}",
- # type utilisé dans les actions modifier / détails (modifier.j2, details.j2)
- type="Justificatif" if obj_type == "justificatif" else "Assiduité",
- )
- # ----- Cas POST
- if obj_type == "assiduite":
- try:
- _action_modifier_assiduite(objet)
- except ScoValueError as error:
- raise ScoValueError(error.args[0], request.referrer) from error
- flash("L'assiduité a bien été modifiée.")
- else:
- try:
- _action_modifier_justificatif(objet)
- except ScoValueError as error:
- raise ScoValueError(error.args[0], request.referrer) from error
- flash("Le justificatif a bien été modifié.")
- return redirect(request.form["table_url"])
-
-
-def _action_modifier_assiduite(assi: Assiduite):
- form = request.form
-
- # Gestion de l'état
- etat = scu.EtatAssiduite.get(form["etat"])
- if etat is not None:
- assi.etat = etat
- if etat == scu.EtatAssiduite.PRESENT:
- assi.est_just = False
- else:
- assi.est_just = len(get_assiduites_justif(assi.assiduite_id, False)) > 0
-
- # Gestion de la description
- assi.description = form["description"]
-
- possible_moduleimpl_id: str = form["moduleimpl_select"]
-
- # Raise ScoValueError (si None et force module | Etudiant non inscrit | Module non reconnu)
- assi.set_moduleimpl(possible_moduleimpl_id)
-
- db.session.add(assi)
- db.session.commit()
- scass.simple_invalidate_cache(assi.to_dict(True), assi.etudid)
-
-
-def _action_modifier_justificatif(justi: Justificatif):
- "Modifie le justificatif avec les valeurs dans le form"
- form = request.form
-
- # Gestion des Dates
- date_debut: datetime = scu.is_iso_formated(form["date_debut"], True)
- date_fin: datetime = scu.is_iso_formated(form["date_fin"], True)
- if date_debut is None or date_fin is None or date_fin < date_debut:
- raise ScoValueError("Dates invalides", request.referrer)
- justi.date_debut = date_debut
- justi.date_fin = date_fin
-
- # Gestion de l'état
- etat = scu.EtatJustificatif.get(form["etat"])
- if etat is not None:
- justi.etat = etat
- else:
- raise ScoValueError("État invalide", request.referrer)
-
- # Gestion de la raison
- justi.raison = form["raison"]
-
- # Gestion des fichiers
- files = request.files.getlist("justi_fich")
- if len(files) != 0:
- files = request.files.values()
-
- archive_name: str = justi.fichier
- # Utilisation de l'archiver de justificatifs
- archiver: JustificatifArchiver = JustificatifArchiver()
-
- for fich in files:
- archive_name, _ = archiver.save_justificatif(
- justi.etudiant,
- filename=fich.filename,
- data=fich.stream.read(),
- archive_name=archive_name,
- user_id=current_user.id,
- )
-
- justi.fichier = archive_name
-
- justi.dejustifier_assiduites()
- db.session.add(justi)
- db.session.commit()
- justi.justifier_assiduites()
- scass.simple_invalidate_cache(justi.to_dict(True), justi.etudid)
+ # Si on arrive ici, c'est que l'action n'est pas autorisée
+ # cette fonction ne sert plus qu'à supprimer ou justifier
+ flash("Méthode non autorisée", "error")
+ return redirect(request.referrer)
def _preparer_objet(
@@ -1864,20 +1563,18 @@ def _preparer_objet(
# Gestion justification
- if not objet.est_just:
- objet_prepare["justification"] = {"est_just": False}
- else:
- objet_prepare["justification"] = {"est_just": True, "justificatifs": []}
+ objet_prepare["justification"] = {
+ "est_just": objet.est_just,
+ "justificatifs": [],
+ }
- if not sans_gros_objet:
- justificatifs: list[int] = get_assiduites_justif(
- objet.assiduite_id, False
+ if not sans_gros_objet:
+ justificatifs: list[int] = get_assiduites_justif(objet.assiduite_id, False)
+ for justi_id in justificatifs:
+ justi: Justificatif = Justificatif.query.get(justi_id)
+ objet_prepare["justification"]["justificatifs"].append(
+ _preparer_objet("justificatif", justi, sans_gros_objet=True)
)
- for justi_id in justificatifs:
- justi: Justificatif = Justificatif.query.get(justi_id)
- objet_prepare["justification"]["justificatifs"].append(
- _preparer_objet("justificatif", justi, sans_gros_objet=True)
- )
else: # objet == "justificatif"
justif: Justificatif = objet
@@ -1890,9 +1587,8 @@ def _preparer_objet(
objet_prepare["justification"] = {"assiduites": [], "fichiers": {}}
if not sans_gros_objet:
- assiduites: list[int] = scass.justifies(justif)
- for assi_id in assiduites:
- assi: Assiduite = Assiduite.query.get(assi_id)
+ assiduites: list[Assiduite] = justif.get_assiduites()
+ for assi in assiduites:
objet_prepare["justification"]["assiduites"].append(
_preparer_objet("assiduite", assi, sans_gros_objet=True)
)
@@ -1922,115 +1618,6 @@ def _preparer_objet(
return objet_prepare
-@bp.route("/signal_assiduites_diff")
-@scodoc
-@permission_required(Permission.AbsChange)
-def signal_assiduites_diff():
- """
- Utilisé notamment par "Saisie différée" sur tableau de bord semetstre"
-
- Arguments de la requête:
-
- - group_ids : liste des groupes
- example : group_ids=1,2,3
- - formsemestre_id : id du formsemestre
- example : formsemestre_id=1
- - moduleimpl_id : id du moduleimpl
- example : moduleimpl_id=1
-
- (Permet de pré-générer une plage. Si non renseigné, la plage sera vide)
- (Les trois valeurs suivantes doivent être renseignées ensemble)
- - date
- example : date=01/01/2021
- - heure_debut
- example : heure_debut=08:00
- - heure_fin
- example : heure_fin=10:00
-
- Exemple de requête :
- signal_assiduites_diff?formsemestre_id=67&group_ids=400&moduleimpl_id=1229&date=15/04/2024&heure_debut=12:34&heure_fin=12:55
- """
- # Récupération des paramètres de la requête
- group_ids: list[int] = request.args.get("group_ids", None)
- formsemestre_id: int = request.args.get("formsemestre_id", -1)
- formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
-
- etudiants: list[Identite] = []
-
- # Vérification des groupes
- if group_ids is None:
- group_ids = []
- else:
- group_ids = group_ids.split(",")
- map(str, group_ids)
- groups_infos = sco_groups_view.DisplayedGroupsInfos(
- group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
- )
- if not groups_infos.members:
- return (
- html_sco_header.sco_header(page_title="Assiduité: saisie différée")
- + "
Aucun étudiant !
"
- + html_sco_header.sco_footer()
- )
-
- # Récupération des étudiants
- etudiants.extend(
- [Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members]
- )
- etudiants = list(sorted(etudiants, key=lambda etud: etud.sort_key))
-
- if groups_infos.tous_les_etuds_du_sem:
- gr_tit = "en"
- else:
- if len(groups_infos.group_ids) > 1:
- grp = "des groupes"
- else:
- grp = "du groupe"
- gr_tit = (
- grp + ' ' + groups_infos.groups_titles + ""
- )
-
- # Pré-remplissage des sélecteurs
- moduleimpl_id = request.args.get("moduleimpl_id", -1)
- try:
- moduleimpl_id = int(moduleimpl_id)
- except ValueError:
- moduleimpl_id = -1
- # date fra (dd/mm/yyyy)
- date = request.args.get("date", "")
- # heures (hh:mm)
- heure_deb = request.args.get("heure_debut", "")
- heure_fin = request.args.get("heure_fin", "")
-
- # vérifications des sélecteurs
- date = date if re.match(r"^\d{2}\/\d{2}\/\d{4}$", date) else ""
- heure_deb = heure_deb if re.match(r"^[0-2]\d:[0-5]\d$", heure_deb) else ""
- heure_fin = heure_fin if re.match(r"^[0-2]\d:[0-5]\d$", heure_fin) else ""
- nouv_plage: list[str] = [date, heure_deb, heure_fin]
-
- return render_template(
- "assiduites/pages/signal_assiduites_diff.j2",
- etudiants=etudiants,
- moduleimpl_select=_module_selector(
- formsemestre=formsemestre, moduleimpl_id=moduleimpl_id
- ),
- gr=gr_tit,
- nonworkdays=_non_work_days(),
- sco=ScoData(formsemestre=formsemestre),
- forcer_module=sco_preferences.get_preference(
- "forcer_module",
- formsemestre_id=formsemestre_id,
- dept_id=g.scodoc_dept_id,
- ),
- non_present=sco_preferences.get_preference(
- "non_present",
- formsemestre_id=formsemestre_id,
- dept_id=g.scodoc_dept_id,
- ),
- nouv_plage=nouv_plage,
- )
-
-
@bp.route("/signale_evaluation_abs//")
@scodoc
@permission_required(Permission.AbsChange)
@@ -2175,6 +1762,290 @@ def traitement_justificatifs():
)
+@bp.route("signal_assiduites_hebdo")
+@scodoc
+@permission_required(Permission.ScoView)
+def signal_assiduites_hebdo():
+ """
+ signal_assiduites_hebdo
+
+ paramètres obligatoires :
+ - formsemestre_id : id du formsemestre
+ - groups_id : id des groupes (séparés par des virgules -> 1,2,3)
+
+ paramètres optionnels :
+ - week : date semaine (iso 8601 -> 20XX-WXX), par défaut la semaine actuelle
+ - moduleimpl_id : id du moduleimpl (par défaut None)
+
+
+ Permissions :
+ - ScoView -> page en lecture seule
+ - AbsChange -> page en lecture/écriture
+ """
+
+ # Récupération des paramètres
+ moduleimpl_id: int = request.args.get("moduleimpl_id", None)
+ group_ids: str = request.args.get("group_ids", "") # ex: "1,2,3"
+ formsemestre_id: int = request.args.get("formsemestre_id", -1)
+ week: str = request.args.get("week", datetime.datetime.now().strftime("%G-W%V"))
+ # Vérification des paramètres
+ if group_ids == "" or formsemestre_id == -1:
+ raise ScoValueError("Paramètres manquants", dest_url=request.referrer)
+
+ # Récupération du moduleimpl
+ try:
+ moduleimpl_id: int = int(moduleimpl_id)
+ except (ValueError, TypeError):
+ moduleimpl_id: str | None = None if moduleimpl_id != "autre" else moduleimpl_id
+
+ # Récupération du formsemestre
+ formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
+
+ # Vérification semaine dans format iso 8601 et formsemestre
+ regex_iso8601 = r"^\d{4}-W\d{2}$"
+ if week and not re.match(regex_iso8601, week):
+ raise ScoValueError("Semaine invalide", dest_url=request.referrer)
+
+ fs_deb_iso8601 = formsemestre.date_debut.strftime("%Y-W%W")
+ fs_fin_iso8601 = formsemestre.date_fin.strftime("%Y-W%W")
+
+ # Utilisation de la propriété de la norme iso 8601
+ # les chaines sont triables par ordre alphanumérique croissant
+ # et produiront le même ordre que les dates par ordre chronologique croissant
+ if (not week) or week < fs_deb_iso8601 or week > fs_fin_iso8601:
+ if week:
+ flash(
+ """La semaine n'est pas dans le semestre,
+ choisissez la semaine sur laquelle saisir l'assiduité"""
+ )
+ return sco_gen_cal.calendrier_choix_date(
+ date_debut=formsemestre.date_debut,
+ date_fin=formsemestre.date_fin,
+ url=url_for(
+ "assiduites.signal_assiduites_hebdo",
+ scodoc_dept=g.scodoc_dept,
+ formsemestre_id=formsemestre_id,
+ group_ids=group_ids,
+ moduleimpl_id=moduleimpl_id,
+ week="placeholder",
+ ),
+ mode="semaine",
+ titre="Choix de la semaine",
+ )
+
+ # Vérification des groupes
+ group_ids = group_ids.split(",") if group_ids != "" else []
+
+ groups_infos = sco_groups_view.DisplayedGroupsInfos(
+ group_ids, formsemestre_id=formsemestre.id, select_all_when_unspecified=True
+ )
+ if not groups_infos.members:
+ return (
+ html_sco_header.sco_header(page_title="Assiduité: saisie hebdomadaire")
+ + "
Aucun étudiant !
"
+ + html_sco_header.sco_footer()
+ )
+
+ # Récupération des étudiants
+ etudiants: list[Identite] = [
+ Identite.get_etud(etudid=m["etudid"]) for m in groups_infos.members
+ ]
+
+ if groups_infos.tous_les_etuds_du_sem:
+ gr_tit = "en"
+ else:
+ if len(groups_infos.group_ids) > 1:
+ grp = "des groupes"
+ else:
+ grp = "du groupe"
+ gr_tit = (
+ grp + ' ' + groups_infos.groups_titles + ""
+ )
+
+ # Gestion des jours
+ jours: dict[str, list[str]] = {
+ "lun": [
+ "Lundi",
+ datetime.datetime.strptime(week + "-1", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "mar": [
+ "Mardi",
+ datetime.datetime.strptime(week + "-2", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "mer": [
+ "Mercredi",
+ datetime.datetime.strptime(week + "-3", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "jeu": [
+ "Jeudi",
+ datetime.datetime.strptime(week + "-4", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "ven": [
+ "Vendredi",
+ datetime.datetime.strptime(week + "-5", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "sam": [
+ "Samedi",
+ datetime.datetime.strptime(week + "-6", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ "dim": [
+ "Dimanche",
+ datetime.datetime.strptime(week + "-7", "%G-W%V-%u").strftime("%d/%m/%Y"),
+ ],
+ }
+
+ non_travail = sco_preferences.get_preference("non_travail")
+ non_travail = non_travail.replace(" ", "").split(",")
+
+ hebdo_jours: list[tuple[bool, str]] = []
+ for key, val in jours.items():
+ hebdo_jours.append((key in non_travail, val))
+
+ url_choix_semaine = url_for(
+ "assiduites.signal_assiduites_hebdo",
+ group_ids=",".join(map(str, groups_infos.group_ids)),
+ week="",
+ scodoc_dept=g.scodoc_dept,
+ formsemestre_id=groups_infos.formsemestre_id,
+ moduleimpl_id=moduleimpl_id,
+ )
+
+ return render_template(
+ "assiduites/pages/signal_assiduites_hebdo.j2",
+ title="Assiduité: saisie hebdomadaire",
+ gr=gr_tit,
+ etudiants=etudiants,
+ moduleimpl_select=_module_selector(
+ formsemestre=formsemestre, moduleimpl_id=moduleimpl_id
+ ),
+ hebdo_jours=hebdo_jours,
+ readonly=not current_user.has_permission(Permission.AbsChange),
+ non_present=sco_preferences.get_preference(
+ "non_present",
+ formsemestre_id=formsemestre_id,
+ dept_id=g.scodoc_dept_id,
+ ),
+ url_choix_semaine=url_choix_semaine,
+ )
+
+
+@bp.route("edit_assiduite_etud/", methods=["GET", "POST"])
+@scodoc
+@permission_required(Permission.ScoView)
+def edit_assiduite_etud(assiduite_id: int):
+ """
+ Page affichant les détails d'une assiduité
+ Si le current_user alors la page propose un formulaire de modification
+ """
+ try:
+ assi: Assiduite = Assiduite.get_assiduite(assiduite_id=assiduite_id)
+ except HTTPException:
+ flash("Assiduité invalide")
+ return redirect(url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept))
+
+ etud: Identite = assi.etudiant
+ formsemestre: FormSemestre = assi.get_formsemestre()
+
+ readonly: bool = not current_user.has_permission(Permission.AbsChange)
+
+ form: EditAssiForm = EditAssiForm(request.form)
+ if readonly:
+ form.disable_all()
+
+ # peuplement moduleimpl_select
+ choices: OrderedDict = OrderedDict()
+ choices[""] = [("", "Non spécifié"), ("autre", "Autre module (pas dans la liste)")]
+
+ # Récupération des modulesimpl du semestre si existant.
+ if formsemestre:
+ # indique le nom du semestre dans le menu (optgroup)
+ modimpls_from_formsemestre = etud.get_modimpls_from_formsemestre(formsemestre)
+ group_name: str = formsemestre.titre_annee()
+ choices[group_name] = [
+ (m.id, f"{m.module.code} {m.module.abbrev or m.module.titre or ''}")
+ for m in modimpls_from_formsemestre
+ if m.module.ue.type == UE_STANDARD
+ ]
+
+ choices.move_to_end("", last=False)
+ form.modimpl.choices = choices
+
+ # Vérification formulaire
+ if form.validate_on_submit():
+ if form.cancel.data: # cancel button
+ return redirect(request.referrer)
+
+ # vérification des valeurs
+
+ # Gestion de l'état
+ etat = form.assi_etat.data
+ try:
+ etat = int(etat)
+ etat = scu.EtatAssiduite.inverse().get(etat, None)
+ except ValueError:
+ etat = None
+
+ if etat is None:
+ form.error_messages.append("État invalide")
+ form.ok = False
+
+ description = form.description.data or ""
+ description = description.strip()
+ moduleimpl_id = form.modimpl.data if form.modimpl.data is not None else -1
+ # Vérifications des dates / horaires
+
+ ok, dt_deb, dt_fin, dt_entry = _get_dates_from_assi_form(
+ form, etud, from_justif=True, formsemestre=formsemestre
+ )
+ if ok:
+ if is_period_conflicting(
+ dt_deb, dt_fin, etud.assiduites, Assiduite, assi.id
+ ):
+ form.set_error("La période est en conflit avec une autre assiduité")
+ form.ok = False
+
+ if form.ok:
+ assi.etat = etat
+ assi.description = description
+ if moduleimpl_id != -1:
+ assi.set_moduleimpl(moduleimpl_id)
+
+ assi.date_debut = dt_deb
+ assi.date_fin = dt_fin
+ assi.entry_date = dt_entry
+
+ db.session.add(assi)
+ db.session.commit()
+
+ scass.simple_invalidate_cache(assi.to_dict(format_api=True), assi.etudid)
+
+ flash("enregistré")
+ return redirect(request.referrer)
+
+ # Remplissage du formulaire
+ form.assi_etat.data = str(assi.etat)
+ form.description.data = assi.description
+ moduleimpl_id: int | str | None = assi.get_moduleimpl_id() or ""
+ form.modimpl.data = str(moduleimpl_id)
+
+ form.date_debut.data = assi.date_debut.strftime(scu.DATE_FMT)
+ form.heure_debut.data = assi.date_debut.strftime(scu.TIME_FMT)
+ form.date_fin.data = assi.date_fin.strftime(scu.DATE_FMT)
+ form.heure_fin.data = assi.date_fin.strftime(scu.TIME_FMT)
+ form.entry_date.data = assi.entry_date.strftime(scu.DATE_FMT)
+ form.entry_time.data = assi.entry_date.strftime(scu.TIME_FMT)
+
+ return render_template(
+ "assiduites/pages/edit_assiduite_etud.j2",
+ etud=etud,
+ sco=ScoData(etud, formsemestre=formsemestre),
+ form=form,
+ readonly=readonly,
+ objet=_preparer_objet("assiduite", assi),
+ title=f"Assiduité {etud.nom_short}",
+ )
+
+
def generate_bul_list(etud: Identite, semestre: FormSemestre) -> str:
"""Génère la liste des assiduités d'un étudiant pour le bulletin mail"""
@@ -2298,89 +2169,6 @@ def _get_date_str(deb: datetime.datetime, fin: datetime.datetime) -> str:
return f'du {deb.strftime("%d/%m/%Y %H:%M")} au {fin.strftime("%d/%m/%Y %H:%M")}'
-def _get_days_between_dates(deb: str, fin: str) -> str:
- """
- _get_days_between_dates récupère tous les jours entre deux dates
-
- Args:
- deb (str): date de début
- fin (str): date de fin
-
- Returns:
- str: une chaine json représentant une liste des jours
- ['date_iso','date_iso2', ...]
- """
- if deb is None or fin is None:
- return "null"
- try:
- if isinstance(deb, str) and isinstance(fin, str):
- date_deb: datetime.date = datetime.date.fromisoformat(deb)
- date_fin: datetime.date = datetime.date.fromisoformat(fin)
- else:
- date_deb, date_fin = deb.date(), fin.date()
- except ValueError:
- return "null"
- dates: list[str] = []
- while date_deb <= date_fin:
- dates.append(f'"{date_deb.isoformat()}"')
- date_deb = date_deb + datetime.timedelta(days=1)
-
- return f"[{','.join(dates)}]"
-
-
-def _differee(
- etudiants: list[dict],
- moduleimpl_select: str,
- date: str = None,
- periode: dict[str, str] = None,
- formsemestre_id: int = None,
-) -> str:
- """
- _differee Génère un tableau de saisie différé
-
- Args:
- etudiants (list[dict]): la liste des étudiants (représentés par des dictionnaires)
- moduleimpl_select (str): l'html représentant le selecteur de module
- date (str, optional): la première date à afficher. Defaults to None.
- periode (dict[str, str], optional):La période par défaut de la première colonne.
- formsemestre_id (int, optional): l'id du semestre pour le selecteur de module.
-
- Returns:
- str: le widget (html/css/js)
- """
- if date is None:
- date = datetime.date.today().isoformat()
-
- forcer_module = sco_preferences.get_preference(
- "forcer_module",
- formsemestre_id=formsemestre_id,
- dept_id=g.scodoc_dept_id,
- )
-
- assi_etat_defaut = sco_preferences.get_preference(
- "assi_etat_defaut",
- formsemestre_id=formsemestre_id,
- dept_id=g.scodoc_dept_id,
- )
-
- periode_defaut = sco_preferences.get_preference(
- "periode_defaut",
- formsemestre_id=formsemestre_id,
- dept_id=g.scodoc_dept_id,
- )
-
- return render_template(
- "assiduites/widgets/differee.j2",
- etudiants=etudiants,
- assi_etat_defaut=assi_etat_defaut,
- periode_defaut=periode_defaut,
- forcer_module=forcer_module,
- moduleimpl_select=moduleimpl_select,
- date=date,
- periode=periode,
- )
-
-
def _module_selector(formsemestre: FormSemestre, moduleimpl_id: int = None) -> str:
"""
_module_selector Génère un HTMLSelectElement à partir des moduleimpl du formsemestre
@@ -2450,7 +2238,7 @@ def _module_selector_multiple(
)
-def _timeline(formsemestre_id: int = None, heures=None) -> str:
+def _timeline(heures=None) -> str:
"""
_timeline retourne l'html de la timeline
@@ -2465,11 +2253,9 @@ def _timeline(formsemestre_id: int = None, heures=None) -> str:
return render_template(
"assiduites/widgets/timeline.j2",
t_start=ScoDocSiteConfig.assi_get_rounded_time("assi_morning_time", "08:00:00"),
+ t_mid=ScoDocSiteConfig.assi_get_rounded_time("assi_lunch_time", "13:00:00"),
t_end=ScoDocSiteConfig.assi_get_rounded_time("assi_afternoon_time", "18:00:00"),
tick_time=ScoDocSiteConfig.get("assi_tick_time", 15),
- periode_defaut=sco_preferences.get_preference(
- "periode_defaut", formsemestre_id
- ),
heures=heures,
)
@@ -2617,12 +2403,12 @@ class JourAssi(sco_gen_cal.Jour):
(version journee divisée en demi-journees)
"""
heure_midi = scass.str_to_time(ScoDocSiteConfig.get("assi_lunch_time", "13:00"))
-
+ plage: tuple[datetime.datetime, datetime.datetime] = ()
if matin:
heure_matin = scass.str_to_time(
ScoDocSiteConfig.get("assi_morning_time", "08:00")
)
- matin = (
+ plage = (
# date debut
scu.localize_datetime(
datetime.datetime.combine(self.date, heure_matin)
@@ -2630,71 +2416,52 @@ class JourAssi(sco_gen_cal.Jour):
# date fin
scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
)
- assiduites_matin = [
- assi
- for assi in self.assiduites
- if scu.is_period_overlapping(
- (assi.date_debut, assi.date_fin), matin, bornes=False
- )
- ]
- justificatifs_matin = [
- justi
- for justi in self.justificatifs
- if scu.is_period_overlapping(
- (justi.date_debut, justi.date_fin), matin, bornes=False
- )
- ]
-
- etat = self._get_color_assiduites_cascade(
- self._get_etats_from_assiduites(assiduites_matin),
- show_pres=self.parent.show_pres,
- show_reta=self.parent.show_reta,
+ else:
+ heure_soir = scass.str_to_time(
+ ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
)
- est_just = self._get_color_justificatifs_cascade(
- self._get_etats_from_justificatifs(justificatifs_matin),
+ # séparation en demi journées
+ plage = (
+ # date debut
+ scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
+ # date fin
+ scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)),
)
- return f"color {etat} {est_just}"
-
- heure_soir = scass.str_to_time(
- ScoDocSiteConfig.get("assi_afternoon_time", "17:00")
- )
-
- # séparation en demi journées
- aprem = (
- # date debut
- scu.localize_datetime(datetime.datetime.combine(self.date, heure_midi)),
- # date fin
- scu.localize_datetime(datetime.datetime.combine(self.date, heure_soir)),
- )
-
- assiduites_aprem = [
+ assiduites = [
assi
for assi in self.assiduites
if scu.is_period_overlapping(
- (assi.date_debut, assi.date_fin), aprem, bornes=False
+ (assi.date_debut, assi.date_fin), plage, bornes=False
)
]
- justificatifs_aprem = [
+ justificatifs = [
justi
for justi in self.justificatifs
if scu.is_period_overlapping(
- (justi.date_debut, justi.date_fin), aprem, bornes=False
+ (justi.date_debut, justi.date_fin), plage, bornes=False
)
]
etat = self._get_color_assiduites_cascade(
- self._get_etats_from_assiduites(assiduites_aprem),
+ self._get_etats_from_assiduites(assiduites),
show_pres=self.parent.show_pres,
show_reta=self.parent.show_reta,
)
est_just = self._get_color_justificatifs_cascade(
- self._get_etats_from_justificatifs(justificatifs_aprem),
+ self._get_etats_from_justificatifs(justificatifs),
)
+ if est_just == "est_just" and any(
+ not assi.est_just
+ for assi in assiduites
+ if assi.etat != scu.EtatAssiduite.PRESENT
+ ):
+ est_just = ""
+
return f"color {etat} {est_just}"
def _get_etats_from_assiduites(self, assiduites: Query) -> list[scu.EtatAssiduite]:
@@ -2854,14 +2621,26 @@ def _generate_assiduite_bubble(assiduite: Assiduite) -> str:
# Récupérer informations saisie
saisie: str = assiduite.get_saisie()
- motif: str = assiduite.description if assiduite.description else ""
+ motif: str = assiduite.description or "Non spécifié"
+
+ # Récupérer date
+
+ if assiduite.date_debut.date() == assiduite.date_fin.date():
+ jour = assiduite.date_debut.strftime("%d/%m/%Y")
+ heure_deb: str = assiduite.date_debut.strftime("%H:%M")
+ heure_fin: str = assiduite.date_fin.strftime("%H:%M")
+ date: str = f"{jour} de {heure_deb} à {heure_fin}"
+ else:
+ date: str = (
+ f"du {assiduite.date_debut.strftime('%d/%m/%Y')} "
+ + f"au {assiduite.date_fin.strftime('%d/%m/%Y')}"
+ )
return render_template(
"assiduites/widgets/assiduite_bubble.j2",
moduleimpl=moduleimpl_infos,
etat=scu.EtatAssiduite(assiduite.etat).name.lower(),
- date_debut=assiduite.date_debut.strftime("%d/%m/%Y %H:%M"),
- date_fin=assiduite.date_fin.strftime("%d/%m/%Y %H:%M"),
+ date=date,
saisie=saisie,
motif=motif,
)
diff --git a/app/views/notes.py b/app/views/notes.py
index 1080edfa8..233ed249e 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -551,12 +551,6 @@ def ue_sharing_code():
)
-sco_publish(
- "/edit_ue_set_code_apogee",
- sco_edit_ue.edit_ue_set_code_apogee,
- Permission.EditFormation,
- methods=["POST"],
-)
sco_publish(
"/formsemestre_edit_uecoefs",
sco_formsemestre_edit.formsemestre_edit_uecoefs,
@@ -619,12 +613,6 @@ sco_publish(
Permission.EditFormation,
methods=["GET", "POST"],
)
-sco_publish(
- "/edit_module_set_code_apogee",
- sco_edit_module.edit_module_set_code_apogee,
- Permission.EditFormation,
- methods=["GET", "POST"],
-)
sco_publish("/module_list", sco_edit_module.module_table, Permission.ScoView)
sco_publish("/module_tag_search", sco_tag_module.module_tag_search, Permission.ScoView)
@@ -1248,6 +1236,7 @@ def view_module_abs(moduleimpl_id, fmt="html"):
filename="absmodule_" + scu.make_filename(modimpl.module.titre_str()),
caption=f"Absences dans le module {modimpl.module.titre_str()}",
preferences=sco_preferences.SemPreferences(),
+ table_id="view_module_abs",
)
if fmt != "html":
@@ -1340,7 +1329,7 @@ def formsemestre_enseignants_list(formsemestre_id, fmt="html"):
# --- Generate page with table
title = f"Enseignants de {formsemestre.titre_mois()}"
- T = GenTable(
+ table = GenTable(
columns_ids=["nom_fmt", "prenom_fmt", "descr_mods", "nbabsadded", "email"],
titles={
"nom_fmt": "Nom",
@@ -1361,8 +1350,9 @@ def formsemestre_enseignants_list(formsemestre_id, fmt="html"):
caption="""Tous les enseignants (responsables ou associés aux modules de
ce semestre) apparaissent. Le nombre de saisies d'absences est indicatif.""",
preferences=sco_preferences.SemPreferences(formsemestre_id),
+ table_id="formsemestre_enseignants_list",
)
- return T.make_page(page_title=title, title=title, fmt=fmt)
+ return table.make_page(page_title=title, title=title, fmt=fmt)
@bp.route("/edit_enseignants_form_delete", methods=["GET", "POST"])
@@ -1436,14 +1426,14 @@ def formsemestre_desinscription(etudid, formsemestre_id, dialog_confirmed=False)
if nt.etud_has_decision(etudid):
raise ScoValueError(
f"""Désinscription impossible: l'étudiant a une décision de jury
- (la supprimer avant si nécessaire:
- supprimer décision jury
- )
- """
+ }">supprimer décision jury)
+ """,
+ safe=True,
)
if not dialog_confirmed:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
@@ -1631,7 +1621,7 @@ sco_publish(
sco_publish(
"/moduleimpl_inscriptions_edit",
sco_moduleimpl_inscriptions.moduleimpl_inscriptions_edit,
- Permission.EtudInscrit,
+ Permission.ScoView,
methods=["GET", "POST"],
)
sco_publish(
diff --git a/app/views/refcomp.py b/app/views/refcomp.py
index b6402a260..bf4bd9f4a 100644
--- a/app/views/refcomp.py
+++ b/app/views/refcomp.py
@@ -56,7 +56,9 @@ def refcomp(refcomp_id):
@permission_required(Permission.ScoView)
def refcomp_show(refcomp_id):
"""Affichage du référentiel de compétences."""
- referentiel_competence = ApcReferentielCompetences.query.get_or_404(refcomp_id)
+ referentiel_competence: ApcReferentielCompetences = (
+ ApcReferentielCompetences.query.get_or_404(refcomp_id)
+ )
# Autres référentiels "équivalents" pour proposer de changer les formations:
referentiels_equivalents = referentiel_competence.equivalents()
return render_template(
@@ -128,6 +130,7 @@ def refcomp_table():
}
for ref in refs
],
+ table_id="refcomp_table",
)
return render_template(
"but/refcomp_table.j2",
diff --git a/app/views/scolar.py b/app/views/scolar.py
index c45af3fe5..34e7384e1 100644
--- a/app/views/scolar.py
+++ b/app/views/scolar.py
@@ -85,6 +85,7 @@ from app.scodoc import (
html_sco_header,
sco_import_etuds,
sco_archives_etud,
+ sco_bug_report,
sco_cache,
sco_debouche,
sco_dept,
@@ -109,6 +110,7 @@ from app.scodoc import (
sco_up_to_date,
)
from app.tables import list_etuds
+from app.forms.main.create_bug_report import CreateBugReport
def sco_publish(route, function, permission, methods=["GET"]):
@@ -332,6 +334,7 @@ def showEtudLog(etudid, fmt="html"):
fiche de {etud['nomprenom']}
""",
preferences=sco_preferences.SemPreferences(),
+ table_id="showEtudLog",
)
return tab.make_page(fmt=fmt)
@@ -2362,28 +2365,36 @@ def form_students_import_infos_admissions(formsemestre_id=None):
Les données sont affichées sur les fiches individuelles des étudiants.
-
- Importer ici la feuille excel utilisée pour envoyer le classement Parcoursup.
- Seuls les étudiants actuellement inscrits dans ce semestre ScoDoc seront affectés,
- les autres lignes de la feuille seront ignorées.
- Et seules les colonnes intéressant ScoDoc
- seront importées: il est inutile d'éliminer les autres.
-
- Seules les données "admission" seront modifiées
- (et pas l'identité de l'étudiant).
-
- Les colonnes "nom" et "prenom" sont requises, ou bien une colonne "etudid".
-
-
- Avant d'importer vos données, il est recommandé d'enregistrer
- les informations actuelles:
- exporter les données actuelles de ScoDoc
- (ce fichier peut être ré-importé après d'éventuelles modifications)
-
- """,
+
+
+ Vous pouvez importer ici la feuille excel utilisée pour envoyer
+ le classement Parcoursup.
+ Seuls les étudiants actuellement inscrits dans ce semestre ScoDoc seront affectés,
+ les autres lignes de la feuille seront ignorées.
+ Et seules les colonnes intéressant ScoDoc
+ seront importées: il est inutile d'éliminer les autres.
+
+
+ Seules les données "admission" seront modifiées
+ (et pas l'identité de l'étudiant).
+
+
+ Les colonnes "nom" et "prenom" sont requises,
+ ou bien une colonne "etudid" si la case
+ "Utiliser l'identifiant d'étudiant ScoDoc" est cochée.
+
+
+
+ Avant d'importer vos données, il est recommandé d'enregistrer
+ les informations actuelles:
+ exporter les données actuelles de ScoDoc
+ (ce fichier peut être ré-importé après d'éventuelles modifications)
+