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 %} +
+

Détails Assiduité concernant {{etud.html_link_fiche()|safe}}

+ +
+
+ Saisie par {{objet.saisie_par}} le {{objet.entry_date}} +
+
+ Période : du {{objet.date_debut}} au {{objet.date_fin}} +
+
+ Module : {{objet.module}} +
+
+ État de l'assiduité :{{objet.etat}} +
+
+ Description: + {% if objet.description != "" and objet.description != None %} + {{objet.description}} + {% else %} + Pas de description + {% endif %} + +
+ {# Affichage des justificatifs si assiduité justifiée #} + {% if objet.etat != "Présence" %} +
+ Justifiée: + {% if objet.justification.est_just %} + Oui + {% else %} + Non + {% if not objet.justification.justificatifs %} + Justifier l'assiduité + {% endif %} + {% endif %} +
+
+ {% if not objet.justification.justificatifs %} + Pas de justificatif associé + {% else %} + Justificatifs associés: + + {% endif %} +
+ {% endif %} +
+ + {% if readonly != True %} +

Modification de l'assiduité

+ {% for err_msg in form.error_messages %} +
+ {{ err_msg }} +
+ {% endfor %} + +
+ {{ form.hidden_tag() }} + {# Type d'évènement #} +
+ {{ form.assi_etat.label }} + {{ form.assi_etat() }} +
+
+ {{ form.date_debut.label }} : {{ form.date_debut }} + à {{ form.heure_debut }} + {{ render_field_errors(form, 'date_debut') }} + {{ render_field_errors(form, 'heure_debut') }} +
+ {{ form.date_fin.label }} : {{ form.date_fin }} + à {{ form.heure_fin }} + {{ render_field_errors(form, 'date_fin') }} + {{ render_field_errors(form, 'heure_fin') }} +
+ {{ form.entry_date.label }} : {{ form.entry_date }} à {{ form.entry_time }} + +
+
+ {# Menu module #} +
+ {{ form.modimpl.label }} : + {{ form.modimpl }} + {{ render_field_errors(form, 'modimpl') }} +
+ {# Description #} +
+
{{ form.description.label }}
+ {{ form.description() }} + {{ render_field_errors(form, 'description') }} +
+ {# Submit #} +
+ {{ form.submit }} {{ form.cancel }} +
+ + +
+ + {% else %} +

Vous n'avez pas la permission de modifier cette assiduité

+ {% endif %} + +
+ +{% endblock app_content %} + +{% block scripts %} +{{ super() }} + +{% include "sco_timepicker.j2" %} +{% endblock scripts %} \ No newline at end of file diff --git a/app/templates/assiduites/pages/liste_assiduites.j2 b/app/templates/assiduites/pages/liste_assiduites.j2 deleted file mode 100644 index ad7c0de09..000000000 --- a/app/templates/assiduites/pages/liste_assiduites.j2 +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "sco_page.j2" %} - -{% block title %} -Assiduité de {{etud.nomprenom}} -{% endblock title %} - -{% block styles %} - {{ super() }} - -{% endblock styles %} - -{% block scripts %} - {{ super() }} - -{% endblock %} - - -{% block app_content %} -
- -

Liste de l'assiduité et des justificatifs de {{sco.etud.html_link_fiche()|safe}}

- {{tableau | safe }} -
- -{% include "assiduites/explication_etats_justifs.j2" %} - -{% endblock app_content %} diff --git a/app/templates/assiduites/pages/signal_assiduites_diff.j2 b/app/templates/assiduites/pages/signal_assiduites_diff.j2 deleted file mode 100644 index 0c5c800ec..000000000 --- a/app/templates/assiduites/pages/signal_assiduites_diff.j2 +++ /dev/null @@ -1,672 +0,0 @@ -{% extends "sco_page.j2" %} - -{% block styles %} -{{ super() }} - - - - - -{% endblock styles %} - -{% block scripts %} -{{ super() }} - - - -{% include "sco_timepicker.j2" %} - - - -{% endblock scripts %} - -{% block title %} -{{title}} -{% endblock title %} - -{% block app_content %} - -

Signalement différé de l'assiduité {{gr |safe}}

- - -
- - -
- - - - - - - -
-
- -
-
- - - - - -
- - - - - - -{% include "assiduites/widgets/alert.j2" %} -{% endblock app_content %} diff --git a/app/templates/assiduites/pages/signal_assiduites_group.j2 b/app/templates/assiduites/pages/signal_assiduites_group.j2 index 737440403..fddae39c1 100644 --- a/app/templates/assiduites/pages/signal_assiduites_group.j2 +++ b/app/templates/assiduites/pages/signal_assiduites_group.j2 @@ -105,6 +105,24 @@ + + + {% endblock styles %} @@ -113,6 +131,10 @@ {{ minitimeline|safe }}
+ + ⬆️ + +
{{formsemestre_id}} {{formsemestre_date_debut}} @@ -131,12 +153,22 @@
Groupes : {{grp|safe}}
+
+
{{timeline|safe}} +
+ + +
{% if readonly == "false" %} @@ -162,14 +194,14 @@
{% if not non_present %} + class="rbtn present" onclick="mettreToutLeMonde('present', this)" title="Indique l'état Présent pour tous les étudiants" data-tooltip> {% endif %} + class="rbtn retard" onclick="mettreToutLeMonde('retard', this)" title="Indique l'état Retard pour tous les étudiants" data-tooltip> + class="rbtn absent" onclick="mettreToutLeMonde('absent', this)" title="Indique l'état Absent pour tous les étudiants" data-tooltip> + class="rbtn aucun" onclick="mettreToutLeMonde('vide', this)" title="Retire l'état pour tous les étudiants" data-tooltip>
Les saisies ci-dessous sont enregistrées au fur et à mesure.
diff --git a/app/templates/assiduites/pages/signal_assiduites_hebdo.j2 b/app/templates/assiduites/pages/signal_assiduites_hebdo.j2 new file mode 100644 index 000000000..36985a8de --- /dev/null +++ b/app/templates/assiduites/pages/signal_assiduites_hebdo.j2 @@ -0,0 +1,880 @@ +{% extends "sco_page.j2" %} + +{% block styles %} +{{ super() }} + + + + + + +{% endblock styles %} + +{% block scripts %} +{{ super() }} + + + +{% include "sco_timepicker.j2" %} + + + + + + + + + + +{% endblock scripts %} + +{% block title %} +{{ title }} +{% endblock title %} + +{% block app_content %} + +

Signalement hebdomadaire de l'assiduité {{ gr | safe }}

+
+
+ + + + autre semaine +
+ +

+ Le matin 9h à 12h et l'après-midi de 13h à 17h +

+ +{% if readonly %} +

+ Ouvert en mode lecture seule. +

+ +{% endif %} + + + + + + {% for jour in hebdo_jours %} + + {% if not jour[0] or jour[1][0] not in ['Samedi', 'Dimanche'] %} + + {% endif %} + + {% endfor %} + + + {% for jour in hebdo_jours %} + + {% if not jour[0] or jour[1][0] not in ['Samedi', 'Dimanche'] %} + + + {% 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 %} + + + {# Sera rempli en JS #} + {# Ne pas afficher bouton présent si pref "non présences" #} + {# + #} + + {% endfor %} + +
Étudiants{{ jour[1][0] }} {{jour[1][1] }}
MatinAprès-midi
+ + + +
{{ etud.nom_prenom() }} + + + + + + + +
+ +
+
+ × +

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 @@
- {% for mois,jours in calendrier.items() %} -
+ {% for mois,semaines in calendrier.items() %} +

{{mois}}

-
- {% for jour in jours %} -
+ {% for semaine in semaines %} +
+ {% for jour in semaines[semaine] %} +
{{jour.get_nom()}}
{{jour.get_html() | safe}} @@ -12,6 +13,7 @@
{% endfor %}
+ {% endfor %}
{% endfor %}
@@ -84,5 +86,8 @@ border-left: solid 3px var(--couleur); border-right: solid 3px var(--couleur); } + .highlight:hover{ + border: solid 3px yellow; + } \ No newline at end of file diff --git a/app/templates/choix_date.j2 b/app/templates/choix_date.j2 new file mode 100644 index 000000000..a8c4d0a46 --- /dev/null +++ b/app/templates/choix_date.j2 @@ -0,0 +1,62 @@ +{% extends "sco_page.j2" %} +{% block styles %} + {{super()}} + + +{% endblock %} + + +{% block app_content %} +
+

{{titre}}

+ {{calendrier | safe}} +
+ + +{% endblock app_content %} + +{% block scripts %} +{{ super() }} + + + +{% endblock scripts %} diff --git a/app/templates/pn/form_mods.j2 b/app/templates/pn/form_mods.j2 index 21677f404..274b25b5c 100644 --- a/app/templates/pn/form_mods.j2 +++ b/app/templates/pn/form_mods.j2 @@ -13,14 +13,14 @@
  • {% if editable and not loop.first %} - {{icons.arrow_up|safe}} {% else %} {{icons.arrow_none|safe}} {% endif %} {% if editable and not loop.last %} - {{icons.arrow_down|safe}} {% else %} @@ -28,7 +28,7 @@ {% endif %} {% if editable and not mod.modimpls.count() %} - {{icons.delete|safe}} {% else %} @@ -36,7 +36,7 @@ {% endif %} {% if editable %} - {% endif %} @@ -56,7 +56,9 @@ ({{mod.heures_cours|default(" ",true)|safe}}/{{mod.heures_td|default(" ",true)|safe}}/{{mod.heures_tp|default(" ",true)|safe}}, {% else %} ({% endif %}Apo: + data-url="{{url_for('apiweb.module_set_code_apogee', + scodoc_dept=g.scodoc_dept, module_id=mod.id) + }}" id="{{mod.id}}" data-placeholder="{{scu.APO_MISSING_CODE_STR}}"> {{mod.code_apogee|default("", true)}}) @@ -67,7 +69,7 @@ {% if mod.ue.type != 0 and mod.module_type != 0 %} - type incompatible avec son UE de rattachement ! @@ -91,8 +93,8 @@ {% if module_type==scu.ModuleType.STANDARD %}
  • 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.
    +

    + +
    +
    + {{ wtf.quick_form(form) }} +
    +
    +{% endblock app_content %} diff --git a/app/templates/sco_page.j2 b/app/templates/sco_page.j2 index f1e8d8c2e..53883f74b 100644 --- a/app/templates/sco_page.j2 +++ b/app/templates/sco_page.j2 @@ -49,7 +49,7 @@ diff --git a/app/templates/sco_value_error.j2 b/app/templates/sco_value_error.j2 index 6fa329c7b..2a488c933 100644 --- a/app/templates/sco_value_error.j2 +++ b/app/templates/sco_value_error.j2 @@ -5,15 +5,19 @@

    Erreur !

    -{{ exc }} +{% if exc.safe %} + {{ exc | safe }} +{% else %} + {{ exc }} +{% endif %}
    {% if g.scodoc_dept %} - continuer + continuer {% elif exc.dest_url %} - continuer + continuer {% else %} - retour page d'accueil + retour page d'accueil {% endif %}
    diff --git a/app/templates/scodoc/forms/evaluation_edit.j2 b/app/templates/scodoc/forms/evaluation_edit.j2 new file mode 100644 index 000000000..4e144ef75 --- /dev/null +++ b/app/templates/scodoc/forms/evaluation_edit.j2 @@ -0,0 +1,22 @@ +{# Interdit de définir des évaluations non normales "immédiates" #} + \ No newline at end of file diff --git a/app/templates/scolar/index.j2 b/app/templates/scolar/index.j2 index 74cd8a42b..2c3584a5d 100644 --- a/app/templates/scolar/index.j2 +++ b/app/templates/scolar/index.j2 @@ -299,6 +299,9 @@ div.effectif {
  • Envoyer données
  • +
  • + Signaler une erreur ou suggérer une amélioration +
  • diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 23fc83b2f..f3ea3a126 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -1,7 +1,7 @@ {# Barre marge gauche ScoDoc #} {# -*- mode: jinja-html -*- #} - -

    - 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) +

    +
    + """, ] tf = TrivialFormulator( @@ -2394,6 +2405,15 @@ def form_students_import_infos_admissions(formsemestre_id=None): "csvfile", {"title": "Fichier Excel:", "input_type": "file", "size": 40}, ), + ( + "use_etudid", + { + "input_type": "boolcheckbox", + "title": "Utiliser l'identifiant d'étudiant ScoDoc (etudid)", + "explanation": """si cochée, utilise le code pour retrouver dans ScoDoc + les étudiants du fichier excel. Sinon, utilise les noms/prénoms.""", + }, + ), ( "type_admission", { @@ -2433,6 +2453,7 @@ def form_students_import_infos_admissions(formsemestre_id=None): tf[2]["csvfile"], type_admission=tf[2]["type_admission"], formsemestre_id=formsemestre_id, + use_etudid=tf[2]["use_etudid"], ) @@ -2533,25 +2554,65 @@ def stat_bac(formsemestre_id): def sco_dump_and_send_db(message="", request_url="", traceback_str_base64=""): "Send anonymized data to supervision" - status_code = sco_dump_db.sco_dump_and_send_db( + r = sco_dump_db.sco_dump_and_send_db( message, request_url, traceback_str_base64=traceback_str_base64 ) + + status_code = r.status_code + + try: + r_msg = r.json()["message"] + except (requests.exceptions.JSONDecodeError, KeyError): + r_msg = "Erreur: code " + +status_code + +' Merci de contacter ' + +scu.SCO_DEV_MAIL + +"" + H = [html_sco_header.sco_header(page_title="Assistance technique")] - if status_code == requests.codes.INSUFFICIENT_STORAGE: # pylint: disable=no-member - H.append( - """

    - Erreur: espace serveur trop plein. - Merci de contacter {0}

    """.format( - scu.SCO_DEV_MAIL - ) - ) - elif status_code == requests.codes.OK: # pylint: disable=no-member - H.append("""

    Opération effectuée.

    """) + if status_code == requests.codes.OK: # pylint: disable=no-member + H.append(f"""

    Opération effectuée.

    {r_msg}

    """) else: - H.append( - f"""

    - Erreur: code {status_code} - Merci de contacter {scu.SCO_DEV_MAIL}

    """ - ) + H.append(f"""

    {r_msg}

    """) flash("Données envoyées au serveur d'assistance") return "\n".join(H) + html_sco_header.sco_footer() + + +# --- Report form (assistance) +@bp.route("/sco_bug_report", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +def sco_bug_report_form(): + "Formulaire de création d'un ticket d'assistance" + + form = CreateBugReport() + if request.method == "POST" and form.cancel.data: # cancel button + return flask.redirect(url_for("scodoc.index")) + if form.validate_on_submit(): + r = sco_bug_report.sco_bug_report( + form.title.data, form.message.data, form.etab.data, form.include_dump.data + ) + + status_code = r.status_code + try: + r_msg = r.json()["message"] + except (requests.exceptions.JSONDecodeError, KeyError): + log(f"sco_bug_report: error {status_code}") + r_msg = f"""Erreur: code {status_code} + Merci de contacter + {scu.SCO_DEV_MAIL} + """ + + H = [html_sco_header.sco_header(page_title="Assistance technique")] + if r.status_code >= 200 and r.status_code < 300: + H.append(f"""

    Opération effectuée.

    {r_msg}

    """) + else: + H.append(f"""

    {r_msg}

    """) + return "\n".join(H) + html_sco_header.sco_footer() + + return render_template( + "sco_bug_report.j2", + form=form, + ) diff --git a/migrations/versions/809faa9d89ec_code_apo_rcue.py b/migrations/versions/809faa9d89ec_code_apo_rcue.py new file mode 100644 index 000000000..6a2e383a1 --- /dev/null +++ b/migrations/versions/809faa9d89ec_code_apo_rcue.py @@ -0,0 +1,29 @@ +"""Ajoute code apo RCUE sur les UEs + +Revision ID: 809faa9d89ec +Revises: cddabc3f868a +Create Date: 2024-06-07 11:06:07.694166 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "809faa9d89ec" +down_revision = "cddabc3f868a" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("notes_ue", schema=None) as batch_op: + batch_op.add_column( + sa.Column("code_apogee_rcue", sa.String(length=512), nullable=True) + ) + + +def downgrade(): + with op.batch_alter_table("notes_ue", schema=None) as batch_op: + batch_op.drop_column("code_apogee_rcue") diff --git a/ressources/referentiels/equivalences.yaml b/ressources/referentiels/equivalences.yaml index d7ac623e5..097a2f8e0 100644 --- a/ressources/referentiels/equivalences.yaml +++ b/ressources/referentiels/equivalences.yaml @@ -15,4 +15,10 @@ QLIO: # la clé est 'specialite' ATN: MTD # competences: # titres de compétences ('nom_court' dans le XML) -SD: STID +STID: # passage de STID à SD + alias: + - SD + +SD: # pour revenir en arrière au besoin + alias: + - STID diff --git a/sco_version.py b/sco_version.py index 67362a1aa..336790981 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.966" +SCOVERSION = "9.6.980" SCONAME = "ScoDoc" @@ -14,7 +14,7 @@ SCONEWS = """
  • Nouveaux bulletins BUT compacts
  • Nouvelle gestion des absences et assiduité
  • Mise à jour logiciels: Debian 12, Python 3.11, ...
  • -
  • Evaluations bonus
  • +
  • Évaluations bonus
  • ScoDoc 9.5 (juillet 2023)
  • diff --git a/scodoc.py b/scodoc.py index 1b5b8eccf..582fb3a3e 100755 --- a/scodoc.py +++ b/scodoc.py @@ -1,10 +1,8 @@ # -*- coding: UTF-8 -*- -"""Application Flask: ScoDoc +"""Application Flask: ScoDoc""" - -""" import datetime from pprint import pprint as pp import re @@ -385,6 +383,18 @@ def user_role(username, dept_acronym=None, add_role_name=None, remove_role_name= db.session.commit() +@app.cli.command() +@click.argument("user_name") +@click.argument("new_user_name") +def user_change_login(user_name, new_user_name): + """Change user's login (user_name)""" + user: User = User.query.filter_by(user_name=user_name).first() + if not user: + sys.stderr.write(f"user_change_login: user {user_name} does not exists\n") + return 1 + user.change_user_name(new_user_name) + + def abort_if_false(ctx, param, value): if not value: ctx.abort() @@ -723,3 +733,16 @@ def generate_ens_calendars(): # generate-ens-calendars from tools.edt import edt_ens edt_ens.generate_ens_calendars() + + +@app.cli.command() +@click.option( + "-e", + "--endpoint", + default="api", + help="Endpoint à partir duquel générer la carte des routes", +) +@with_appcontext +def gen_api_map(endpoint): + """Génère la carte des routes de l'API.""" + tools.gen_api_map(app, endpoint_start=endpoint) diff --git a/tests/api/dump_all_results.py b/tests/api/dump_all_results.py new file mode 100644 index 000000000..65d58d617 --- /dev/null +++ b/tests/api/dump_all_results.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +"""Exemple utilisation API ScoDoc 9 + + Usage: + cd /opt/scodoc/tests/api + python -i dump_all_results.py + + Demande les résultats (bulletins JSON) de tous les semestres + et les enregistre dans un fichier json + (`all_results.json` du rép. courant) + + Sur M1 en 9.6.966, processed 1219 formsemestres in 645.64s + Sur M1 en 9.6.967, processed 1219 formsemestres in 626.22s + +""" + +import json +from pprint import pprint as pp +import sys +import time +import urllib3 +from setup_test_api import ( + API_PASSWORD, + API_URL, + API_USER, + APIError, + CHECK_CERTIFICATE, + get_auth_headers, + GET, + POST_JSON, + SCODOC_URL, +) + + +if not CHECK_CERTIFICATE: + urllib3.disable_warnings() + +print(f"SCODOC_URL={SCODOC_URL}") +print(f"API URL={API_URL}") + + +HEADERS = get_auth_headers(API_USER, API_PASSWORD) + + +# Liste de tous les formsemestres (de tous les depts) +formsemestres = GET("/formsemestres/query", headers=HEADERS) +print(f"{len(formsemestres)} formsemestres") + +# +all_results = [] +t0 = time.time() +for formsemestre in formsemestres: + print(f"{formsemestre['session_id']}\t", end="", flush=True) + t = time.time() + result = GET(f"/formsemestre/{formsemestre['id']}/resultats", headers=HEADERS) + print(f"{(time.time()-t):3.2f}s") + all_results.append( + f""" +{{ + "formsemestre" : { json.dumps(formsemestre, indent=1, sort_keys=True) }, + "resultats" : { json.dumps(result, indent=1, sort_keys=True) } +}} + """ + ) + +print(f"Processed {len(formsemestres)} formsemestres in {(time.time()-t0):3.2f}s") + +with open("all_results.json", "w", encoding="utf-8") as f: + f.write("[\n" + ",\n".join(all_results) + "\n]") diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py index 65ac47b50..01547b2e0 100644 --- a/tests/api/setup_test_api.py +++ b/tests/api/setup_test_api.py @@ -119,8 +119,12 @@ def GET(path: str, headers: dict = None, errmsg=None, dept=None, raw=False): raise APIError("Unknown returned content {r.headers.get('Content-Type', None} !\n") -def POST_JSON(path: str, data: dict = {}, headers: dict = None, errmsg=None, dept=None): - """Post""" +def POST_JSON( + path: str, data: dict = {}, headers: dict = None, errmsg=None, dept=None, raw=False +): + """Post + Decode réponse en json, sauf si raw. + """ if dept: url = SCODOC_URL + f"/ScoDoc/{dept}/api" + path else: @@ -134,7 +138,7 @@ def POST_JSON(path: str, data: dict = {}, headers: dict = None, errmsg=None, dep ) if r.status_code != 200: raise APIError(errmsg or f"erreur status={r.status_code} !", r.json()) - return r.json() # decode la reponse JSON + return r if raw else r.json() # decode la reponse JSON def check_fields(data: dict, fields: dict = None): diff --git a/tests/api/test_api_formations.py b/tests/api/test_api_formations.py index ebe34d345..2940d40ef 100644 --- a/tests/api/test_api_formations.py +++ b/tests/api/test_api_formations.py @@ -20,7 +20,14 @@ Utilisation : import requests from app.scodoc import sco_utils as scu -from tests.api.setup_test_api import API_URL, CHECK_CERTIFICATE, api_headers +from tests.api.setup_test_api import ( + api_admin_headers, + api_headers, + API_URL, + CHECK_CERTIFICATE, + GET, + POST_JSON, +) from tests.api.tools_test_api import ( verify_fields, FORMATION_EXPORT_FIELDS, @@ -320,3 +327,40 @@ def test_referentiel_competences(api_headers): timeout=scu.SCO_TEST_API_TIMEOUT, ) assert r_error.status_code == 404 + + +def test_api_ue_apo(api_admin_headers): + """Routes + /ue/ + /ue//set_code_apogee/ + /ue//set_code_apogee_rcue/ + """ + ue_id = 1 + ue = GET(path=f"/ue/{ue_id}", headers=api_admin_headers) + assert ue["id"] == ue_id + r = POST_JSON(f"/ue/{ue_id}/set_code_apogee/APOUE", {}, api_admin_headers, raw=True) + assert r.text == "APOUE" + r = POST_JSON( + f"/ue/{ue_id}/set_code_apogee_rcue/APORCUE", {}, api_admin_headers, raw=True + ) + assert r.text == "APORCUE" + ue = GET(path=f"/ue/{ue_id}", headers=api_admin_headers) + assert ue["code_apogee"] == "APOUE" + assert ue["code_apogee_rcue"] == "APORCUE" + + +def test_api_module_apo(api_admin_headers): + """Routes + /module/ + /module//set_code_apogee/ + """ + module_id = 1 + module = GET(path=f"/module/{module_id}", headers=api_admin_headers) + assert module["id"] == module_id + assert module["code_apogee"] == "" + r = POST_JSON( + f"/module/{module_id}/set_code_apogee/APOMOD", {}, api_admin_headers, raw=True + ) + assert r.text == "APOMOD" + module = GET(path=f"/module/{module_id}", headers=api_admin_headers) + assert module["code_apogee"] == "APOMOD" diff --git a/tests/api/test_api_formsemestre.py b/tests/api/test_api_formsemestre.py index 032b9729c..8b1dc0156 100644 --- a/tests/api/test_api_formsemestre.py +++ b/tests/api/test_api_formsemestre.py @@ -28,7 +28,6 @@ from tests.api.setup_test_api import ( API_URL, CHECK_CERTIFICATE, GET, - POST_JSON, api_headers, ) diff --git a/tests/unit/test_but_jury.py b/tests/unit/test_but_jury.py index ca61d2c60..3a4284b7a 100644 --- a/tests/unit/test_but_jury.py +++ b/tests/unit/test_but_jury.py @@ -25,18 +25,21 @@ from tests.unit import yaml_setup, yaml_setup_but import app from app.but.jury_but_validation_auto import formsemestre_validation_auto_but from app.models import Formation, FormSemestre, UniteEns +from app.scodoc import sco_prepajury from config import TestConfig DEPT = TestConfig.DEPT_TEST -def setup_and_test_jurys(yaml_filename: str): - "Charge YAML et lance test jury BUT" +def setup_and_test_jurys(yaml_filename: str) -> FormSemestre | None: + """Charge YAML et lance test jury BUT. + Rennvoie le dernier formsemestre s'il y en a un. + """ app.set_sco_dept(DEPT) # Construit la base de test GB une seule fois # puis lance les tests de jury doc, formation, formsemestre_titres = yaml_setup.setup_from_yaml(yaml_filename) - + formsemestre = None for formsemestre_titre in formsemestre_titres: formsemestre = yaml_setup.create_formsemestre_with_etuds( doc, formation, formsemestre_titre @@ -48,6 +51,7 @@ def setup_and_test_jurys(yaml_filename: str): # et vérification des résultats attendus: formsemestre_validation_auto_but(formsemestre, only_adm=False) yaml_setup_but.but_test_jury(formsemestre, doc) + return formsemestre @pytest.mark.slow @@ -59,8 +63,14 @@ def test_but_jury_GB(test_client): - vérification jury de S2 - vérification jury de S3 - vérification jury de S1 avec redoublants et capitalisations + puis: + - teste feuille prepajury """ - setup_and_test_jurys("tests/ressources/yaml/cursus_but_gb.yaml") + formsemestre: FormSemestre = setup_and_test_jurys( + "tests/ressources/yaml/cursus_but_gb.yaml" + ) + # Test feuille préparation jury + sco_prepajury.feuille_preparation_jury(formsemestre.id) @pytest.mark.slow diff --git a/tests/unit/test_but_modules.py b/tests/unit/test_but_modules.py index e12e6009f..515e156e1 100644 --- a/tests/unit/test_but_modules.py +++ b/tests/unit/test_but_modules.py @@ -247,14 +247,16 @@ def test_module_moy(test_client): _ = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, n1)]) _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, n2)]) # Calcul de la moyenne du module - evals_poids, _ = moy_mod.load_evaluations_poids(moduleimpl_id) + evals_poids = modimpl.get_evaluations_poids() assert evals_poids.shape == (nb_evals, nb_ues) etudids, etudids_actifs = formsemestre.etudids_actifs() mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs) evals_notes = mod_results.evals_notes assert evals_notes[evaluation1.id].dtype == np.float64 - - etuds_moy_module = mod_results.compute_module_moy(evals_poids) + modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs( + formsemestre, modimpls=formsemestre.modimpls_sorted + ) + etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df) return etuds_moy_module # --- Notes ordinaires: diff --git a/tests/unit/test_but_ues.py b/tests/unit/test_but_ues.py index ee66bcde7..1c0e33aef 100644 --- a/tests/unit/test_but_ues.py +++ b/tests/unit/test_but_ues.py @@ -1,6 +1,7 @@ """ Test calcul moyennes UE """ + import numpy as np from tests.unit import setup @@ -63,7 +64,10 @@ def test_ue_moy(test_client): _ = sco_saisie_notes.notes_add(G.default_user, evaluation1.id, [(etudid, n1)]) _ = sco_saisie_notes.notes_add(G.default_user, evaluation2.id, [(etudid, n2)]) # Recalcul des moyennes - sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) + modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs( + formsemestre, modimpls=formsemestre.modimpls_sorted + ) + sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre, modimpl_coefs_df) # Masque de tous les modules _sauf_ les bonus (sport) modimpl_mask = [ modimpl.module.ue.type != UE_SPORT @@ -117,7 +121,10 @@ def test_ue_moy(test_client): exception_raised = True assert exception_raised # Recalcule les notes: - sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre) + modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs( + formsemestre, modimpls=formsemestre.modimpls_sorted + ) + sem_cube, _, _ = moy_ue.notes_sem_load_cube(formsemestre, modimpl_coefs_df) etuds = formsemestre.etuds.all() modimpl_mask = [ modimpl.module.ue.type != UE_SPORT for modimpl in formsemestre.modimpls_sorted diff --git a/tests/unit/test_etudiants.py b/tests/unit/test_etudiants.py index d319360f6..aa0a6d05c 100644 --- a/tests/unit/test_etudiants.py +++ b/tests/unit/test_etudiants.py @@ -400,10 +400,10 @@ def test_import_etuds_xlsx(test_client): # Test de search_etud_in_dept etuds = sco_find_etud.search_etuds_infos_from_exp("NOM10") assert len(etuds) == 1 - assert etuds[0]["code_ine"] == "ine10" + assert etuds[0].code_ine == "ine10" etuds = sco_find_etud.search_etuds_infos_from_exp("NOM") assert len(etuds) > 1 - assert all(e["nom"].startswith("NOM") for e in etuds) + assert all(e.nom.startswith("NOM") for e in etuds) etuds = sco_find_etud.search_etuds_infos_from_exp("1000010") assert len(etuds) == 1 - assert etuds[0]["code_ine"] == "ine10" + assert etuds[0].code_ine == "ine10" diff --git a/tests/unit/test_export_xml.py b/tests/unit/test_export_xml.py index c220638c2..6e5d7ee41 100644 --- a/tests/unit/test_export_xml.py +++ b/tests/unit/test_export_xml.py @@ -36,7 +36,7 @@ def xmls_compare(x, y): def test_export_xml(test_client): """exports XML compatibles ScoDoc 7""" # expected_result est le résultat de l'ancienne fonction ScoDoc7: - for (data, expected_result) in ( + for data, expected_result in ( ( [{"id": 1, "ues": [{"note": 10}, {}, {"valeur": 25}]}, {"bis": 2}], """ @@ -122,6 +122,7 @@ def test_export_xml(test_client): table = GenTable( rows=[{"nom": "Toto", "age": 26}, {"nom": "Titi", "age": 21}], columns_ids=("nom", "age"), + table_id="test_export_xml", ) table_xml = table.xml() @@ -138,4 +139,4 @@ def test_export_xml(test_client): """ - assert xmls_compare(table_xml, expected_result) \ No newline at end of file + assert xmls_compare(table_xml, expected_result) diff --git a/tests/unit/test_notes_modules.py b/tests/unit/test_notes_modules.py index 1542eaecd..7a5a9ea56 100644 --- a/tests/unit/test_notes_modules.py +++ b/tests/unit/test_notes_modules.py @@ -2,6 +2,7 @@ Vérif moyennes de modules des bulletins et aussi moyennes modules et UE internes (via nt) """ + import datetime import numpy as np from flask import g @@ -107,16 +108,16 @@ def test_notes_modules(test_client): # --- Notes ordinaires note_1 = 12.0 note_2 = 13.0 - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etuds[0]["etudid"], note=note_1 ) - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etuds[0]["etudid"], note=note_2 ) - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etuds[1]["etudid"], note=note_1 / 2 ) - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etuds[1]["etudid"], note=note_2 / 3 ) b = sco_bulletins.formsemestre_bulletinetud_dict( @@ -139,22 +140,20 @@ def test_notes_modules(test_client): ) # Absence à une évaluation - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etudid, note=None ) # abs - _, _, _ = G.create_note( - evaluation_id=e2["evaluation_id"], etudid=etudid, note=note_2 - ) + _ = G.create_note(evaluation_id=e2["evaluation_id"], etudid=etudid, note=note_2) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) note_th = (coef_1 * 0.0 + coef_2 * note_2) / (coef_1 + coef_2) assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(note_th) # Absences aux deux évaluations - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etudid, note=None ) # abs - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etudid, note=None ) # abs b = sco_bulletins.formsemestre_bulletinetud_dict( @@ -171,10 +170,8 @@ def test_notes_modules(test_client): ) # Note excusée EXC <-> scu.NOTES_NEUTRALISE - _, _, _ = G.create_note( - evaluation_id=e1["evaluation_id"], etudid=etudid, note=note_1 - ) - _, _, _ = G.create_note( + _ = G.create_note(evaluation_id=e1["evaluation_id"], etudid=etudid, note=note_1) + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etudid, note=scu.NOTES_NEUTRALISE ) # EXC b = sco_bulletins.formsemestre_bulletinetud_dict( @@ -190,10 +187,8 @@ def test_notes_modules(test_client): expected_moy_ue=note_1, ) # Note en attente ATT <-> scu.NOTES_ATTENTE - _, _, _ = G.create_note( - evaluation_id=e1["evaluation_id"], etudid=etudid, note=note_1 - ) - _, _, _ = G.create_note( + _ = G.create_note(evaluation_id=e1["evaluation_id"], etudid=etudid, note=note_1) + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etudid, note=scu.NOTES_ATTENTE ) # ATT b = sco_bulletins.formsemestre_bulletinetud_dict( @@ -209,10 +204,10 @@ def test_notes_modules(test_client): expected_moy_ue=note_1, ) # Neutralisation (EXC) des 2 évals - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etudid, note=scu.NOTES_NEUTRALISE ) # EXC - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etudid, note=scu.NOTES_NEUTRALISE ) # EXC b = sco_bulletins.formsemestre_bulletinetud_dict( @@ -228,10 +223,10 @@ def test_notes_modules(test_client): expected_moy_ue=np.nan, ) # Attente (ATT) sur les 2 evals - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etudid, note=scu.NOTES_ATTENTE ) # ATT - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etudid, note=scu.NOTES_ATTENTE ) # ATT b = sco_bulletins.formsemestre_bulletinetud_dict( @@ -290,7 +285,7 @@ def test_notes_modules(test_client): {"etudid": etudid, "moduleimpl_id": moduleimpl_id}, formsemestre_id=formsemestre_id, ) - _, _, _ = G.create_note(evaluation_id=e1["evaluation_id"], etudid=etudid, note=12.5) + _ = G.create_note(evaluation_id=e1["evaluation_id"], etudid=etudid, note=12.5) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) mod_stats = nt.get_mod_stats(moduleimpl_id) @@ -318,9 +313,7 @@ def test_notes_modules(test_client): description="evaluation mod 2", coefficient=1.0, ) - _, _, _ = G.create_note( - evaluation_id=e_m2["evaluation_id"], etudid=etudid, note=19.5 - ) + _ = G.create_note(evaluation_id=e_m2["evaluation_id"], etudid=etudid, note=19.5) formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) ue_status = nt.get_etud_ue_status(etudid, ue_id) @@ -328,22 +321,20 @@ def test_notes_modules(test_client): # Moyenne d'UE si l'un des modules est EXC ("NA") # 2 modules, notes EXC dans le premier, note valide n dans le second # la moyenne de l'UE doit être n - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etudid, note=scu.NOTES_NEUTRALISE ) # EXC - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etudid, note=scu.NOTES_NEUTRALISE ) # EXC - _, _, _ = G.create_note( - evaluation_id=e_m2["evaluation_id"], etudid=etudid, note=12.5 - ) - _, _, _ = G.create_note( + _ = G.create_note(evaluation_id=e_m2["evaluation_id"], etudid=etudid, note=12.5) + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etuds[1]["etudid"], note=11.0 ) - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e2["evaluation_id"], etudid=etuds[1]["etudid"], note=11.0 ) - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e_m2["evaluation_id"], etudid=etuds[1]["etudid"], note=11.0 ) @@ -407,12 +398,12 @@ def test_notes_modules_att_dem(test_client): coefficient=coef_1, ) # Attente (ATT) sur les 2 evals - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etuds[0]["etudid"], note=scu.NOTES_ATTENTE, ) # ATT - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etuds[1]["etudid"], note=scu.NOTES_ATTENTE, @@ -455,7 +446,7 @@ def test_notes_modules_att_dem(test_client): assert note_e1 == scu.NOTES_ATTENTE # XXXX un peu contestable # Saisie note ABS pour le deuxième etud - _, _, _ = G.create_note( + _ = G.create_note( evaluation_id=e1["evaluation_id"], etudid=etuds[1]["etudid"], note=None ) nt = check_nt( diff --git a/tests/unit/test_notes_rattrapage.py b/tests/unit/test_notes_rattrapage.py index 4435cb2fb..4ee29c328 100644 --- a/tests/unit/test_notes_rattrapage.py +++ b/tests/unit/test_notes_rattrapage.py @@ -72,8 +72,8 @@ def test_notes_rattrapage(test_client): evaluation_type=Evaluation.EVALUATION_RATTRAPAGE, ) etud = etuds[0] - _, _, _ = G.create_note(evaluation_id=e["id"], etudid=etud["etudid"], note=12.0) - _, _, _ = G.create_note(evaluation_id=e_rat["id"], etudid=etud["etudid"], note=11.0) + _ = G.create_note(evaluation_id=e["id"], etudid=etud["etudid"], note=12.0) + _ = G.create_note(evaluation_id=e_rat["id"], etudid=etud["etudid"], note=11.0) # --- Vérifications internes structures ScoDoc formsemestre = db.session.get(FormSemestre, formsemestre_id) @@ -81,8 +81,9 @@ def test_notes_rattrapage(test_client): mod_res = res.modimpls_results[moduleimpl_id] moduleimpl = db.session.get(ModuleImpl, moduleimpl_id) # retrouve l'éval. de rattrapage: - eval_rat = mod_res.get_evaluation_rattrapage(moduleimpl) - assert eval_rat.id == e_rat["id"] + evals_rat = mod_res.get_evaluations_rattrapage(moduleimpl) + assert len(evals_rat) == 1 + assert evals_rat[0].id == e_rat["id"] # Les deux évaluations sont considérées comme complètes: assert len(mod_res.get_evaluations_completes(moduleimpl)) == 2 @@ -97,23 +98,21 @@ def test_notes_rattrapage(test_client): # Note moyenne: ici le ratrapage est inférieur à la note: assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(12.0) # rattrapage > moyenne: - _, _, _ = G.create_note(evaluation_id=e_rat["id"], etudid=etud["etudid"], note=18.0) + _ = G.create_note(evaluation_id=e_rat["id"], etudid=etud["etudid"], note=18.0) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(18.0) # rattrapage vs absences - _, _, _ = G.create_note( - evaluation_id=e["id"], etudid=etud["etudid"], note=None - ) # abs - _, _, _ = G.create_note(evaluation_id=e_rat["id"], etudid=etud["etudid"], note=17.0) + _ = G.create_note(evaluation_id=e["id"], etudid=etud["etudid"], note=None) # abs + _ = G.create_note(evaluation_id=e_rat["id"], etudid=etud["etudid"], note=17.0) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(17.0) # et sans note de rattrapage - _, _, _ = G.create_note(evaluation_id=e["id"], etudid=etud["etudid"], note=10.0) - _, _, _ = G.create_note(evaluation_id=e_rat["id"], etudid=etud["etudid"], note=None) + _ = G.create_note(evaluation_id=e["id"], etudid=etud["etudid"], note=10.0) + _ = G.create_note(evaluation_id=e_rat["id"], etudid=etud["etudid"], note=None) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) @@ -151,42 +150,44 @@ def test_notes_rattrapage(test_client): res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) mod_res = res.modimpls_results[moduleimpl_id] # retrouve l'éval. de session 2: - eval_session2 = mod_res.get_evaluation_session2(moduleimpl) - assert eval_session2.id == e_session2["id"] + evals_session2 = mod_res.get_evaluations_session2(moduleimpl) + assert len(evals_session2) == 1 + assert evals_session2[0].id == e_session2["id"] # Les deux évaluations sont considérées comme complètes: assert len(mod_res.get_evaluations_completes(moduleimpl)) == 2 # Saisie note session 2: - _, _, _ = G.create_note( - evaluation_id=e_session2["id"], etudid=etud["etudid"], note=5.0 - ) + _ = G.create_note(evaluation_id=e_session2["id"], etudid=etud["etudid"], note=5.0) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) # Note moyenne: utilise session 2 même si inférieure assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(5.0) - _, _, _ = G.create_note( - evaluation_id=e_session2["id"], etudid=etud["etudid"], note=20.0 - ) + _ = G.create_note(evaluation_id=e_session2["id"], etudid=etud["etudid"], note=20.0) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) # Note moyenne: utilise session 2 même si inférieure assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(20.0) - _, _, _ = G.create_note( - evaluation_id=e_session2["id"], etudid=etud["etudid"], note=None + # Met la note session2 à ABS (None) + _ = G.create_note(evaluation_id=e_session2["id"], etudid=etud["etudid"], note=None) + b = sco_bulletins.formsemestre_bulletinetud_dict( + sem["formsemestre_id"], etud["etudid"] + ) + # Note moyenne: zéro car ABS + assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(0.0) + # Supprime note session 2 + _ = G.create_note( + evaluation_id=e_session2["id"], etudid=etud["etudid"], note=scu.NOTES_SUPPRESS ) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) - # Note moyenne: revient à note normale + # Note moyenne: revient à sa valeur initiale, 10/20 assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(10.0) # Supprime évaluation session 2 - _, _, _ = G.create_note( - evaluation_id=e_session2["id"], etudid=etud["etudid"], note=scu.NOTES_SUPPRESS - ) evaluation = db.session.get(Evaluation, e_session2["id"]) assert evaluation evaluation.delete() @@ -207,18 +208,14 @@ def test_notes_rattrapage(test_client): # Note moyenne sans bonus assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(10.0) # Saisie note bonus - _, _, _ = G.create_note( - evaluation_id=e_bonus["id"], etudid=etud["etudid"], note=1.0 - ) + _ = G.create_note(evaluation_id=e_bonus["id"], etudid=etud["etudid"], note=1.0) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) # Note moyenne sans bonus assert b["ues"][0]["modules"][0]["mod_moy_txt"] == scu.fmt_note(11.0) # Négatif, avec clip à zéro - _, _, _ = G.create_note( - evaluation_id=e_bonus["id"], etudid=etud["etudid"], note=-20.0 - ) + _ = G.create_note(evaluation_id=e_bonus["id"], etudid=etud["etudid"], note=-20.0) b = sco_bulletins.formsemestre_bulletinetud_dict( sem["formsemestre_id"], etud["etudid"] ) diff --git a/tests/unit/test_sco_basic.py b/tests/unit/test_sco_basic.py index b3bba2e20..e159391c4 100644 --- a/tests/unit/test_sco_basic.py +++ b/tests/unit/test_sco_basic.py @@ -105,7 +105,7 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: # --- Saisie toutes les notes de l'évaluation for idx, etud in enumerate(etuds): - etudids_changed, nb_suppress, existing_decisions = G.create_note( + etudids_changed, nb_suppress, existing_decisions, messages = G.create_note( evaluation_id=e1.id, etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], @@ -113,6 +113,7 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: assert not existing_decisions assert nb_suppress == 0 assert len(etudids_changed) == 1 + assert messages == [] # --- Vérifie que les notes sont prises en compte: b = sco_bulletins.formsemestre_bulletinetud_dict(formsemestre_id, etud["etudid"]) @@ -139,11 +140,12 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: db.session.commit() # Saisie les notes des 5 premiers étudiants: for idx, etud in enumerate(etuds[:5]): - etudids_changed, nb_suppress, existing_decisions = G.create_note( + etudids_changed, nb_suppress, existing_decisions, messages = G.create_note( evaluation_id=e2.id, etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], ) + assert messages == [] # Cette éval n'est pas complète etat = sco_evaluations.do_evaluation_etat(e2.id) assert etat["evalcomplete"] is False @@ -162,11 +164,12 @@ def run_sco_basic(verbose=False, dept=None) -> FormSemestre: # Saisie des notes qui manquent: for idx, etud in enumerate(etuds[5:]): - etudids_changed, nb_suppress, existing_decisions = G.create_note( + etudids_changed, nb_suppress, existing_decisions, messages = G.create_note( evaluation_id=e2.id, etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], ) + assert messages == [] etat = sco_evaluations.do_evaluation_etat(e2.id) assert etat["evalcomplete"] assert etat["nb_att"] == 0 diff --git a/tools/__init__.py b/tools/__init__.py index da9214bfa..c7c13e6bd 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -10,3 +10,4 @@ from tools.migrate_scodoc7_archives import migrate_scodoc7_dept_archives from tools.migrate_scodoc7_logos import migrate_scodoc7_dept_logos from tools.migrate_abs_to_assiduites import migrate_abs_to_assiduites from tools.downgrade_assiduites import downgrade_module +from tools.create_api_map import gen_api_map diff --git a/tools/anonymize_db.py b/tools/anonymize_db.py index 494c61a00..2c416038a 100755 --- a/tools/anonymize_db.py +++ b/tools/anonymize_db.py @@ -185,6 +185,7 @@ def anonymize_users(cursor): ) # Change les username: utilisés en référence externe # dans diverses tables: + # NB: la méthode User.change_user_name fait la même chose for table, field in ( ("etud_annotations", "author"), ("scolog", "authenticated_user"), diff --git a/tools/create_api_map.py b/tools/create_api_map.py new file mode 100644 index 000000000..953f384a6 --- /dev/null +++ b/tools/create_api_map.py @@ -0,0 +1,691 @@ +""" +Script permettant de générer une carte SVG de l'API de ScoDoc + +Écrit par Matthias HARTMANN +""" + +import xml.etree.ElementTree as ET +import re + + +class COLORS: + """ + Couleurs utilisées pour les éléments de la carte + """ + + BLUE = "rgb(114,159,207)" # Couleur de base / élément simple + GREEN = "rgb(165,214,165)" # Couleur route GET / valeur query + PINK = "rgb(230,156,190)" # Couleur route POST + GREY = "rgb(224,224,224)" # Couleur séparateur + + +class Token: + """ + Classe permettant de représenter un élément de l'API + Exemple : + + /ScoDoc/api/test + + Token(ScoDoc)-> Token(api) -> Token(test) + + Chaque token peut avoir des enfants (Token) et des paramètres de query + Chaque token dispose + d'un nom (texte écrit dans le rectangle), + d'une méthode (GET ou POST) par défaut GET, + d'une func_name (nom de la fonction associée à ce token) + [OPTIONNEL] d'une query (dictionnaire {param: }) + + Un token est une leaf si il n'a pas d'enfants. + Une LEAF possède un `?` renvoyant vers la doc de la route + + Il est possible de forcer un token à être une pseudo LEAF en mettant force_leaf=True + Une PSEUDO LEAF possède aussi un `?` renvoyant vers la doc de la route + tout en ayant des enfants. + """ + + def __init__(self, name, method="GET", query=None, leaf=False): + self.children: list["Token"] = [] + self.name: str = name + self.method: str = method + self.query: dict[str, str] = query or {} + self.force_leaf: bool = leaf + self.func_name = "" + + def add_child(self, child): + """ + Ajoute un enfant à ce token + """ + self.children.append(child) + + def find_child(self, name): + """ + Renvoie l'enfant portant le nom `name` ou None si aucun enfant ne correspond + """ + for child in self.children: + if child.name == name: + return child + return None + + def __repr__(self, level=0): + """ + représentation textuelle simplifiée de l'arbre + (ne prend pas en compte les query, les méthodes, les func_name, ...) + """ + ret = "\t" * level + f"({self.name})\n" + for child in self.children: + ret += child.__repr__(level + 1) + return ret + + def is_leaf(self): + """ + Renvoie True si le token est une leaf, False sinon + (i.e. s'il n'a pas d'enfants) + (force_leaf n'est pas pris en compte ici) + """ + return len(self.children) == 0 + + def get_height(self, y_step): + """ + Renvoie la hauteur de l'élément en prenant en compte la hauteur de ses enfants + """ + # Calculer la hauteur totale des enfants + children_height = sum(child.get_height(y_step) for child in self.children) + + # Calculer la hauteur des éléments de la query + query_height = len(self.query) * (y_step * 1.33) + + # La hauteur totale est la somme de la hauteur des enfants et des éléments de la query + height = children_height + query_height + if height == 0: + height = y_step + return height + + def to_svg_group( + self, + x_offset: int = 0, + y_offset: int = 0, + x_step: int = 150, + y_step: int = 50, + parent_coords: tuple[tuple[int, int], tuple[int, int]] = None, + parent_children_nb: int = 0, + ): + """ + Transforme un token en un groupe SVG + (récursif, appelle la fonction sur ses enfants) + """ + + group = ET.Element("g") # groupe principal + color = COLORS.BLUE + if self.is_leaf(): + if self.method == "GET": + color = COLORS.GREEN + elif self.method == "POST": + color = COLORS.PINK + + # Création du rectangle avec le nom du token et placement sur la carte + element = _create_svg_element(self.name, color) + element.set("transform", f"translate({x_offset}, {y_offset})") + group.append(element) + + # On récupère les coordonnées de début et de fin de l'élément pour les flèches + current_start_coords, current_end_coords = _get_anchor_coords( + element, x_offset, y_offset + ) + # Préparation du lien vers la doc de la route + href = "#" + self.func_name.replace("_", "-") + if self.query: + href += "-query" + question_mark_group = _create_question_mark_group(current_end_coords, href) + + # Ajout de la flèche partant du parent jusqu'à l'élément courant + if parent_coords and parent_children_nb > 1: + arrow = _create_arrow(parent_coords, current_start_coords) + group.append(arrow) + + # Ajout du `/` si le token n'est pas une leaf (ne prend pas en compte force_leaf) + if not self.is_leaf(): + slash_group = _create_svg_element("/", COLORS.GREY) + slash_group.set( + "transform", + f"translate({x_offset + _get_element_width(element)}, {y_offset})", + ) + group.append(slash_group) + # Ajout du `?` si le token est une leaf et possède une query + if self.is_leaf() and self.query: + slash_group = _create_svg_element("?", COLORS.GREY) + slash_group.set( + "transform", + f"translate({x_offset + _get_element_width(element)}, {y_offset})", + ) + group.append(slash_group) + + # Actualisation des coordonnées de fin + current_end_coords = _get_anchor_coords(group, 0, 0)[1] + + # Gestion des éléments de la query + # Pour chaque élément on va créer : + # (param) (=) (valeur) (&) + query_y_offset = y_offset + query_sub_element = ET.Element("g") + for key, value in self.query.items(): + # Création d'un sous-groupe pour chaque élément de la query + sub_group = ET.Element("g") + + # On décale l'élément de la query vers la droite par rapport à l'élément parent + translate_x = x_offset + x_step + + # création élément (param) + param_el = _create_svg_element(key, COLORS.BLUE) + param_el.set( + "transform", + f"translate({translate_x}, {query_y_offset})", + ) + sub_group.append(param_el) + + # Ajout d'une flèche partant de l'élément "query" vers le paramètre courant + coords = ( + current_end_coords, + _get_anchor_coords(param_el, translate_x, query_y_offset)[0], + ) + sub_group.append(_create_arrow(*coords)) + + # création élément (=) + equal_el = _create_svg_element("=", COLORS.GREY) + # On met à jour le décalage en fonction de l'élément précédent + translate_x = x_offset + x_step + _get_element_width(param_el) + equal_el.set( + "transform", + f"translate({translate_x}, {query_y_offset})", + ) + sub_group.append(equal_el) + + # création élément (value) + value_el = _create_svg_element(value, COLORS.GREEN) + # On met à jour le décalage en fonction des éléments précédents + translate_x = ( + x_offset + + x_step + + sum(_get_element_width(el) for el in [param_el, equal_el]) + ) + value_el.set( + "transform", + f"translate({translate_x}, {query_y_offset})", + ) + sub_group.append(value_el) + # Si il y a qu'un seul élément dans la query, on ne met pas de `&` + if len(self.query) == 1: + continue + + # création élément (&) + ampersand_group = _create_svg_element("&", "rgb(224,224,224)") + # On met à jour le décalage en fonction des éléments précédents + translate_x = ( + x_offset + + x_step + + sum(_get_element_width(el) for el in [param_el, equal_el, value_el]) + ) + ampersand_group.set( + "transform", + f"translate({translate_x}, {query_y_offset})", + ) + sub_group.append(ampersand_group) + + # On décale le prochain élément de la query vers le bas + query_y_offset += y_step * 1.33 + # On ajoute le sous-groupe (param = value &) au groupe de la query + query_sub_element.append(sub_group) + + # On ajoute le groupe de la query à l'élément principal + group.append(query_sub_element) + + # Gestion des enfants du Token + + # On met à jour les décalages en fonction des éléments précédents + y_offset = query_y_offset + current_y_offset = y_offset + + # Pour chaque enfant, on crée un groupe SVG de façon récursive + for child in self.children: + # On décale l'enfant vers la droite par rapport à l'élément parent + # Si il n'y a qu'un enfant, alors on colle l'enfant à l'élément parent + rel_x_offset = x_offset + _get_group_width(group) + if len(self.children) > 1: + rel_x_offset += x_step + + # On crée le groupe SVG de l'enfant + child_group = child.to_svg_group( + rel_x_offset, + current_y_offset, + x_step, + y_step, + parent_coords=current_end_coords, + parent_children_nb=len(self.children), + ) + # On ajoute le groupe de l'enfant au groupe principal + group.append(child_group) + # On met à jour le décalage Y en fonction de la hauteur de l'enfant + current_y_offset += child.get_height(y_step) + + # Ajout du `?` si le token est une pseudo leaf ou une leaf + if self.force_leaf or self.is_leaf(): + group.append(question_mark_group) + + return group + + +def _create_svg_element(text, color="rgb(230,156,190)"): + """ + Fonction générale pour créer un élément SVG simple + (rectangle avec du texte à l'intérieur) + + text : texte à afficher dans l'élément + color : couleur de l'élément + """ + + # Paramètres de style de l'élément + padding = 5 + font_size = 16 + rect_height = 30 + rect_x = 10 + rect_y = 20 + + # Estimation de la largeur du texte + text_width = ( + len(text) * font_size * 0.6 + ) # On suppose que la largeur d'un caractère est 0.6 * font_size + # Largeur du rectangle = Largeur du texte + padding à gauche et à droite + rect_width = text_width + padding * 2 + + # Création du groupe SVG + group = ET.Element("g") + + # Création du rectangle + ET.SubElement( + group, + "rect", + { + "x": str(rect_x), + "y": str(rect_y), + "width": str(rect_width), + "height": str(rect_height), + "style": f"fill:{color};stroke:black;stroke-width:2;fill-opacity:1;stroke-opacity:1", + }, + ) + + # Création du texte + text_element = ET.SubElement( + group, + "text", + { + "x": str(rect_x + padding), + "y": str( + rect_y + rect_height / 2 + font_size / 2.5 + ), # Ajustement pour centrer verticalement + "font-family": "Courier New, monospace", + "font-size": str(font_size), + "fill": "black", + "style": "white-space: pre;", + }, + ) + + # Ajout du texte à l'élément + text_element.text = text + + return group + + +def _get_anchor_coords(element, x_offset, y_offset): + """ + Récupération des coordonnées des points d'ancrage d'un élément SVG + (début et fin de l'élément pour les flèches) + (le milieu vertical de l'élément est utilisé pour les flèches) + """ + bbox = _get_bbox(element, x_offset, y_offset) + startX = bbox["x_min"] + endX = bbox["x_max"] + # Milieu vertical de l'élément + y = bbox["y_min"] + (bbox["y_max"] - bbox["y_min"]) / 2 + return (startX, y), (endX, y) + + +def _create_arrow(start_coords, end_coords): + """ + Création d'une flèche entre deux points + """ + # On récupère les coordonnées de début et de fin de la flèche + start_x, start_y = start_coords + end_x, end_y = end_coords + # On calcule le milieu horizontal de la flèche + mid_x = (start_x + end_x) / 2 + + # On crée le chemin de la flèche + path_data = ( + f"M {start_x},{start_y} L {mid_x},{start_y} L {mid_x},{end_y} L {end_x},{end_y}" + ) + # On crée l'élément path de la flèche + path = ET.Element( + "path", + { + "d": path_data, + "style": "stroke:black;stroke-width:2;fill:none", + "marker-end": "url(#arrowhead)", # Ajout de la flèche à la fin du path + }, + ) + return path + + +def _get_element_width(element): + """ + Retourne la largueur d'un élément simple + L'élément simple correspond à un rectangle avec du texte à l'intérieur + on récupère la largueur du rectangle + """ + rect = element.find("rect") + if rect is not None: + return float(rect.get("width", 0)) + return 0 + + +def _get_group_width(group): + """ + Récupère la largeur d'un groupe d'éléments + on fait la somme des largeurs de chaque élément du groupe + """ + return sum(_get_element_width(child) for child in group) + + +def _create_question_mark_group(coords, href): + """ + Création d'un groupe SVG contenant un cercle et un lien vers la doc de la route + le `?` renvoie vers la doc de la route + """ + # Récupération du point d'ancrage de l'élément + x, y = coords + radius = 10 # Rayon du cercle + y -= radius * 2 + font_size = 17 # Taille de la police + + group = ET.Element("g") + + # Création du cercle + ET.SubElement( + group, + "circle", + { + "cx": str(x), + "cy": str(y), + "r": str(radius), + "fill": COLORS.GREY, + "stroke": "black", + "stroke-width": "2", + }, + ) + + # Création du lien (a) vers la doc de la route + link = ET.Element("a", {"href": href}) + + # Création du texte `?` + text_element = ET.SubElement( + link, + "text", + { + "x": str(x + 1), + "y": str(y + font_size / 3), # Ajustement pour centrer verticalement + "text-anchor": "middle", # Centrage horizontal + "font-family": "Arial", + "font-size": str(font_size), + "fill": "black", + }, + ) + text_element.text = "?" + + group.append(link) + + return group + + +def gen_api_map(app, endpoint_start="api"): + """ + Fonction permettant de générer une carte SVG de l'API de ScoDoc + Elle récupère les routes de l'API et les transforme en un arbre de Token + puis génère un fichier SVG à partir de cet arbre + """ + # Création du token racine + api_map = Token("") + + # Parcours de toutes les routes de l'application + for rule in app.url_map.iter_rules(): + # On ne garde que les routes de l'API / APIWEB + if not rule.endpoint.lower().startswith(endpoint_start.lower()): + continue + + # Transformation de la route en segments + # ex : /ScoDoc/api/test -> ["ScoDoc", "api", "test"] + segments = rule.rule.strip("/").split("/") + + # On positionne le token courant sur le token racine + current_token = api_map + + # Pour chaque segment de la route + for i, segment in enumerate(segments): + # On cherche si le segment est déjà un enfant du token courant + child = current_token.find_child(segment) + + # Si ce n'est pas le cas on crée un nouveau token et on l'ajoute comme enfant + if child is None: + func = app.view_functions[rule.endpoint] + # Si c'est le dernier segment, on marque le token comme une leaf + # On utilise force_leaf car il est possible que le token ne soit que + # momentanément une leaf + # ex : + # - /ScoDoc/api/test/ -> ["ScoDoc", "api", "test"] + # - /ScoDoc/api/test/1 -> ["ScoDoc", "api", "test", "1"] + # dans le premier cas test est une leaf, dans le deuxième cas test n'est pas une leaf + # force_leaf permet de forcer le token à être une leaf même s'il a des enfants + # permettant d'afficher le `?` renvoyant vers la doc de la route + # car la route peut être utilisée sans forcément la continuer. + + if i == len(segments) - 1: + # Un Token sera query si parse_query_doc retourne un dictionnaire non vide + child = Token( + segment, + leaf=True, + query=parse_query_doc(func.__doc__ or ""), + ) + else: + child = Token( + segment, + ) + + # On ajoute le token comme enfant du token courant + # en donnant la méthode et le nom de la fonction associée + child.func_name = func.__name__ + method: str = "POST" if "POST" in rule.methods else "GET" + child.method = method + current_token.add_child(child) + + # On met à jour le token courant pour le prochain segment + current_token = child + + # On génère le SVG à partir de l'arbre de Token + generate_svg(api_map.to_svg_group(), "/tmp/api_map.svg") + print( + "La carte a été générée avec succès. " + + "Vous pouvez la consulter à l'adresse suivante : /tmp/api_map.svg" + ) + + +def _get_bbox(element, x_offset=0, y_offset=0): + """ + Récupérer les coordonnées de la boîte englobante d'un élément SVG + Utilisé pour calculer les coordonnées d'un élément SVG et pour avoir la taille + total du SVG + """ + # Initialisation des coordonnées de la boîte englobante + bbox = { + "x_min": float("inf"), + "y_min": float("inf"), + "x_max": float("-inf"), + "y_max": float("-inf"), + } + + # Parcours récursif des enfants de l'élément + for child in element: + # On récupère la transformation (position) de l'enfant + # On met les OffSet par défaut à leur valeur donnée en paramètre + transform = child.get("transform") + child_x_offset = x_offset + child_y_offset = y_offset + + # Si la transformation est définie, on récupère les coordonnées de translation + # et on les ajoute aux offsets + if transform: + translate = transform.replace("translate(", "").replace(")", "").split(",") + if len(translate) == 2: + child_x_offset += float(translate[0]) + child_y_offset += float(translate[1]) + + # On regarde ensuite la boite englobante de l'enfant + # On met à jour les coordonnées de la boîte englobante en fonction de l'enfant + # x_min, y_min, x_max, y_max. + + if child.tag == "rect": + x = child_x_offset + float(child.get("x", 0)) + y = child_y_offset + float(child.get("y", 0)) + width = float(child.get("width", 0)) + height = float(child.get("height", 0)) + bbox["x_min"] = min(bbox["x_min"], x) + bbox["y_min"] = min(bbox["y_min"], y) + bbox["x_max"] = max(bbox["x_max"], x + width) + bbox["y_max"] = max(bbox["y_max"], y + height) + + if len(child): + child_bbox = _get_bbox(child, child_x_offset, child_y_offset) + bbox["x_min"] = min(bbox["x_min"], child_bbox["x_min"]) + bbox["y_min"] = min(bbox["y_min"], child_bbox["y_min"]) + bbox["x_max"] = max(bbox["x_max"], child_bbox["x_max"]) + bbox["y_max"] = max(bbox["y_max"], child_bbox["y_max"]) + + return bbox + + +def generate_svg(element, fname): + """ + Génère un fichier SVG à partir d'un élément SVG + """ + # On récupère les dimensions de l'élément racine + bbox = _get_bbox(element) + # On définit la taille du SVG en fonction des dimensions de l'élément racine + width = bbox["x_max"] - bbox["x_min"] + 80 + height = bbox["y_max"] - bbox["y_min"] + 80 + + # Création de l'élément racine du SVG + svg = ET.Element( + "svg", + { + "width": str(width), + "height": str(height), + "xmlns": "http://www.w3.org/2000/svg", + "viewBox": f"{bbox['x_min'] - 10} {bbox['y_min'] - 10} {width} {height}", + }, + ) + + # Création du motif de la flèche pour les liens + # (définition d'un marqueur pour les flèches) + defs = ET.SubElement(svg, "defs") + marker = ET.SubElement( + defs, + "marker", + { + "id": "arrowhead", + "markerWidth": "10", + "markerHeight": "7", + "refX": "10", + "refY": "3.5", + "orient": "auto", + }, + ) + ET.SubElement(marker, "polygon", {"points": "0 0, 10 3.5, 0 7"}) + + # Ajout de l'élément principal à l'élément racine + svg.append(element) + + # Écriture du fichier SVG + tree = ET.ElementTree(svg) + tree.write(fname, encoding="utf-8", xml_declaration=True) + + +def parse_query_doc(doc): + """ + renvoie un dictionnaire {param: } (ex: {assiduite_id : }) + + La doc doit contenir des lignes de la forme: + + QUERY + ----- + param: + param1: + param2: + + Dès qu'une ligne ne respecte pas ce format (voir regex dans la fonction), on arrête de parser + Attention, la ligne ----- doit être collée contre QUERY et contre le premier paramètre + """ + # Récupérer les lignes de la doc + lines = [line.strip() for line in doc.split("\n")] + # On cherche la ligne "QUERY" et on vérifie que la ligne suivante est "-----" + # Si ce n'est pas le cas, on renvoie un dictionnaire vide + try: + query_index = lines.index("QUERY") + if lines[query_index + 1] != "-----": + return {} + except ValueError: + return {} + # On récupère les lignes de la doc qui correspondent à la query (enfin on espère) + query_lines = lines[query_index + 2 :] + + query = {} + regex = re.compile(r"^(\w+):(<.+>)$") + for line in query_lines: + # On verifie que la ligne respecte le format attendu + # Si ce n'est pas le cas, on arrête de parser + parts = regex.match(line) + if not parts: + break + # On récupère le paramètre et son type:nom + param, type_nom_param = parts.groups() + # On ajoute le paramètre au dictionnaire + query[param] = type_nom_param + + return query + + +if __name__ == "__main__": + # Exemple d'utilisation de la classe Token + # Exemple simple de création d'un arbre de Token + + root = Token("api") + child1 = Token("assiduites", leaf=True) + child1.func_name = "assiduites_get" + child2 = Token("count") + child22 = Token("all") + child23 = Token( + "query", + query={ + "etat": "", + "moduleimpl_id": "", + "count": "", + "formsemestre_id": "", + }, + ) + child3 = Token("justificatifs", "POST") + child3.func_name = "justificatifs_post" + + root.add_child(child1) + child1.add_child(child2) + child2.add_child(child22) + child2.add_child(child23) + root.add_child(child3) + + group_element = root.to_svg_group() + + generate_svg(group_element, "/tmp/api_map.svg") diff --git a/tools/fakeportal/fakeportal.py b/tools/fakeportal/fakeportal.py index 785f5856a..7e9abe7e2 100755 --- a/tools/fakeportal/fakeportal.py +++ b/tools/fakeportal/fakeportal.py @@ -28,7 +28,20 @@ script_dir = Path(os.path.abspath(__file__)).parent os.chdir(script_dir) # Les "photos" des étudiants -FAKE_FACES_PATHS = list((Path("faces").glob("*.jpg"))) +if os.path.exists("/opt/ExtraFaces"): + FAKE_FACES_PATHS = list((Path("extra_faces").glob("*/*.jpg"))) + FAKE_FACES_PATHS_BY_CIVILITE = { + "M": list((Path("extra_faces").glob("M/*.jpg"))), + "F": list((Path("extra_faces").glob("F/*.jpg"))), + "X": list((Path("extra_faces").glob("X/*.jpg"))), + } +else: + FAKE_FACES_PATHS = list((Path("faces").glob("*.jpg"))) + FAKE_FACES_PATHS_BY_CIVILITE = { + "M": FAKE_FACES_PATHS, + "F": FAKE_FACES_PATHS, + "X": FAKE_FACES_PATHS, + } # Etudiant avec tous les champs (USPN) ETUD_TEMPLATE_FULL = open(script_dir / "etud_template.xml").read() @@ -84,16 +97,22 @@ def make_random_etape_etuds(etape, annee): return "\n".join(L) -def get_photo_filename(nip: str) -> str: +def get_photo_filename(nip: str, civilite: str | None = None) -> str: """get an existing filename for a fake photo, found in faces/ Returns a path relative to the current working dir + If civilite is not None, use it to select a subdir """ - # - nb_faces = len(FAKE_FACES_PATHS) + print("get_photo_filename") + if civilite: + faces = FAKE_FACES_PATHS_BY_CIVILITE[civilite] + else: + faces = FAKE_FACES_PATHS + nb_faces = len(faces) if nb_faces == 0: print("WARNING: aucun fichier image disponible !") return "" - return FAKE_FACES_PATHS[hash(nip) % nb_faces] + print(faces[hash(nip) % nb_faces]) + return faces[hash(nip) % nb_faces] class MyHttpRequestHandler(http.server.SimpleHTTPRequestHandler): @@ -139,7 +158,9 @@ class MyHttpRequestHandler(http.server.SimpleHTTPRequestHandler): return elif ("getPhoto" in self.path) or ("scodocPhoto" in self.path): nip = query_components["nip"][0] - self.path = str(get_photo_filename(nip)) + civilite = query_components.get("civilite") + civilite = civilite[0] if civilite else None + self.path = str(get_photo_filename(nip, civilite=civilite)) print(f"photo for nip={nip}: {self.path}") else: print(f"Error 404: path={self.path}")