diff --git a/app/api/etudiants.py b/app/api/etudiants.py index 07df3d593..e8030b019 100644 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -8,16 +8,17 @@ API : accès aux étudiants """ from datetime import datetime +from operator import attrgetter from flask import g, request from flask_json import as_json from flask_login import current_user from flask_login import login_required -from sqlalchemy import desc, or_ +from sqlalchemy import desc, func, or_ +from sqlalchemy.dialects.postgresql import VARCHAR import app from app.api import api_bp as bp, api_web_bp -from app.scodoc.sco_utils import json_error from app.api import tools from app.decorators import scodoc, permission_required from app.models import ( @@ -31,6 +32,8 @@ from app.scodoc import sco_bulletins from app.scodoc import sco_groups from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_utils import json_error, suppress_accents + # Un exemple: # @bp.route("/api_function/") @@ -164,12 +167,39 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None): ) if not None in allowed_depts: # restreint aux départements autorisés: - etuds = etuds.join(Departement).filter( + query = query.join(Departement).filter( or_(Departement.acronym == acronym for acronym in allowed_depts) ) return [etud.to_dict_api() for etud in query] +@bp.route("/etudiants/name/") +@api_web_bp.route("/etudiants/name/") +@scodoc +@permission_required(Permission.ScoView) +@as_json +def etudiants_by_name(start: str = "", min_len=3, limit=32): + """Liste des étudiants dont le nom débute par start. + Si start fait moins de min_len=3 caractères, liste vide. + La casse et les accents sont ignorés. + """ + if len(start) < min_len: + return [] + start = suppress_accents(start).lower() + query = Identite.query.filter( + func.lower(func.unaccent(Identite.nom, type_=VARCHAR)).ilike(start + "%") + ) + allowed_depts = current_user.get_depts_with_permission(Permission.ScoView) + if not None in allowed_depts: + # restreint aux départements autorisés: + query = query.join(Departement).filter( + or_(Departement.acronym == acronym for acronym in allowed_depts) + ) + etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit) + # Note: on raffine le tri pour les caractères spéciaux et nom usuel ici: + return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))] + + @bp.route("/etudiant/etudid//formsemestres") @bp.route("/etudiant/nip//formsemestres") @bp.route("/etudiant/ine//formsemestres") diff --git a/app/api/evaluations.py b/app/api/evaluations.py index d5a7a3cfc..af1c40af3 100644 --- a/app/api/evaluations.py +++ b/app/api/evaluations.py @@ -8,7 +8,7 @@ ScoDoc 9 API : accès aux évaluations """ -from flask import g +from flask import g, request from flask_json import as_json from flask_login import login_required @@ -17,7 +17,7 @@ import app from app.api import api_bp as bp, api_web_bp from app.decorators import scodoc, permission_required from app.models import Evaluation, ModuleImpl, FormSemestre -from app.scodoc import sco_evaluation_db +from app.scodoc import sco_evaluation_db, sco_saisie_notes from app.scodoc.sco_permissions import Permission import app.scodoc.sco_utils as scu @@ -28,7 +28,7 @@ import app.scodoc.sco_utils as scu @scodoc @permission_required(Permission.ScoView) @as_json -def the_eval(evaluation_id: int): +def evaluation(evaluation_id: int): """Description d'une évaluation. { @@ -93,24 +93,22 @@ def evaluations(moduleimpl_id: int): @as_json def evaluation_notes(evaluation_id: int): """ - Retourne la liste des notes à partir de l'id d'une évaluation donnée + Retourne la liste des notes de l'évaluation - evaluation_id : l'id d'une évaluation + evaluation_id : l'id de l'évaluation Exemple de résultat : { - "1": { - "id": 1, - "etudid": 10, + "11": { + "etudid": 11, "evaluation_id": 1, "value": 15.0, "comment": "", "date": "Wed, 20 Apr 2022 06:49:05 GMT", "uid": 2 }, - "2": { - "id": 2, - "etudid": 1, + "12": { + "etudid": 12, "evaluation_id": 1, "value": 12.0, "comment": "", @@ -128,8 +126,8 @@ def evaluation_notes(evaluation_id: int): .filter_by(dept_id=g.scodoc_dept_id) ) - the_eval = query.first_or_404() - dept = the_eval.moduleimpl.formsemestre.departement + evaluation = query.first_or_404() + dept = evaluation.moduleimpl.formsemestre.departement app.set_sco_dept(dept.acronym) notes = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) @@ -137,7 +135,49 @@ def evaluation_notes(evaluation_id: int): # "ABS", "EXC", etc mais laisse les notes sur le barème de l'éval. note = notes[etudid] note["value"] = scu.fmt_note(note["value"], keep_numeric=True) - note["note_max"] = the_eval.note_max + note["note_max"] = evaluation.note_max del note["id"] - return notes + # in JS, keys must be string, not integers + return {str(etudid): note for etudid, note in notes.items()} + + +@bp.route("/evaluation//notes/set", methods=["POST"]) +@api_web_bp.route("/evaluation//notes/set", methods=["POST"]) +@login_required +@scodoc +@permission_required(Permission.ScoEnsView) +@as_json +def evaluation_set_notes(evaluation_id: int): + """Écriture de notes dans une évaluation. + The request content type should be "application/json", + and contains: + { + 'notes' : [ [etudid, value], ... ], + 'comment' : optional string + } + Result: + - nb_changed: nombre de notes changées + - nb_suppress: nombre de notes effacées + - etudids_with_decision: liste des etudiants dont la note a changé + alors qu'ils ont une décision de jury enregistrée. + """ + query = Evaluation.query.filter_by(id=evaluation_id) + if g.scodoc_dept: + query = ( + query.join(ModuleImpl) + .join(FormSemestre) + .filter_by(dept_id=g.scodoc_dept_id) + ) + evaluation = query.first_or_404() + dept = evaluation.moduleimpl.formsemestre.departement + app.set_sco_dept(dept.acronym) + data = request.get_json(force=True) # may raise 400 Bad Request + notes = data.get("notes") + if notes is None: + return scu.json_error(404, "no notes") + if not isinstance(notes, list): + return scu.json_error(404, "invalid notes argument (must be a list)") + return sco_saisie_notes.save_notes( + evaluation, notes, comment=data.get("comment", "") + ) diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index 64ba29799..c9c9348bd 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -18,7 +18,7 @@ import pandas as pd from flask import g -from app.scodoc.codes_cursus import UE_SPORT +from app.scodoc.codes_cursus import UE_SPORT, UE_STANDARD from app.scodoc.codes_cursus import CursusDUT, CursusDUTMono from app.scodoc.sco_utils import ModuleType @@ -740,6 +740,7 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif): name = "bonus_iut1grenoble_2017" displayed_name = "IUT de Grenoble 1" + # C'est un bonus "multiplicatif": on l'exprime en additif, # sur chaque moyenne d'UE m_0 # Augmenter de 5% correspond à multiplier par a=1.05 @@ -782,6 +783,7 @@ class BonusIUTRennes1(BonusSportAdditif): seuil_moy_gen = 10.0 proportion_point = 1 / 20.0 classic_use_bonus_ues = False + # S'applique aussi en classic, sur la moy. gen. def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus""" @@ -1336,6 +1338,7 @@ class BonusStNazaire(BonusSport): classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP amplitude = 0.01 / 4 # 4pt => 1% factor_max = 0.1 # 10% max + # Modifié 2022-11-29: calculer chaque bonus # (de 1 à 3 modules) séparément. def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): @@ -1533,6 +1536,63 @@ class BonusIUTV(BonusSportAdditif): # c'est le bonus par défaut: aucune méthode à surcharger +# Finalement inutile: un bonus direct est mieux adapté à leurs besoins. +# # class BonusMastersUSPNIG(BonusSportAdditif): +# """Calcul bonus modules optionnels (sport, culture), règle Masters de l'Institut Galilée (USPN) + +# Les étudiants peuvent suivre des enseignements optionnels +# de l'USPN (sports, musique, deuxième langue, culture, etc) dans une +# UE libre. Les points au-dessus de 10 sur 20 obtenus dans cette UE +# libre sont ajoutés au total des points obtenus pour les UE obligatoires +# du semestre concerné. +# """ + +# name = "bonus_masters__uspn_ig" +# displayed_name = "Masters de l'Institut Galilée (USPN)" +# proportion_point = 1.0 +# seuil_moy_gen = 10.0 + +# def __init__( +# self, +# formsemestre: "FormSemestre", +# sem_modimpl_moys: np.array, +# ues: list, +# modimpl_inscr_df: pd.DataFrame, +# modimpl_coefs: np.array, +# etud_moy_gen, +# etud_moy_ue, +# ): +# # Pour ce bonus, il nous faut la somme des coefs des modules non bonus +# # du formsemestre (et non auxquels les étudiants sont inscrits !) +# self.sum_coefs = sum( +# [ +# m.module.coefficient +# for m in formsemestre.modimpls_sorted +# if (m.module.module_type == ModuleType.STANDARD) +# and (m.module.ue.type == UE_STANDARD) +# ] +# ) +# super().__init__( +# formsemestre, +# sem_modimpl_moys, +# ues, +# modimpl_inscr_df, +# modimpl_coefs, +# etud_moy_gen, +# etud_moy_ue, +# ) +# # Bonus sur la moyenne générale seulement +# # On a dans bonus_moy_arr le bonus additif classique +# # Sa valeur sera appliquée comme moy_gen += bonus_moy_gen +# # or ici on veut +# # moy_gen = (somme des notes + bonus_moy_arr) / somme des coefs +# # moy_gen += bonus_moy_arr / somme des coefs + +# self.bonus_moy_gen = ( +# None if self.bonus_moy_gen is None else self.bonus_moy_gen / self.sum_coefs +# ) + + def get_bonus_class_dict(start=BonusSport, d=None): """Dictionnaire des classes de bonus (liste les sous-classes de BonusSport ayant un nom) diff --git a/app/comp/res_but.py b/app/comp/res_but.py index a7af95655..e4602327b 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -288,7 +288,7 @@ class ResultatsSemestreBUT(NotesTableCompat): if ref_comp is None: return set() if parcour_id is None: - ues_ids = {ue.id for ue in self.ues} + ues_ids = {ue.id for ue in self.ues if ue.type != UE_SPORT} else: parcour: ApcParcours = ApcParcours.query.get(parcour_id) annee = (self.formsemestre.semestre_id + 1) // 2 @@ -306,12 +306,12 @@ class ResultatsSemestreBUT(NotesTableCompat): return ues_ids - def etud_has_decision(self, etudid): + def etud_has_decision(self, etudid) -> bool: """True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre. prend aussi en compte les autorisations de passage. Sous-classée en BUT pour les RCUEs et années. """ - return ( + return bool( super().etud_has_decision(etudid) or ApcValidationAnnee.query.filter_by( formsemestre_id=self.formsemestre.id, etudid=etudid diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py index cbebb8356..d5e85d99d 100644 --- a/app/comp/res_compat.py +++ b/app/comp/res_compat.py @@ -283,12 +283,12 @@ class NotesTableCompat(ResultatsSemestre): ] return etudids - def etud_has_decision(self, etudid): + def etud_has_decision(self, etudid) -> bool: """True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre. prend aussi en compte les autorisations de passage. Sous-classée en BUT pour les RCUEs et années. """ - return ( + return bool( self.get_etud_decisions_ue(etudid) or self.get_etud_decision_sem(etudid) or ScolarAutorisationInscription.query.filter_by( diff --git a/app/entreprises/forms.py b/app/entreprises/forms.py index c2b8cdf31..5bf3e03b7 100644 --- a/app/entreprises/forms.py +++ b/app/entreprises/forms.py @@ -36,6 +36,7 @@ from sqlalchemy import text from wtforms import ( BooleanField, DateField, + DecimalField, FieldList, FormField, HiddenField, @@ -122,13 +123,13 @@ class EntrepriseCreationForm(FlaskForm): origine = _build_string_field("Origine du correspondant", required=False) notes = _build_string_field("Notes sur le correspondant", required=False) - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False if EntreprisePreferences.get_check_siret() and self.siret.data != "": siret_data = self.siret.data.strip().replace(" ", "") @@ -248,13 +249,13 @@ class SiteCreationForm(FlaskForm): codepostal = _build_string_field("Code postal (*)") ville = _build_string_field("Ville (*)") pays = _build_string_field("Pays", required=False) - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False site = EntrepriseSite.query.filter_by( entreprise_id=self.hidden_entreprise_id.data, nom=self.nom.data @@ -278,10 +279,10 @@ class SiteModificationForm(FlaskForm): submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False site = EntrepriseSite.query.filter( EntrepriseSite.entreprise_id == self.hidden_entreprise_id.data, @@ -326,7 +327,7 @@ class OffreCreationForm(FlaskForm): FileAllowed(["pdf", "docx"], "Fichier .pdf ou .docx uniquement"), ], ) - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) def __init__(self, *args, **kwargs): @@ -344,10 +345,10 @@ class OffreCreationForm(FlaskForm): (dept.id, dept.acronym) for dept in Departement.query.all() ] - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False if len(self.depts.data) < 1: self.depts.errors.append("Choisir au moins un département") @@ -392,10 +393,10 @@ class OffreModificationForm(FlaskForm): (dept.id, dept.acronym) for dept in Departement.query.all() ] - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False if len(self.depts.data) < 1: self.depts.errors.append("Choisir au moins un département") @@ -442,10 +443,10 @@ class CorrespondantCreationForm(FlaskForm): "Notes", required=False, render_kw={"class": "form-control"} ) - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False if not self.telephone.data and not self.mail.data: msg = "Saisir un moyen de contact (mail ou téléphone)" @@ -458,13 +459,13 @@ class CorrespondantCreationForm(FlaskForm): class CorrespondantsCreationForm(FlaskForm): hidden_site_id = HiddenField() correspondants = FieldList(FormField(CorrespondantCreationForm), min_entries=1) - submit = SubmitField("Envoyer") + submit = SubmitField("Enregistrer") cancel = SubmitField("Annuler") - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False correspondant_list = [] for entry in self.correspondants.entries: @@ -531,10 +532,10 @@ class CorrespondantModificationForm(FlaskForm): .all() ] - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False correspondant = EntrepriseCorrespondant.query.filter( EntrepriseCorrespondant.id != self.hidden_correspondant_id.data, @@ -566,7 +567,7 @@ class ContactCreationForm(FlaskForm): render_kw={"placeholder": "Tapez le nom de l'utilisateur"}, ) notes = TextAreaField("Notes (*)", validators=[DataRequired(message=CHAMP_REQUIS)]) - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) def validate_utilisateur(self, utilisateur): @@ -613,8 +614,9 @@ class ContactModificationForm(FlaskForm): class StageApprentissageCreationForm(FlaskForm): etudiant = _build_string_field( "Étudiant (*)", - render_kw={"placeholder": "Tapez le nom de l'étudiant"}, + render_kw={"placeholder": "Tapez le nom de l'étudiant", "autocomplete": "off"}, ) + etudid = HiddenField() type_offre = SelectField( "Type de l'offre (*)", choices=[("Stage"), ("Alternance")], @@ -627,12 +629,12 @@ class StageApprentissageCreationForm(FlaskForm): "Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)] ) notes = TextAreaField("Notes") - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): + if not super().validate(extra_validators): validate = False if ( @@ -646,64 +648,27 @@ class StageApprentissageCreationForm(FlaskForm): return validate - def validate_etudiant(self, etudiant): - etudiant_data = etudiant.data.upper().strip() - stm = text( - "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" - ) - etudiant = ( - Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first() - ) + def validate_etudid(self, field): + "L'etudid doit avoit été placé par le JS" + etudid = int(field.data) if field.data else None + etudiant = Identite.query.get(etudid) if etudid is not None else None if etudiant is None: - raise ValidationError("Champ incorrect (selectionnez dans la liste)") + raise ValidationError("Étudiant introuvable (sélectionnez dans la liste)") -class StageApprentissageModificationForm(FlaskForm): - etudiant = _build_string_field( - "Étudiant (*)", - render_kw={"placeholder": "Tapez le nom de l'étudiant"}, - ) - type_offre = SelectField( - "Type de l'offre (*)", - choices=[("Stage"), ("Alternance")], - validators=[DataRequired(message=CHAMP_REQUIS)], - ) - date_debut = DateField( - "Date début (*)", validators=[DataRequired(message=CHAMP_REQUIS)] - ) - date_fin = DateField( - "Date fin (*)", validators=[DataRequired(message=CHAMP_REQUIS)] - ) - notes = TextAreaField("Notes") - submit = SubmitField("Modifier", render_kw=SUBMIT_MARGE) - cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) +class FrenchFloatField(StringField): + "A field allowing to enter . or ," - def validate(self): - validate = True - if not FlaskForm.validate(self): - validate = False - - if ( - self.date_debut.data - and self.date_fin.data - and self.date_debut.data > self.date_fin.data - ): - self.date_debut.errors.append("Les dates sont incompatibles") - self.date_fin.errors.append("Les dates sont incompatibles") - validate = False - - return validate - - def validate_etudiant(self, etudiant): - etudiant_data = etudiant.data.upper().strip() - stm = text( - "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" - ) - etudiant = ( - Identite.query.from_statement(stm).params(nom_prenom=etudiant_data).first() - ) - if etudiant is None: - raise ValidationError("Champ incorrect (selectionnez dans la liste)") + def process_formdata(self, valuelist): + "catch incoming data" + if not valuelist: + return + try: + value = valuelist[0].replace(",", ".") + self.data = float(value) + except ValueError as exc: + self.data = None + raise ValueError(self.gettext("Not a valid decimal value.")) from exc class TaxeApprentissageForm(FlaskForm): @@ -720,25 +685,26 @@ class TaxeApprentissageForm(FlaskForm): ], default=int(datetime.now().strftime("%Y")), ) - montant = IntegerField( + montant = FrenchFloatField( "Montant (*)", validators=[ DataRequired(message=CHAMP_REQUIS), - NumberRange( - min=1, - message="Le montant doit être supérieur à 0", - ), + # NumberRange( + # min=0.1, + # max=1e8, + # message="Le montant doit être supérieur à 0", + # ), ], default=1, ) notes = TextAreaField("Notes") - submit = SubmitField("Envoyer", render_kw=SUBMIT_MARGE) + submit = SubmitField("Enregistrer", render_kw=SUBMIT_MARGE) cancel = SubmitField("Annuler", render_kw=SUBMIT_MARGE) - def validate(self): + def validate(self, extra_validators=None): validate = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False taxe = EntrepriseTaxeApprentissage.query.filter_by( entreprise_id=self.hidden_entreprise_id.data, annee=self.annee.data @@ -788,12 +754,12 @@ class EnvoiOffreForm(FlaskForm): submit = SubmitField("Envoyer") cancel = SubmitField("Annuler") - def validate(self): + def validate(self, extra_validators=None): validate = True list_select = True - if not FlaskForm.validate(self): - validate = False + if not super().validate(extra_validators): + return False for entry in self.responsables.entries: if entry.data: diff --git a/app/entreprises/models.py b/app/entreprises/models.py index 0ad22f25f..2dc825b82 100644 --- a/app/entreprises/models.py +++ b/app/entreprises/models.py @@ -164,7 +164,10 @@ class EntrepriseStageApprentissage(db.Model): entreprise_id = db.Column( db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") ) - etudid = db.Column(db.Integer) + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + ) type_offre = db.Column(db.Text) date_debut = db.Column(db.Date) date_fin = db.Column(db.Date) @@ -180,7 +183,7 @@ class EntrepriseTaxeApprentissage(db.Model): db.Integer, db.ForeignKey("are_entreprises.id", ondelete="cascade") ) annee = db.Column(db.Integer) - montant = db.Column(db.Integer) + montant = db.Column(db.Float) notes = db.Column(db.Text) diff --git a/app/entreprises/routes.py b/app/entreprises/routes.py index 7e9773700..f9eac4f9a 100644 --- a/app/entreprises/routes.py +++ b/app/entreprises/routes.py @@ -28,7 +28,6 @@ from app.entreprises.forms import ( ContactCreationForm, ContactModificationForm, StageApprentissageCreationForm, - StageApprentissageModificationForm, EnvoiOffreForm, AjoutFichierForm, TaxeApprentissageForm, @@ -239,7 +238,7 @@ def delete_validation_entreprise(entreprise_id): text=f"Non validation de la fiche entreprise ({entreprise.nom})", ) db.session.add(log) - flash("L'entreprise a été supprimé de la liste des entreprise à valider.") + flash("L'entreprise a été supprimée de la liste des entreprises à valider.") return redirect(url_for("entreprises.validation")) return render_template( "entreprises/form_confirmation.j2", @@ -770,7 +769,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id): ) db.session.add(log) db.session.commit() - flash("La taxe d'apprentissage a été supprimé de la liste.") + flash("La taxe d'apprentissage a été supprimée de la liste.") return redirect( url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id) ) @@ -966,7 +965,7 @@ def delete_offre(entreprise_id, offre_id): ) db.session.add(log) db.session.commit() - flash("L'offre a été supprimé de la fiche entreprise.") + flash("L'offre a été supprimée de la fiche entreprise.") return redirect( url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id) ) @@ -1473,7 +1472,8 @@ def delete_contact(entreprise_id, contact_id): @permission_required(Permission.RelationsEntreprisesChange) def add_stage_apprentissage(entreprise_id): """ - Permet d'ajouter un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise + Permet d'ajouter un étudiant ayant réalisé un stage ou alternance + sur la fiche de l'entreprise """ entreprise = Entreprise.query.filter_by( id=entreprise_id, visible=True @@ -1484,15 +1484,8 @@ def add_stage_apprentissage(entreprise_id): url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id) ) if form.validate_on_submit(): - etudiant_nomcomplet = form.etudiant.data.upper().strip() - stm = text( - "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" - ) - etudiant = ( - Identite.query.from_statement(stm) - .params(nom_prenom=etudiant_nomcomplet) - .first() - ) + etudid = form.etudid.data + etudiant = Identite.query.get_or_404(etudid) formation = etudiant.inscription_courante_date( form.date_debut.data, form.date_fin.data ) @@ -1538,7 +1531,7 @@ def add_stage_apprentissage(entreprise_id): @permission_required(Permission.RelationsEntreprisesChange) def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): """ - Permet de modifier un étudiant ayant réalisé un stage ou une alternance sur la fiche entreprise de l'entreprise + Permet de modifier un étudiant ayant réalisé un stage ou alternance sur la fiche de l'entreprise """ stage_apprentissage = EntrepriseStageApprentissage.query.filter_by( id=stage_apprentissage_id, entreprise_id=entreprise_id @@ -1548,21 +1541,14 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): etudiant = Identite.query.filter_by(id=stage_apprentissage.etudid).first_or_404( description=f"etudiant {stage_apprentissage.etudid} inconnue" ) - form = StageApprentissageModificationForm() + form = StageApprentissageCreationForm() if request.method == "POST" and form.cancel.data: return redirect( url_for("entreprises.fiche_entreprise", entreprise_id=entreprise_id) ) if form.validate_on_submit(): - etudiant_nomcomplet = form.etudiant.data.upper().strip() - stm = text( - "SELECT id, CONCAT(nom, ' ', prenom) as nom_prenom FROM Identite WHERE CONCAT(nom, ' ', prenom)=:nom_prenom" - ) - etudiant = ( - Identite.query.from_statement(stm) - .params(nom_prenom=etudiant_nomcomplet) - .first() - ) + etudid = form.etudid.data + etudiant = Identite.query.get_or_404(etudid) formation = etudiant.inscription_courante_date( form.date_debut.data, form.date_fin.data ) @@ -1577,6 +1563,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): formation.formsemestre.formsemestre_id if formation else None, ) stage_apprentissage.notes = form.notes.data.strip() + db.session.add(stage_apprentissage) log = EntrepriseHistorique( authenticated_user=current_user.user_name, entreprise_id=stage_apprentissage.entreprise_id, @@ -1593,7 +1580,9 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id): ) ) elif request.method == "GET": - form.etudiant.data = f"{sco_etud.format_nom(etudiant.nom)} {sco_etud.format_prenom(etudiant.prenom)}" + form.etudiant.data = f"""{sco_etud.format_nom(etudiant.nom)} { + sco_etud.format_prenom(etudiant.prenom)}""" + form.etudid.data = etudiant.id form.type_offre.data = stage_apprentissage.type_offre form.date_debut.data = stage_apprentissage.date_debut form.date_fin.data = stage_apprentissage.date_fin diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 0f5f59c25..8a6047097 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -43,8 +43,8 @@ class Identite(db.Model): "optionnel (si present, affiché à la place du nom)" civilite = db.Column(db.String(1), nullable=False) - # données d'état-civil. Si présent remplace les données d'usage dans les documents officiels (bulletins, PV) - # cf nomprenom_etat_civil() + # données d'état-civil. Si présent remplace les données d'usage dans les documents + # officiels (bulletins, PV): voir nomprenom_etat_civil() civilite_etat_civil = db.Column(db.String(1), nullable=False, server_default="X") prenom_etat_civil = db.Column(db.Text(), nullable=False, server_default="") @@ -220,7 +220,7 @@ class Identite(db.Model): } args_dict = {} for key, value in args.items(): - if hasattr(cls, key): + if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property): # compat scodoc7 (mauvaise idée de l'époque) if key in fs_empty_stored_as_nulls and value == "": value = None diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 3e3cd07cd..fd777b1c2 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -145,6 +145,18 @@ class Evaluation(db.Model): db.session.add(copy) return copy + def is_matin(self) -> bool: + "Evaluation ayant lieu le matin (faux si pas de date)" + heure_debut_dt = self.heure_debut or datetime.time(8, 00) + # 8:00 au cas ou pas d'heure (note externe?) + return bool(self.jour) and heure_debut_dt < datetime.time(12, 00) + + def is_apresmidi(self) -> bool: + "Evaluation ayant lieu l'après midi (faux si pas de date)" + heure_debut_dt = self.heure_debut or datetime.time(8, 00) + # 8:00 au cas ou pas d'heure (note externe?) + return bool(self.jour) and heure_debut_dt >= datetime.time(12, 00) + def set_default_poids(self) -> bool: """Initialize les poids bvers les UE à leurs valeurs par défaut C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon. diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 68399262b..24468c7fb 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -782,6 +782,8 @@ class FormSemestre(db.Model): Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS et leur nom est le code du parcours (eg "Cyber"). """ + if self.formation.referentiel_competence_id is None: + return # safety net partition = Partition.query.filter_by( formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS ).first() @@ -805,7 +807,10 @@ class FormSemestre(db.Model): query = ( ApcParcours.query.filter_by(code=group.group_name) .join(ApcReferentielCompetences) - .filter_by(dept_id=g.scodoc_dept_id) + .filter_by( + dept_id=g.scodoc_dept_id, + id=self.formation.referentiel_competence_id, + ) ) if query.count() != 1: log( diff --git a/app/models/modules.py b/app/models/modules.py index 3c04f097f..ed155fdd2 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -55,7 +55,7 @@ class Module(db.Model): secondary=parcours_modules, lazy="subquery", backref=db.backref("modules", lazy=True), - order_by="ApcParcours.numero", + order_by="ApcParcours.numero, ApcParcours.code", ) app_critiques = db.relationship( diff --git a/app/models/notes.py b/app/models/notes.py index 0f82e2865..04ceb1c0f 100644 --- a/app/models/notes.py +++ b/app/models/notes.py @@ -56,7 +56,7 @@ class NotesNotes(db.Model): "pour debug" from app.models.evaluations import Evaluation - return f"""<{self.__class__.__name__} {self.id} v={self.value} {self.date.isoformat() + return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} v={self.value} {self.date.isoformat() } {Evaluation.query.get(self.evaluation_id) if self.evaluation_id else "X" }>""" diff --git a/app/models/ues.py b/app/models/ues.py index 8bbdb99e5..383f20f8f 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -58,7 +58,10 @@ class UniteEns(db.Model): # Une UE appartient soit à tous les parcours (tronc commun), soit à un sous-ensemble parcours = db.relationship( - ApcParcours, secondary="ue_parcours", backref=db.backref("ues", lazy=True) + ApcParcours, + secondary="ue_parcours", + backref=db.backref("ues", lazy=True), + order_by="ApcParcours.numero, ApcParcours.code", ) # relations diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py index 17e2fe2a7..5e09bf96c 100644 --- a/app/scodoc/sco_archives.py +++ b/app/scodoc/sco_archives.py @@ -70,7 +70,7 @@ from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.models import Departement, FormSemestre from app.scodoc.TrivialFormulator import TrivialFormulator -from app.scodoc.sco_exceptions import ScoPermissionDenied +from app.scodoc.sco_exceptions import ScoException, ScoPermissionDenied from app.scodoc import html_sco_header from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_groups @@ -125,6 +125,12 @@ class BaseArchiver(object): if not os.path.isdir(obj_dir): log(f"creating directory {obj_dir}") os.mkdir(obj_dir) + except FileExistsError as exc: + raise ScoException( + f"""BaseArchiver error: obj_dir={obj_dir} exists={ + os.path.exists(obj_dir) + } isdir={os.path.isdir(obj_dir)}""" + ) from exc finally: scu.GSL.release() return obj_dir diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index 7cdb19a0b..7bbf12b5f 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -62,7 +62,9 @@ def format_etud_ident(etud): else: etud["prenom_etat_civil"] = "" etud["civilite_str"] = format_civilite(etud["civilite"]) - etud["civilite_etat_civil_str"] = format_civilite(etud["civilite_etat_civil"]) + etud["civilite_etat_civil_str"] = format_civilite( + etud.get("civilite_etat_civil", "X") + ) # Nom à afficher: if etud["nom_usuel"]: etud["nom_disp"] = etud["nom_usuel"] @@ -145,7 +147,7 @@ def format_civilite(civilite): def format_etat_civil(etud: dict): if etud["prenom_etat_civil"]: - civ = {"M": "M.", "F": "Mme", "X": ""}[etud["civilite_etat_civil"]] + civ = {"M": "M.", "F": "Mme", "X": ""}[etud.get("civilite_etat_civil", "X")] return f'{civ} {etud["prenom_etat_civil"]} {etud["nom"]}' else: return etud["nomprenom"] diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py index b99af2cc3..7cbc32331 100644 --- a/app/scodoc/sco_evaluation_db.py +++ b/app/scodoc/sco_evaluation_db.py @@ -252,12 +252,11 @@ def do_evaluation_delete(evaluation_id): def do_evaluation_get_all_notes( evaluation_id, table="notes_notes", filter_suppressed=True, by_uid=None ): - """Toutes les notes pour une evaluation: { etudid : { 'value' : value, 'date' : date ... }} + """Toutes les notes pour une évaluation: { etudid : { 'value' : value, 'date' : date ... }} Attention: inclut aussi les notes des étudiants qui ne sont plus inscrits au module. """ - do_cache = ( - filter_suppressed and table == "notes_notes" and (by_uid is None) - ) # pas de cache pour (rares) appels via undo_notes ou specifiant un enseignant + # pas de cache pour (rares) appels via undo_notes ou specifiant un enseignant + do_cache = filter_suppressed and table == "notes_notes" and (by_uid is None) if do_cache: r = sco_cache.EvaluationCache.get(evaluation_id) if r is not None: diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index 2f2c93b55..922951379 100644 --- a/app/scodoc/sco_excel.py +++ b/app/scodoc/sco_excel.py @@ -433,7 +433,7 @@ def excel_simple_table( return ws.generate() -def excel_feuille_saisie(e, titreannee, description, lines): +def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, lines): """Genere feuille excel pour saisie des notes. E: evaluation (dict) lines: liste de tuples @@ -512,18 +512,20 @@ def excel_feuille_saisie(e, titreannee, description, lines): # description evaluation ws.append_single_cell_row(scu.unescape_html(description), style_titres) ws.append_single_cell_row( - "Evaluation du %s (coef. %g)" % (e["jour"], e["coefficient"]), style + "Evaluation du %s (coef. %g)" + % (evaluation.jour or "sans date", evaluation.coefficient or 0.0), + style, ) # ligne blanche ws.append_blank_row() # code et titres colonnes ws.append_row( [ - ws.make_cell("!%s" % e["evaluation_id"], style_ro), + ws.make_cell("!%s" % evaluation.id, style_ro), ws.make_cell("Nom", style_titres), ws.make_cell("Prénom", style_titres), ws.make_cell("Groupe", style_titres), - ws.make_cell("Note sur %g" % e["note_max"], style_titres), + ws.make_cell("Note sur %g" % (evaluation.note_max or 0.0), style_titres), ws.make_cell("Remarque", style_titres), ] ) diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index d64bf4d9c..4961e8a21 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -1333,11 +1333,18 @@ Ceci n'est possible que si : cancelbutton="Annuler", ) if tf[0] == 0: - if formsemestre_has_decisions_or_compensations(formsemestre): + has_decisions, message = formsemestre_has_decisions_or_compensations( + formsemestre + ) + if has_decisions: H.append( - """

Ce semestre ne peut pas être supprimé ! - (il y a des décisions de jury ou des compensations par d'autres semestres) -

""" + f"""

Ce semestre ne peut pas être supprimé !

+

il y a des décisions de jury ou des compensations par d'autres semestres: +

+
    +
  • {message}
  • +
+ """ ) else: H.append(tf[1]) @@ -1372,32 +1379,46 @@ def formsemestre_delete2(formsemestre_id, dialog_confirmed=False): return flask.redirect(scu.ScoURL()) -def formsemestre_has_decisions_or_compensations(formsemestre: FormSemestre): +def formsemestre_has_decisions_or_compensations( + formsemestre: FormSemestre, +) -> tuple[bool, str]: """True if decision de jury (sem. UE, RCUE, année) émanant de ce semestre ou compensation de ce semestre par d'autres semestres ou autorisations de passage. """ # Validations de semestre ou d'UEs - if ScolarFormSemestreValidation.query.filter_by( + nb_validations = ScolarFormSemestreValidation.query.filter_by( formsemestre_id=formsemestre.id - ).count(): - return True - if ScolarFormSemestreValidation.query.filter_by( + ).count() + if nb_validations: + return True, f"{nb_validations} validations de semestre ou d'UE" + nb_validations = ScolarFormSemestreValidation.query.filter_by( compense_formsemestre_id=formsemestre.id - ).count(): - return True + ).count() + if nb_validations: + return True, f"{nb_validations} compensations utilisées dans d'autres semestres" # Autorisations d'inscription: - if ScolarAutorisationInscription.query.filter_by( + nb_validations = ScolarAutorisationInscription.query.filter_by( origin_formsemestre_id=formsemestre.id - ).count(): - return True + ).count() + if nb_validations: + return ( + True, + f"{nb_validations} autorisations d'inscriptions émanant de ce semestre", + ) # Validations d'années BUT - if ApcValidationAnnee.query.filter_by(formsemestre_id=formsemestre.id).count(): - return True + nb_validations = ApcValidationAnnee.query.filter_by( + formsemestre_id=formsemestre.id + ).count() + if nb_validations: + return True, f"{nb_validations} validations d'année BUT utilisant ce semestre" # Validations de RCUEs - if ApcValidationRCUE.query.filter_by(formsemestre_id=formsemestre.id).count(): - return True - return False + nb_validations = ApcValidationRCUE.query.filter_by( + formsemestre_id=formsemestre.id + ).count() + if nb_validations: + return True, f"{nb_validations} validations de RCUE utilisant ce semestre" + return False, "" def do_formsemestre_delete(formsemestre_id): diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index c12fa73a8..82744b8fb 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -175,9 +175,7 @@ def do_formsemestre_demission( ) db.session.add(event) db.session.commit() - sco_cache.invalidate_formsemestre( - formsemestre_id=formsemestre_id - ) # > démission ou défaillance + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) if etat_new == scu.DEMISSION: flash("Démission enregistrée") elif etat_new == scu.DEF: @@ -210,7 +208,7 @@ def do_formsemestre_desinscription(etudid, formsemestre_id): if nt.etud_has_decision(etudid): raise ScoValueError( - """désinscription impossible: l'étudiant {etud.nomprenom} a + f"""désinscription impossible: l'étudiant {etud.nomprenom} a une décision de jury (la supprimer avant si nécessaire)""" ) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 2a911ef53..c10322de5 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -600,6 +600,7 @@ def formsemestre_description_table( formsemestre: FormSemestre = FormSemestre.query.filter_by( id=formsemestre_id, dept_id=g.scodoc_dept_id ).first_or_404() + is_apc = formsemestre.formation.is_apc() nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id) parcours = codes_cursus.get_cursus_from_code(formsemestre.formation.type_parcours) @@ -613,7 +614,7 @@ def formsemestre_description_table( else: ues = formsemestre.get_ues() columns_ids += [f"ue_{ue.id}" for ue in ues] - if sco_preferences.get_preference("bul_show_ects", formsemestre_id): + if sco_preferences.get_preference("bul_show_ects", formsemestre_id) and not is_apc: columns_ids += ["ects"] columns_ids += ["Inscrits", "Responsable", "Enseignants"] if with_evals: @@ -640,6 +641,7 @@ def formsemestre_description_table( sum_coef = 0 sum_ects = 0 last_ue_id = None + formsemestre_parcours_ids = {p.id for p in formsemestre.parcours} for modimpl in formsemestre.modimpls_sorted: # Ligne UE avec ECTS: ue = modimpl.module.ue @@ -666,7 +668,7 @@ def formsemestre_description_table( ue_info[ f"_{k}_td_attrs" ] = f'style="background-color: {ue.color} !important;"' - if not formsemestre.formation.is_apc(): + if not is_apc: # n'affiche la ligne UE qu'en formation classique # car l'UE de rattachement n'a pas d'intérêt en BUT rows.append(ue_info) @@ -707,8 +709,17 @@ def formsemestre_description_table( for ue in ues: row[f"ue_{ue.id}"] = coef_dict.get(ue.id, 0.0) or "" if with_parcours: + # Intersection des parcours du module avec ceux du formsemestre row["parcours"] = ", ".join( - sorted([pa.code for pa in modimpl.module.parcours]) + [ + pa.code + for pa in ( + modimpl.module.parcours + if modimpl.module.parcours + else modimpl.formsemestre.parcours + ) + if pa.id in formsemestre_parcours_ids + ] ) rows.append(row) diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index c693c9228..03217dc7a 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -28,7 +28,7 @@ """Opérations d'inscriptions aux modules (interface pour gérer options ou parcours) """ import collections -from operator import itemgetter +from operator import attrgetter import flask from flask import url_for, g, request @@ -553,8 +553,11 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) -> >{etud.nomprenom}""" ) # Parcours: - group = partition_parcours.get_etud_group(etud.id) - parcours_name = group.group_name if group else "" + if partition_parcours: + group = partition_parcours.get_etud_group(etud.id) + parcours_name = group.group_name if group else "" + else: + parcours_name = "" H.append(f"""{parcours_name}""") # UEs: for ue in ues: @@ -668,7 +671,7 @@ def descr_inscrs_module(moduleimpl_id, set_all, partitions): gr.append((partition["partition_name"], grp)) # d = [] - for (partition_name, grp) in gr: + for partition_name, grp in gr: if grp: d.append("groupes de %s: %s" % (partition_name, ", ".join(grp))) r = [] @@ -680,25 +683,25 @@ def descr_inscrs_module(moduleimpl_id, set_all, partitions): return False, len(ins), " et ".join(r) -def _fmt_etud_set(ins, max_list_size=7): +def _fmt_etud_set(etudids, max_list_size=7) -> str: # max_list_size est le nombre max de noms d'etudiants listés # au delà, on indique juste le nombre, sans les noms. - if len(ins) > max_list_size: - return "%d étudiants" % len(ins) + if len(etudids) > max_list_size: + return f"{len(etudids)} étudiants" etuds = [] - for etudid in ins: - etuds.append(sco_etud.get_etud_info(etudid=etudid, filled=True)[0]) - etuds.sort(key=itemgetter("nom")) + for etudid in etudids: + etud = Identite.query.get(etudid) + if etud: + etuds.append(etud) + return ", ".join( [ - '%s' - % ( + f"""{etud.nomprenom}""" + for etud in sorted(etuds, key=attrgetter("sort_key")) ] ) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index b6a3592d6..9901dad36 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -337,17 +337,18 @@ def ficheEtud(etudid=None): if not sco_permissions_check.can_suppress_annotation(a["id"]): a["dellink"] = "" else: - a[ - "dellink" - ] = '%s' % ( - etudid, - a["id"], - scu.icontag( - "delete_img", - border="0", - alt="suppress", - title="Supprimer cette annotation", - ), + a["dellink"] = ( + '%s' + % ( + etudid, + a["id"], + scu.icontag( + "delete_img", + border="0", + alt="suppress", + title="Supprimer cette annotation", + ), + ) ) author = sco_users.user_info(a["author"]) alist.append( @@ -446,7 +447,7 @@ def ficheEtud(etudid=None): info[ "inscriptions_mkup" ] = f"""
-
Parcours
{info["liste_inscriptions"]} +
Cursus
{info["liste_inscriptions"]} {info["link_bul_pdf"]} {info["link_inscrire_ailleurs"]}
""" diff --git a/app/scodoc/sco_portal_apogee.py b/app/scodoc/sco_portal_apogee.py index 61164c616..1ec3386ea 100644 --- a/app/scodoc/sco_portal_apogee.py +++ b/app/scodoc/sco_portal_apogee.py @@ -489,6 +489,7 @@ def _normalize_apo_fields(infolist): recode les champs: paiementinscription (-> booleen), datefinalisationinscription (date) ajoute le champs 'paiementinscription_str' : 'ok', 'Non' ou '?' ajoute les champs 'etape' (= None) et 'prenom' ('') s'ils ne sont pas présents. + ajoute le champ 'civilite_etat_civil' (='X'), et 'prenom_etat_civil' (='') si non présent. """ for infos in infolist: if "paiementinscription" in infos: @@ -520,6 +521,15 @@ def _normalize_apo_fields(infolist): if "prenom" not in infos: infos["prenom"] = "" + if "civilite_etat_civil" not in infos: + infos["civilite_etat_civil"] = "X" + + if "civilite_etat_civil" not in infos: + infos["civilite_etat_civil"] = "X" + + if "prenom_etat_civil" not in infos: + infos["prenom_etat_civil"] = "" + return infolist diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index 14a103809..6e9084d0c 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -36,41 +36,49 @@ import flask from flask import g, url_for, request from flask_login import current_user +from app import log from app.auth.models import User from app.comp import res_sem from app.comp.res_compat import NotesTableCompat -from app.models import Evaluation, FormSemestre -from app.models import ModuleImpl, ScolarNews +from app.models import ( + Evaluation, + FormSemestre, + Module, + ModuleImpl, + NotesNotes, + ScolarNews, +) from app.models.etudiants import Identite -import app.scodoc.sco_utils as scu -from app.scodoc.sco_utils import ModuleType -import app.scodoc.notesdb as ndb -from app import log + from app.scodoc.sco_exceptions import ( AccessDenied, InvalidNoteValue, NoteProcessError, - ScoGenError, + ScoBugCatcher, + ScoException, ScoInvalidParamError, ScoValueError, ) -from app.scodoc.TrivialFormulator import TrivialFormulator, TF from app.scodoc import html_sco_header, sco_users from app.scodoc import htmlutils from app.scodoc import sco_abs from app.scodoc import sco_cache from app.scodoc import sco_edit_module -from app.scodoc import sco_evaluations +from app.scodoc import sco_etud from app.scodoc import sco_evaluation_db +from app.scodoc import sco_evaluations from app.scodoc import sco_excel -from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_groups_view from app.scodoc import sco_moduleimpl from app.scodoc import sco_permissions_check from app.scodoc import sco_undo_notes -from app.scodoc import sco_etud +import app.scodoc.notesdb as ndb +from app.scodoc.TrivialFormulator import TrivialFormulator, TF +import app.scodoc.sco_utils as scu +from app.scodoc.sco_utils import json_error +from app.scodoc.sco_utils import ModuleType def convert_note_from_string( @@ -128,29 +136,30 @@ def _displayNote(val): return val -def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict): +def _check_notes(notes: list[(int, float)], evaluation: Evaluation): # XXX typehint : float or str """notes is a list of tuples (etudid, value) mod is the module (used to ckeck type, for malus) returns list of valid notes (etudid, float value) - and 4 lists of etudid: invalids, withoutnotes, absents, tosuppress, existingjury + and 4 lists of etudid: etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress """ - note_max = evaluation["note_max"] - if mod["module_type"] in ( + note_max = evaluation.note_max or 0.0 + module: Module = evaluation.moduleimpl.module + if module.module_type in ( scu.ModuleType.STANDARD, scu.ModuleType.RESSOURCE, scu.ModuleType.SAE, ): note_min = scu.NOTES_MIN - elif mod["module_type"] == ModuleType.MALUS: + elif module.module_type == ModuleType.MALUS: note_min = -20.0 else: raise ValueError("Invalid module type") # bug - L = [] # liste (etudid, note) des notes ok (ou absent) - invalids = [] # etudid avec notes invalides - withoutnotes = [] # etudid sans notes (champs vides) - absents = [] # etudid absents - tosuppress = [] # etudids avec ancienne note à supprimer + valid_notes = [] # liste (etudid, note) des notes ok (ou absent) + etudids_invalids = [] # etudid avec notes invalides + etudids_without_notes = [] # etudid sans notes (champs vides) + etudids_absents = [] # etudid absents + etudid_to_suppress = [] # etudids avec ancienne note à supprimer for etudid, note in notes: note = str(note).strip().upper() @@ -166,31 +175,34 @@ def _check_notes(notes: list[(int, float)], evaluation: dict, mod: dict): note_max, note_min=note_min, etudid=etudid, - absents=absents, - tosuppress=tosuppress, - invalids=invalids, + absents=etudids_absents, + tosuppress=etudid_to_suppress, + invalids=etudids_invalids, ) if not invalid: - L.append((etudid, value)) + valid_notes.append((etudid, value)) else: - withoutnotes.append(etudid) - return L, invalids, withoutnotes, absents, tosuppress + etudids_without_notes.append(etudid) + return ( + valid_notes, + etudids_invalids, + etudids_without_notes, + etudids_absents, + etudid_to_suppress, + ) def do_evaluation_upload_xls(): """ Soumission d'un fichier XLS (evaluation_id, notefile) """ - authuser = current_user vals = scu.get_request_args() evaluation_id = int(vals["evaluation_id"]) comment = vals["comment"] - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] - # Check access - # (admin, respformation, and responsable_id) - if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]): - raise AccessDenied("Modification des notes impossible pour %s" % authuser) + evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id) + # Check access (admin, respformation, and responsable_id) + if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id): + raise AccessDenied(f"Modification des notes impossible pour {current_user}") # diag, lines = sco_excel.excel_file_to_list(vals["notefile"]) try: @@ -239,14 +251,16 @@ def do_evaluation_upload_xls(): if etudid: notes.append((etudid, val)) ni += 1 - except: + except Exception as exc: diag.append( f"""Erreur: Ligne invalide ! (erreur ligne {ni})
{lines[ni]}""" ) - raise InvalidNoteValue() + raise InvalidNoteValue() from exc # -- check values - L, invalids, withoutnotes, absents, _ = _check_notes(notes, E, M["module"]) - if len(invalids): + valid_notes, invalids, withoutnotes, absents, _ = _check_notes( + notes, evaluation + ) + if invalids: diag.append( f"Erreur: la feuille contient {len(invalids)} notes invalides

" ) @@ -258,37 +272,33 @@ def do_evaluation_upload_xls(): diag.append("Notes invalides pour: " + ", ".join(etudsnames)) raise InvalidNoteValue() else: - nb_changed, nb_suppress, existing_decisions = notes_add( - authuser, evaluation_id, L, comment + etudids_changed, nb_suppress, etudids_with_decisions = notes_add( + current_user, evaluation_id, valid_notes, comment ) # news - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[ - 0 - ] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - mod["moduleimpl_id"] = M["moduleimpl_id"] - mod["url"] = url_for( + module: Module = evaluation.moduleimpl.module + status_url = url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, - moduleimpl_id=mod["moduleimpl_id"], + moduleimpl_id=evaluation.moduleimpl_id, _external=True, ) ScolarNews.add( typ=ScolarNews.NEWS_NOTE, - obj=M["moduleimpl_id"], - text='Chargement notes dans %(titre)s' % mod, - url=mod["url"], + obj=evaluation.moduleimpl_id, + text=f"""Chargement notes dans { + module.titre or module.code}""", + url=status_url, max_frequency=30 * 60, # 30 minutes ) - msg = ( - "

%d notes changées (%d sans notes, %d absents, %d note supprimées)

" - % (nb_changed, len(withoutnotes), len(absents), nb_suppress) - ) - if existing_decisions: - msg += """

Important: il y avait déjà des décisions de jury enregistrées, qui sont potentiellement à revoir suite à cette modification !

""" - # msg += '

' + str(notes) # debug + msg = f"""

{len(etudids_changed)} notes changées ({len(withoutnotes)} sans notes, { + len(absents)} absents, {nb_suppress} note supprimées) +

""" + if etudids_with_decisions: + msg += """

Important: il y avait déjà des décisions de jury + enregistrées, qui sont peut-être à revoir suite à cette modification !

+ """ return 1, msg except InvalidNoteValue: @@ -310,14 +320,12 @@ def do_evaluation_set_etud_note(evaluation: Evaluation, etud: Identite, value) - if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl.id): raise AccessDenied(f"Modification des notes impossible pour {current_user}") # Convert and check value - L, invalids, _, _, _ = _check_notes( - [(etud.id, value)], evaluation.to_dict(), evaluation.moduleimpl.module.to_dict() - ) + L, invalids, _, _, _ = _check_notes([(etud.id, value)], evaluation) if len(invalids) == 0: - nb_changed, _, _ = notes_add( + etudids_changed, _, _ = notes_add( current_user, evaluation.id, L, "Initialisation notes" ) - if nb_changed == 1: + if len(etudids_changed) == 1: return True return False # error @@ -352,9 +360,7 @@ def do_evaluation_set_missing( if etudid not in notes_db: # pas de note notes.append((etudid, value)) # Convert and check values - L, invalids, _, _, _ = _check_notes( - notes, evaluation.to_dict(), modimpl.module.to_dict() - ) + valid_notes, invalids, _, _, _ = _check_notes(notes, evaluation) dest_url = url_for( "notes.saisie_notes", scodoc_dept=g.scodoc_dept, evaluation_id=evaluation_id ) @@ -372,13 +378,13 @@ def do_evaluation_set_missing( """ # Confirm action if not dialog_confirmed: - plural = len(L) > 1 + plural = len(valid_notes) > 1 return scu.confirm_dialog( f"""

Mettre toutes les notes manquantes de l'évaluation à la valeur {value} ?

Seuls les étudiants pour lesquels aucune note (ni valeur, ni ABS, ni EXC) n'a été rentrée seront affectés.

-

{len(L)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""} +

{len(valid_notes)} étudiant{"s" if plural else ""} concerné{"s" if plural else ""} par ce changement de note.

""", @@ -392,7 +398,7 @@ def do_evaluation_set_missing( ) # ok comment = "Initialisation notes manquantes" - nb_changed, _, _ = notes_add(current_user, evaluation_id, L, comment) + etudids_changed, _, _ = notes_add(current_user, evaluation_id, valid_notes, comment) # news url = url_for( "notes.moduleimpl_status", @@ -408,7 +414,7 @@ def do_evaluation_set_missing( ) return f""" { html_sco_header.sco_header() } -

{nb_changed} notes changées

+

{len(etudids_changed)} notes changées

  • Revenir au formulaire de saisie des notes @@ -454,7 +460,7 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False): ) if not dialog_confirmed: - nb_changed, nb_suppress, existing_decisions = notes_add( + etudids_changed, nb_suppress, existing_decisions = notes_add( current_user, evaluation_id, notes, do_it=False, check_inscription=False ) msg = f"""

    Confirmer la suppression des {nb_suppress} notes ? @@ -475,14 +481,14 @@ def evaluation_suppress_alln(evaluation_id, dialog_confirmed=False): ) # modif - nb_changed, nb_suppress, existing_decisions = notes_add( + etudids_changed, nb_suppress, existing_decisions = notes_add( current_user, evaluation_id, notes, comment="effacer tout", check_inscription=False, ) - assert nb_changed == nb_suppress + assert len(etudids_changed) == nb_suppress H = [f"""

    {nb_suppress} notes supprimées

    """] if existing_decisions: H.append( @@ -516,7 +522,7 @@ def notes_add( comment=None, do_it=True, check_inscription=True, -) -> tuple: +) -> tuple[list[int], int, list[int]]: """ Insert or update notes notes is a list of tuples (etudid,value) @@ -524,12 +530,12 @@ def notes_add( WOULD be changed or suppressed. Nota: - si la note existe deja avec valeur distincte, ajoute une entree au log (notes_notes_log) - Return tuple (nb_changed, nb_suppress, existing_decisions) + + Return: tuple (etudids_changed, nb_suppress, etudids_with_decision) """ - now = psycopg2.Timestamp( - *time.localtime()[:6] - ) # datetime.datetime.now().isoformat() - # Verifie inscription et valeur note + now = psycopg2.Timestamp(*time.localtime()[:6]) + + # Vérifie inscription et valeur note inscrits = { x[0] for x in sco_groups.do_evaluation_listeetuds_groups( @@ -548,13 +554,13 @@ def notes_add( # Met a jour la base cnx = ndb.GetDBConnexion() cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - nb_changed = 0 + etudids_changed = [] nb_suppress = 0 - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - existing_decisions = ( - [] - ) # etudids pour lesquels il y a une decision de jury et que la note change + evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id) + formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre + res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + # etudids pour lesquels il y a une decision de jury et que la note change: + etudids_with_decision = [] try: for etudid, value in notes: changed = False @@ -562,7 +568,7 @@ def notes_add( # nouvelle note if value != scu.NOTES_SUPPRESS: if do_it: - aa = { + args = { "etudid": etudid, "evaluation_id": evaluation_id, "value": value, @@ -570,13 +576,20 @@ def notes_add( "uid": user.id, "date": now, } - ndb.quote_dict(aa) + ndb.quote_dict(args) + # Note: le conflit ci-dessous peut arriver si un autre thread + # a modifié la base après qu'on ait lu notes_db cursor.execute( """INSERT INTO notes_notes (etudid, evaluation_id, value, comment, date, uid) - VALUES (%(etudid)s,%(evaluation_id)s,%(value)s,%(comment)s,%(date)s,%(uid)s) + VALUES + (%(etudid)s,%(evaluation_id)s,%(value)s, + %(comment)s,%(date)s,%(uid)s) + ON CONFLICT ON CONSTRAINT notes_notes_etudid_evaluation_id_key + DO UPDATE SET etudid=%(etudid)s, evaluation_id=%(evaluation_id)s, + value=%(value)s, comment=%(comment)s, date=%(date)s, uid=%(uid)s """, - aa, + args, ) changed = True else: @@ -584,7 +597,7 @@ def notes_add( oldval = notes_db[etudid]["value"] if type(value) != type(oldval): changed = True - elif type(value) == float and ( + elif isinstance(value, float) and ( abs(value - oldval) > scu.NOTES_PRECISION ): changed = True @@ -603,7 +616,7 @@ def notes_add( """, {"etudid": etudid, "evaluation_id": evaluation_id}, ) - aa = { + args = { "etudid": etudid, "evaluation_id": evaluation_id, "value": value, @@ -611,7 +624,7 @@ def notes_add( "comment": comment, "uid": user.id, } - ndb.quote_dict(aa) + ndb.quote_dict(args) if value != scu.NOTES_SUPPRESS: if do_it: cursor.execute( @@ -620,52 +633,49 @@ def notes_add( WHERE etudid = %(etudid)s and evaluation_id = %(evaluation_id)s """, - aa, + args, ) else: # suppression ancienne note if do_it: log( - "notes_add, suppress, evaluation_id=%s, etudid=%s, oldval=%s" - % (evaluation_id, etudid, oldval) + f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={ + etudid}, oldval={oldval}""" ) cursor.execute( """DELETE FROM notes_notes WHERE etudid = %(etudid)s AND evaluation_id = %(evaluation_id)s """, - aa, + args, ) # garde trace de la suppression dans l'historique: - aa["value"] = scu.NOTES_SUPPRESS + args["value"] = scu.NOTES_SUPPRESS cursor.execute( - """INSERT INTO notes_notes_log (etudid,evaluation_id,value,comment,date,uid) - VALUES (%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s) + """INSERT INTO notes_notes_log + (etudid,evaluation_id,value,comment,date,uid) + VALUES + (%(etudid)s, %(evaluation_id)s, %(value)s, %(comment)s, %(date)s, %(uid)s) """, - aa, + args, ) nb_suppress += 1 if changed: - nb_changed += 1 - if has_existing_decision(M, E, etudid): - existing_decisions.append(etudid) + etudids_changed.append(etudid) + if res.etud_has_decision(etudid): + etudids_with_decision.append(etudid) except Exception as exc: log("*** exception in notes_add") if do_it: cnx.rollback() # abort # inval cache - sco_cache.invalidate_formsemestre( - formsemestre_id=M["formsemestre_id"] - ) # > modif notes (exception) + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id) sco_cache.EvaluationCache.delete(evaluation_id) - raise # XXX - raise ScoGenError("Erreur enregistrement note: merci de ré-essayer") from exc + raise ScoException from exc if do_it: cnx.commit() - sco_cache.invalidate_formsemestre( - formsemestre_id=M["formsemestre_id"] - ) # > modif notes + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre.id) sco_cache.EvaluationCache.delete(evaluation_id) - return nb_changed, nb_suppress, existing_decisions + return etudids_changed, nb_suppress, etudids_with_decision def saisie_notes_tableur(evaluation_id, group_ids=()): @@ -868,44 +878,39 @@ def saisie_notes_tableur(evaluation_id, group_ids=()): def feuille_saisie_notes(evaluation_id, group_ids=[]): """Document Excel pour saisie notes dans l'évaluation et les groupes indiqués""" - evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) - if not evals: + evaluation: Evaluation = Evaluation.query.get(evaluation_id) + if not evaluation: raise ScoValueError("invalid evaluation_id") - eval_dict = evals[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=eval_dict["moduleimpl_id"])[0] - formsemestre_id = M["formsemestre_id"] - Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - sem = sco_formsemestre.get_formsemestre(M["formsemestre_id"]) - mod_responsable = sco_users.user_info(M["responsable_id"]) - if eval_dict["jour"]: - indication_date = ndb.DateDMYtoISO(eval_dict["jour"]) + modimpl = evaluation.moduleimpl + formsemestre = modimpl.formsemestre + mod_responsable = sco_users.user_info(modimpl.responsable_id) + if evaluation.jour: + indication_date = evaluation.jour.isoformat() else: - indication_date = scu.sanitize_filename(eval_dict["description"])[:12] - eval_name = "%s-%s" % (Mod["code"], indication_date) + indication_date = scu.sanitize_filename(evaluation.description or "")[:12] + eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}" - if eval_dict["description"]: - evaltitre = "%s du %s" % (eval_dict["description"], eval_dict["jour"]) - else: - evaltitre = "évaluation du %s" % eval_dict["jour"] - description = "%s en %s (%s) resp. %s" % ( - evaltitre, - Mod["abbrev"] or "", - Mod["code"] or "", - mod_responsable["prenomnom"], + date_str = ( + f"""du {evaluation.jour.strftime("%d/%m/%Y")}""" + if evaluation.jour + else "(sans date)" ) + eval_titre = f"""{evaluation.description if evaluation.description else "évaluation"} {date_str}""" + + description = f"""{eval_titre} en {evaluation.moduleimpl.module.abbrev or ""} ({ + evaluation.moduleimpl.module.code + }) resp. {mod_responsable["prenomnom"]}""" groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids=group_ids, - formsemestre_id=formsemestre_id, + formsemestre_id=formsemestre.id, select_all_when_unspecified=True, etat=None, ) groups = sco_groups.listgroups(groups_infos.group_ids) gr_title_filename = sco_groups.listgroups_filename(groups) - # gr_title = sco_groups.listgroups_abbrev(groups) if None in [g["group_name"] for g in groups]: # tous les etudiants getallstudents = True - # gr_title = "tous" gr_title_filename = "tous" else: getallstudents = False @@ -917,17 +922,17 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]): ] # une liste de liste de chaines: lignes de la feuille de calcul - L = [] + rows = [] - etuds = _get_sorted_etuds(eval_dict, etudids, formsemestre_id) + etuds = _get_sorted_etuds(evaluation, etudids, formsemestre.id) for e in etuds: etudid = e["etudid"] - groups = sco_groups.get_etud_groups(etudid, formsemestre_id) + groups = sco_groups.get_etud_groups(etudid, formsemestre.id) grc = sco_groups.listgroups_abbrev(groups) - L.append( + rows.append( [ - "%s" % etudid, + str(etudid), e["nom"].upper(), e["prenom"].lower().capitalize(), e["inscr"]["etat"], @@ -937,31 +942,11 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]): ] ) - filename = "notes_%s_%s" % (eval_name, gr_title_filename) + filename = f"notes_{eval_name}_{gr_title_filename}" xls = sco_excel.excel_feuille_saisie( - eval_dict, sem["titreannee"], description, lines=L + evaluation, formsemestre.titre_annee(), description, lines=rows ) return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE) - # return sco_excel.send_excel_file(xls, filename) - - -def has_existing_decision(M, E, etudid): - """Verifie s'il y a une validation pour cet etudiant dans ce semestre ou UE - Si oui, return True - """ - formsemestre_id = M["formsemestre_id"] - formsemestre = FormSemestre.get_formsemestre(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - if nt.get_etud_decision_sem(etudid): - return True - dec_ues = nt.get_etud_decisions_ue(etudid) - if dec_ues: - mod = sco_edit_module.module_list({"module_id": M["module_id"]})[0] - ue_id = mod["ue_id"] - if ue_id in dec_ues: - return True # decision pour l'UE a laquelle appartient cette evaluation - - return False # pas de decision de jury affectee par cette note # ----------------------------- @@ -973,20 +958,18 @@ def saisie_notes(evaluation_id: int, group_ids: list = None): if not isinstance(evaluation_id, int): raise ScoInvalidParamError() group_ids = [int(group_id) for group_id in (group_ids or [])] - evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) - if not evals: + evaluation: Evaluation = Evaluation.query.get(evaluation_id) + if evaluation is None: raise ScoValueError("évaluation inexistante") - E = evals[0] - M = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] - formsemestre_id = M["formsemestre_id"] + modimpl = evaluation.moduleimpl moduleimpl_status_url = url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, - moduleimpl_id=E["moduleimpl_id"], + moduleimpl_id=evaluation.moduleimpl_id, ) # Check access # (admin, respformation, and responsable_id) - if not sco_permissions_check.can_edit_notes(current_user, E["moduleimpl_id"]): + if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id): return f""" {html_sco_header.sco_header()}

    Modification des notes impossible pour {current_user.user_name}

    @@ -1001,16 +984,16 @@ def saisie_notes(evaluation_id: int, group_ids: list = None): # Informations sur les groupes à afficher: groups_infos = sco_groups_view.DisplayedGroupsInfos( group_ids=group_ids, - formsemestre_id=formsemestre_id, + formsemestre_id=modimpl.formsemestre_id, select_all_when_unspecified=True, etat=None, ) - if E["description"]: - page_title = 'Saisie "%s"' % E["description"] - else: - page_title = "Saisie des notes" - + page_title = ( + f'Saisie "{evaluation.description}"' + if evaluation.description + else "Saisie des notes" + ) # HTML page: H = [ html_sco_header.sco_header( @@ -1036,19 +1019,19 @@ def saisie_notes(evaluation_id: int, group_ids: list = None): "id": "menu_saisie_tableur", "endpoint": "notes.saisie_notes_tableur", "args": { - "evaluation_id": E["evaluation_id"], + "evaluation_id": evaluation.id, "group_ids": groups_infos.group_ids, }, }, { "title": "Voir toutes les notes du module", "endpoint": "notes.evaluation_listenotes", - "args": {"moduleimpl_id": E["moduleimpl_id"]}, + "args": {"moduleimpl_id": evaluation.moduleimpl_id}, }, { "title": "Effacer toutes les notes de cette évaluation", "endpoint": "notes.evaluation_suppress_alln", - "args": {"evaluation_id": E["evaluation_id"]}, + "args": {"evaluation_id": evaluation.id}, }, ], alone=True, @@ -1077,7 +1060,9 @@ def saisie_notes(evaluation_id: int, group_ids: list = None): ) # Le formulaire de saisie des notes: - form = _form_saisie_notes(E, M, groups_infos, destination=moduleimpl_status_url) + form = _form_saisie_notes( + evaluation, modimpl, groups_infos, destination=moduleimpl_status_url + ) if form is None: return flask.redirect(moduleimpl_status_url) H.append(form) @@ -1101,10 +1086,9 @@ def saisie_notes(evaluation_id: int, group_ids: list = None): return "\n".join(H) -def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int): - notes_db = sco_evaluation_db.do_evaluation_get_all_notes( - eval_dict["evaluation_id"] - ) # Notes existantes +def _get_sorted_etuds(evaluation: Evaluation, etudids: list, formsemestre_id: int): + # Notes existantes + notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id) cnx = ndb.GetDBConnexion() etuds = [] for etudid in etudids: @@ -1123,17 +1107,17 @@ def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int): e["groups"] = sco_groups.get_etud_groups(etudid, formsemestre_id) # Information sur absence (tenant compte de la demi-journée) - jour_iso = ndb.DateDMYtoISO(eval_dict["jour"]) + jour_iso = evaluation.jour.isoformat() if evaluation.jour else "" warn_abs_lst = [] - if eval_dict["matin"]: - nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=1) - nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=1) + if evaluation.is_matin(): + nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=True) + nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=True) if nbabs: if nbabsjust: warn_abs_lst.append("absent justifié le matin !") else: warn_abs_lst.append("absent le matin !") - if eval_dict["apresmidi"]: + if evaluation.is_apresmidi(): nbabs = sco_abs.count_abs(etudid, jour_iso, jour_iso, matin=0) nbabsjust = sco_abs.count_abs_just(etudid, jour_iso, jour_iso, matin=0) if nbabs: @@ -1169,35 +1153,38 @@ def _get_sorted_etuds(eval_dict: dict, etudids: list, formsemestre_id: int): return etuds -def _form_saisie_notes(E, M, groups_infos, destination=""): +def _form_saisie_notes( + evaluation: Evaluation, modimpl: ModuleImpl, groups_infos, destination="" +): """Formulaire HTML saisie des notes dans l'évaluation E du moduleimpl M pour les groupes indiqués. On charge tous les étudiants, ne seront montrés que ceux des groupes sélectionnés grace a un filtre en javascript. """ - evaluation_id = E["evaluation_id"] - formsemestre_id = M["formsemestre_id"] - + formsemestre_id = modimpl.formsemestre_id + formsemestre: FormSemestre = evaluation.moduleimpl.formsemestre + res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) etudids = [ x[0] for x in sco_groups.do_evaluation_listeetuds_groups( - evaluation_id, getallstudents=True, include_demdef=True + evaluation.id, getallstudents=True, include_demdef=True ) ] if not etudids: return '
    Aucun étudiant sélectionné !
    ' - # Decisions de jury existantes ? - decisions_jury = {etudid: has_existing_decision(M, E, etudid) for etudid in etudids} - # Nb de decisions de jury (pour les inscrits à l'évaluation): + # Décisions de jury existantes ? + decisions_jury = {etudid: res.etud_has_decision(etudid) for etudid in etudids} + + # Nb de décisions de jury (pour les inscrits à l'évaluation): nb_decisions = sum(decisions_jury.values()) - etuds = _get_sorted_etuds(E, etudids, formsemestre_id) + etuds = _get_sorted_etuds(evaluation, etudids, formsemestre_id) # Build form: descr = [ - ("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}), + ("evaluation_id", {"default": evaluation.id, "input_type": "hidden"}), ("formsemestre_id", {"default": formsemestre_id, "input_type": "hidden"}), ( "group_ids", @@ -1207,7 +1194,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""): ("comment", {"size": 44, "title": "Commentaire", "return_focus_next": True}), ("changed", {"default": "0", "input_type": "hidden"}), # changed in JS ] - if M["module"]["module_type"] in ( + if modimpl.module.module_type in ( ModuleType.STANDARD, ModuleType.RESSOURCE, ModuleType.SAE, @@ -1220,11 +1207,11 @@ def _form_saisie_notes(E, M, groups_infos, destination=""): "title": "Notes ", "cssclass": "formnote_bareme", "readonly": True, - "default": " / %g" % E["note_max"], + "default": " / %g" % evaluation.note_max, }, ) ) - elif M["module"]["module_type"] == ModuleType.MALUS: + elif modimpl.module.module_type == ModuleType.MALUS: descr.append( ( "s3", @@ -1238,7 +1225,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""): ) ) else: - raise ValueError("invalid module type (%s)" % M["module"]["module_type"]) # bug + raise ValueError(f"invalid module type ({modimpl.module.module_type})") # bug initvalues = {} for e in etuds: @@ -1248,7 +1235,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""): if disabled: classdem = " etud_dem" etud_classes.append("etud_dem") - disabled_attr = 'disabled="%d"' % disabled + disabled_attr = f'disabled="{disabled}"' else: classdem = "" disabled_attr = "" @@ -1265,18 +1252,17 @@ def _form_saisie_notes(E, M, groups_infos, destination=""): ) # Historique des saisies de notes: - if not disabled: - explanation = ( - '' % etudid - + get_note_history_menu(evaluation_id, etudid) - + "" - ) - else: - explanation = "" + explanation = ( + "" + if disabled + else f"""{ + get_note_history_menu(evaluation.id, etudid) + }""" + ) explanation = e["absinfo"] + explanation # Lien modif decision de jury: - explanation += '' % etudid + explanation += f'' # Valeur actuelle du champ: initvalues["note_" + str(etudid)] = e["val"] @@ -1330,7 +1316,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""): H.append(tf.getform()) # check and init H.append( f"""Terminer """ ) @@ -1345,7 +1331,7 @@ def _form_saisie_notes(E, M, groups_infos, destination=""): Mettre les notes manquantes à - + @@ -1362,50 +1348,56 @@ def _form_saisie_notes(E, M, groups_infos, destination=""): return None -def save_note(etudid=None, evaluation_id=None, value=None, comment=""): - """Enregistre une note (ajax)""" - authuser = current_user - log( - "save_note: evaluation_id=%s etudid=%s uid=%s value=%s" - % (evaluation_id, etudid, authuser, value) - ) - E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] - M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] - Mod["url"] = url_for( +def save_notes( + evaluation: Evaluation, notes: list[tuple[(int, float)]], comment: str = "" +) -> dict: + """Enregistre une liste de notes. + Vérifie que les étudiants sont bien inscrits à ce module, et que l'on a le droit. + Result: dict avec + """ + log(f"save_note: evaluation_id={evaluation.id} uid={current_user} notes={notes}") + status_url = url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, - moduleimpl_id=M["moduleimpl_id"], + moduleimpl_id=evaluation.moduleimpl_id, _external=True, ) - result = {"nbchanged": 0} # JSON # Check access: admin, respformation, or responsable_id - if not sco_permissions_check.can_edit_notes(authuser, E["moduleimpl_id"]): - result["status"] = "unauthorized" + if not sco_permissions_check.can_edit_notes(current_user, evaluation.moduleimpl_id): + return json_error(403, "modification notes non autorisee pour cet utilisateur") + # + valid_notes, _, _, _, _ = _check_notes(notes, evaluation) + if valid_notes: + etudids_changed, _, etudids_with_decision = notes_add( + current_user, evaluation.id, valid_notes, comment=comment, do_it=True + ) + ScolarNews.add( + typ=ScolarNews.NEWS_NOTE, + obj=evaluation.moduleimpl_id, + text=f"""Chargement notes dans { + evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code}""", + url=status_url, + max_frequency=30 * 60, # 30 minutes + ) + result = { + "etudids_with_decision": etudids_with_decision, + "etudids_changed": etudids_changed, + "history_menu": { + etudid: get_note_history_menu(evaluation.id, etudid) + for etudid in etudids_changed + }, + } else: - L, _, _, _, _ = _check_notes([(etudid, value)], E, Mod) - if L: - nbchanged, _, existing_decisions = notes_add( - authuser, evaluation_id, L, comment=comment, do_it=True - ) - ScolarNews.add( - typ=ScolarNews.NEWS_NOTE, - obj=M["moduleimpl_id"], - text='Chargement notes dans %(titre)s' % Mod, - url=Mod["url"], - max_frequency=30 * 60, # 30 minutes - ) - result["nbchanged"] = nbchanged - result["existing_decisions"] = existing_decisions - if nbchanged > 0: - result["history_menu"] = get_note_history_menu(evaluation_id, etudid) - else: - result["history_menu"] = "" # no update needed - result["status"] = "ok" - return scu.sendJSON(result) + result = { + "etudids_changed": [], + "etudids_with_decision": [], + "history_menu": [], + } + + return result -def get_note_history_menu(evaluation_id, etudid): +def get_note_history_menu(evaluation_id: int, etudid: int) -> str: """Menu HTML historique de la note""" history = sco_undo_notes.get_note_history(evaluation_id, etudid) if not history: diff --git a/app/static/css/partition_editor.css b/app/static/css/partition_editor.css index 086327b42..c74e3f272 100644 --- a/app/static/css/partition_editor.css +++ b/app/static/css/partition_editor.css @@ -354,6 +354,10 @@ body.editionActivated .filtres .nonEditable .move { display: initial; } +.groupe:has(.etudiants:empty) { + display: none; +} + /* .filtres .unselect { background: rgba(0, 153, 204, 0.5) !important; } */ diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 7c414e181..4f922c9d9 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -3173,6 +3173,19 @@ li.tf-msg { /* EMO_WARNING, "⚠️" */ } +p.error { + font-weight: bold; + color: red; +} + +p.error::before { + content: "\2049 \fe0f"; + margin-right: 8px; +} + +mark { + padding-right: 0px; +} .infop { font-weight: normal; diff --git a/app/static/js/etud_autocomplete.js b/app/static/js/etud_autocomplete.js new file mode 100644 index 000000000..217707aaa --- /dev/null +++ b/app/static/js/etud_autocomplete.js @@ -0,0 +1,65 @@ + +// Mécanisme d'auto-complétion (choix) d'un étudiant +// Il faut un champs #etudiant (text input) et à coté un champ hidden etudid qui sera rempli. +// utilise autoComplete.js, source https://tarekraafat.github.io/autoComplete.js +// EV 2023-06-01 + +function etud_autocomplete_config(with_dept = false) { + return { + selector: "#etudiant", + placeHolder: "Nom...", + threshold: 3, + data: { + src: async (query) => { + try { + // Fetch Data from external Source + const source = await fetch(`/ScoDoc/api/etudiants/name/${query}`); + // Data should be an array of `Objects` or `Strings` + const data = await source.json(); + return data; + } catch (error) { + return error; + } + }, + // Data source 'Object' key to be searched + keys: ["nom"] + }, + events: { + input: { + selection: (event) => { + const prenom = sco_capitalize(event.detail.selection.value.prenom); + const selection = with_dept ? `${event.detail.selection.value.nom} ${prenom} (${event.detail.selection.value.dept_acronym})` : `${event.detail.selection.value.nom} ${prenom}`; + // store etudid + const etudidField = document.getElementById('etudid'); + etudidField.value = event.detail.selection.value.id; + autoCompleteJS.input.value = selection; + } + } + }, + resultsList: { + element: (list, data) => { + if (!data.results.length) { + // Create "No Results" message element + const message = document.createElement("div"); + // Add class to the created element + message.setAttribute("class", "no_result"); + // Add message text content + message.innerHTML = `Pas de résultat pour "${data.query}"`; + // Append message element to the results list + list.prepend(message); + // Efface l'etudid + const etudidField = document.getElementById('etudid'); + etudidField.value = ""; + } + }, + noResults: true, + }, + resultItem: { + highlight: true, + element: (item, data) => { + const prenom = sco_capitalize(data.value.prenom); + item.innerHTML += with_dept ? ` ${prenom} (${data.value.dept_acronym})` : ` ${prenom}`; + }, + }, + } +} diff --git a/app/static/js/saisie_notes.js b/app/static/js/saisie_notes.js index 0936b3d7a..7fb223bc5 100644 --- a/app/static/js/saisie_notes.js +++ b/app/static/js/saisie_notes.js @@ -1,132 +1,142 @@ // Formulaire saisie des notes $().ready(function () { + $("#formnotes .note").bind("blur", valid_note); - $("#formnotes .note").bind("blur", valid_note); - - $("#formnotes input").bind("paste", paste_text); - $(".btn_masquer_DEM").bind("click", masquer_DEM); - + $("#formnotes input").bind("paste", paste_text); + $(".btn_masquer_DEM").bind("click", masquer_DEM); }); function is_valid_note(v) { - if (!v) - return true; + if (!v) return true; - var note_min = parseFloat($("#eval_note_min").text()); - var note_max = parseFloat($("#eval_note_max").text()); + var note_min = parseFloat($("#eval_note_min").text()); + var note_max = parseFloat($("#eval_note_max").text()); - if (!v.match("^-?[0-9]*.?[0-9]*$")) { - return (v == "ABS") || (v == "EXC") || (v == "SUPR") || (v == "ATT") || (v == "DEM"); - } else { - var x = parseFloat(v); - return (x >= note_min) && (x <= note_max); - } + if (!v.match("^-?[0-9]*.?[0-9]*$")) { + return v == "ABS" || v == "EXC" || v == "SUPR" || v == "ATT" || v == "DEM"; + } else { + var x = parseFloat(v); + return x >= note_min && x <= note_max; + } } function valid_note(e) { - var v = this.value.trim().toUpperCase().replace(",", "."); - if (is_valid_note(v)) { - if (v && (v != $(this).attr('data-last-saved-value'))) { - this.className = "note_valid_new"; - var etudid = $(this).attr('data-etudid'); - save_note(this, v, etudid); - } - } else { - /* Saisie invalide */ - this.className = "note_invalid"; - sco_message("valeur invalide ou hors barème"); + var v = this.value.trim().toUpperCase().replace(",", "."); + if (is_valid_note(v)) { + if (v && v != $(this).attr("data-last-saved-value")) { + this.className = "note_valid_new"; + const etudid = parseInt($(this).attr("data-etudid")); + save_note(this, v, etudid); } + } else { + /* Saisie invalide */ + this.className = "note_invalid"; + sco_message("valeur invalide ou hors barème"); + } } -function save_note(elem, v, etudid) { - var evaluation_id = $("#formnotes_evaluation_id").attr("value"); - var formsemestre_id = $("#formnotes_formsemestre_id").attr("value"); - $('#sco_msg').html("en cours...").show(); - $.post(SCO_URL + '/Notes/save_note', - { - 'etudid': etudid, - 'evaluation_id': evaluation_id, - 'value': v, - 'comment': document.getElementById('formnotes_comment').value +async function save_note(elem, v, etudid) { + let evaluation_id = $("#formnotes_evaluation_id").attr("value"); + let formsemestre_id = $("#formnotes_formsemestre_id").attr("value"); + $("#sco_msg").html("en cours...").show(); + try { + const response = await fetch( + SCO_URL + "/../api/evaluation/" + evaluation_id + "/notes/set", + { + method: "POST", + headers: { + "Content-Type": "application/json", }, - function (result) { - if (result['nbchanged'] > 0) { - sco_message("enregistré"); - elem.className = "note_saved"; - // il y avait une decision de jury ? - if (result.existing_decisions[0] == etudid) { - if (v != $(elem).attr('data-orig-value')) { - $("#jurylink_" + etudid).html('mettre à jour décision de jury'); - } else { - $("#jurylink_" + etudid).html(''); - } - } - // mise a jour menu historique - if (result['history_menu']) { - $("#hist_" + etudid).html(result['history_menu']); - } - $(elem).attr('data-last-saved-value', v); - } else { - $('#sco_msg').html("").show(); - sco_message("valeur non enregistrée"); - } - - } + body: JSON.stringify({ + notes: [[etudid, v]], + comment: document.getElementById("formnotes_comment").value, + }), + } ); + if (!response.ok) { + sco_message("Erreur: valeur non enregistrée"); + } else { + const data = await response.json(); + $("#sco_msg").hide(); + if (data.etudids_changed.length > 0) { + sco_message("enregistré"); + elem.className = "note_saved"; + // Il y avait une decision de jury ? + if (data.etudids_with_decision.includes(etudid)) { + if (v != $(elem).attr("data-orig-value")) { + $("#jurylink_" + etudid).html( + 'mettre à jour décision de jury' + ); + } else { + $("#jurylink_" + etudid).html(""); + } + } + // Mise à jour menu historique + if (data.history_menu[etudid]) { + $("#hist_" + etudid).html(data.history_menu[etudid]); + } + $(elem).attr("data-last-saved-value", v); + } + } + } catch (error) { + console.error("Fetch error:", error); + sco_message("Erreur réseau: valeur non enregistrée"); + } } function change_history(e) { - var opt = e.selectedOptions[0]; - var val = $(opt).attr("data-note"); - var etudid = $(e).attr('data-etudid'); - // le input associé a ce menu: - var input_elem = e.parentElement.parentElement.parentElement.childNodes[0]; - input_elem.value = val; - save_note(input_elem, val, etudid); + let opt = e.selectedOptions[0]; + let val = $(opt).attr("data-note"); + const etudid = parseInt($(e).attr("data-etudid")); + // le input associé a ce menu: + let input_elem = e.parentElement.parentElement.parentElement.childNodes[0]; + input_elem.value = val; + save_note(input_elem, val, etudid); } // Contribution S.L.: copier/coller des notes - function paste_text(e) { - var event = e.originalEvent; - event.stopPropagation(); - event.preventDefault(); - var clipb = e.originalEvent.clipboardData; - var data = clipb.getData('Text'); - var list = data.split(/\r\n|\r|\n|\t| /g); - var currentInput = event.currentTarget; - var masquerDEM = document.querySelector("body").classList.contains("masquer_DEM"); + var event = e.originalEvent; + event.stopPropagation(); + event.preventDefault(); + var clipb = e.originalEvent.clipboardData; + var data = clipb.getData("Text"); + var list = data.split(/\r\n|\r|\n|\t| /g); + var currentInput = event.currentTarget; + var masquerDEM = document + .querySelector("body") + .classList.contains("masquer_DEM"); - for (var i = 0; i < list.length; i++) { - currentInput.value = list[i]; - var evt = document.createEvent("HTMLEvents"); - evt.initEvent("blur", false, true); - currentInput.dispatchEvent(evt); - var sibbling = currentInput.parentElement.parentElement.nextElementSibling; - while ( - sibbling && - ( - sibbling.style.display == "none" || - ( - masquerDEM && sibbling.classList.contains("etud_dem") - ) - ) - ) { - sibbling = sibbling.nextElementSibling; - } - if (sibbling) { - currentInput = sibbling.querySelector("input"); - if (!currentInput) { - return; - } - } else { - return; - } + for (var i = 0; i < list.length; i++) { + currentInput.value = list[i]; + var evt = document.createEvent("HTMLEvents"); + evt.initEvent("blur", false, true); + currentInput.dispatchEvent(evt); + var sibbling = currentInput.parentElement.parentElement.nextElementSibling; + while ( + sibbling && + (sibbling.style.display == "none" || + (masquerDEM && sibbling.classList.contains("etud_dem"))) + ) { + sibbling = sibbling.nextElementSibling; } + if (sibbling) { + currentInput = sibbling.querySelector("input"); + if (!currentInput) { + return; + } + } else { + return; + } + } } function masquer_DEM() { - document.querySelector("body").classList.toggle("masquer_DEM"); + document.querySelector("body").classList.toggle("masquer_DEM"); } diff --git a/app/static/js/scodoc.js b/app/static/js/scodoc.js index f5b9c995a..44b0352be 100644 --- a/app/static/js/scodoc.js +++ b/app/static/js/scodoc.js @@ -67,6 +67,10 @@ $(function () { } }); +function sco_capitalize(string) { + return string[0].toUpperCase() + string.slice(1).toLowerCase(); +} + // Affiche un message transitoire (duration milliseconds, 0 means infinity) function sco_message(msg, className = "message_custom", duration = 0) { var div = document.createElement("div"); diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/autoComplete.min.js b/app/static/libjs/autoComplete.js-10.2.7/dist/autoComplete.min.js new file mode 100644 index 000000000..55cd004ad --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/autoComplete.min.js @@ -0,0 +1 @@ +var e,t;e=this,t=function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,a=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return u=e.done,e},e:function(e){a=!0,s=e},f:function(){try{u||null==n.return||n.return()}finally{if(a)throw s}}}}(n.keys);try{for(l.s();!(c=l.n()).done;)a(c.value)}catch(e){l.e(e)}finally{l.f()}}else a()})),n.filter&&(i=n.filter(i));var s=i.slice(0,t.resultsList.maxResults);t.feedback={query:e,matches:i,results:s},f("results",t)},m="aria-expanded",b="aria-activedescendant",y="aria-selected",v=function(e,n){e.feedback.selection=t({index:n},e.feedback.results[n])},g=function(e){e.isOpen||((e.wrapper||e.input).setAttribute(m,!0),e.list.removeAttribute("hidden"),e.isOpen=!0,f("open",e))},w=function(e){e.isOpen&&((e.wrapper||e.input).setAttribute(m,!1),e.input.setAttribute(b,""),e.list.setAttribute("hidden",""),e.isOpen=!1,f("close",e))},O=function(e,t){var n=t.resultItem,r=t.list.getElementsByTagName(n.tag),o=!!n.selected&&n.selected.split(" ");if(t.isOpen&&r.length){var s,u,a=t.cursor;e>=r.length&&(e=0),e<0&&(e=r.length-1),t.cursor=e,a>-1&&(r[a].removeAttribute(y),o&&(u=r[a].classList).remove.apply(u,i(o))),r[e].setAttribute(y,!0),o&&(s=r[e].classList).add.apply(s,i(o)),t.input.setAttribute(b,r[t.cursor].id),t.list.scrollTop=r[e].offsetTop-t.list.clientHeight+r[e].clientHeight+5,t.feedback.cursor=t.cursor,v(t,e),f("navigate",t)}},A=function(e){O(e.cursor+1,e)},k=function(e){O(e.cursor-1,e)},L=function(e,t,n){(n=n>=0?n:e.cursor)<0||(e.feedback.event=t,v(e,n),f("selection",e),w(e))};function j(e,n){var r=this;return new Promise((function(i,o){var s,u;return s=n||((u=e.input)instanceof HTMLInputElement||u instanceof HTMLTextAreaElement?u.value:u.innerHTML),function(e,t,n){return t?t(e):e.length>=n}(s=e.query?e.query(s):s,e.trigger,e.threshold)?d(e,s).then((function(n){try{return e.feedback instanceof Error?i():(h(s,e),e.resultsList&&function(e){var n=e.resultsList,r=e.list,i=e.resultItem,o=e.feedback,s=o.matches,u=o.results;if(e.cursor=-1,r.innerHTML="",s.length||n.noResults){var c=new DocumentFragment;u.forEach((function(e,n){var r=a(i.tag,t({id:"".concat(i.id,"_").concat(n),role:"option",innerHTML:e.match,inside:c},i.class&&{class:i.class}));i.element&&i.element(r,e)})),r.append(c),n.element&&n.element(r,o),g(e)}else w(e)}(e),c.call(r))}catch(e){return o(e)}}),o):(w(e),c.call(r));function c(){return i()}}))}var S=function(e,t){for(var n in e)for(var r in e[n])t(n,r)},T=function(e){var n,r,i,o=e.events,s=(n=function(){return j(e)},r=e.debounce,function(){clearTimeout(i),i=setTimeout((function(){return n()}),r)}),u=e.events=t({input:t({},o&&o.input)},e.resultsList&&{list:o?t({},o.list):{}}),a={input:{input:function(){s()},keydown:function(t){!function(e,t){switch(e.keyCode){case 40:case 38:e.preventDefault(),40===e.keyCode?A(t):k(t);break;case 13:t.submit||e.preventDefault(),t.cursor>=0&&L(t,e);break;case 9:t.resultsList.tabSelect&&t.cursor>=0&&L(t,e);break;case 27:t.input.value="",w(t)}}(t,e)},blur:function(){w(e)}},list:{mousedown:function(e){e.preventDefault()},click:function(t){!function(e,t){var n=t.resultItem.tag.toUpperCase(),r=Array.from(t.list.querySelectorAll(n)),i=e.target.closest(n);i&&i.nodeName===n&&L(t,e,r.indexOf(i))}(t,e)}}};S(a,(function(t,n){(e.resultsList||"input"===n)&&(u[t][n]||(u[t][n]=a[t][n]))})),S(u,(function(t,n){e[t].addEventListener(n,u[t][n])}))};function E(e){var n=this;return new Promise((function(r,i){var o,s,u;if(o=e.placeHolder,u={role:"combobox","aria-owns":(s=e.resultsList).id,"aria-haspopup":!0,"aria-expanded":!1},a(e.input,t(t({"aria-controls":s.id,"aria-autocomplete":"both"},o&&{placeholder:o}),!e.wrapper&&t({},u))),e.wrapper&&(e.wrapper=a("div",t({around:e.input,class:e.name+"_wrapper"},u))),s&&(e.list=a(s.tag,t({dest:[s.destination,s.position],id:s.id,role:"listbox",hidden:"hidden"},s.class&&{class:s.class}))),T(e),e.data.cache)return d(e).then((function(e){try{return c.call(n)}catch(e){return i(e)}}),i);function c(){return f("init",e),r()}return c.call(n)}))}function x(e){var t=e.prototype;t.init=function(){E(this)},t.start=function(e){j(this,e)},t.unInit=function(){if(this.wrapper){var e=this.wrapper.parentNode;e.insertBefore(this.input,this.wrapper),e.removeChild(this.wrapper)}var t;S((t=this).events,(function(e,n){t[e].removeEventListener(n,t.events[e][n])}))},t.open=function(){g(this)},t.close=function(){w(this)},t.goTo=function(e){O(e,this)},t.next=function(){A(this)},t.previous=function(){k(this)},t.select=function(e){L(this,null,e)},t.search=function(e,t,n){return p(e,t,n)}}return function e(t){this.options=t,this.id=e.instances=(e.instances||0)+1,this.name="autoComplete",this.wrapper=1,this.threshold=1,this.debounce=0,this.resultsList={position:"afterend",tag:"ul",maxResults:5},this.resultItem={tag:"li"},function(e){var t=e.name,r=e.options,i=e.resultsList,o=e.resultItem;for(var s in r)if("object"===n(r[s]))for(var a in e[s]||(e[s]={}),r[s])e[s][a]=r[s][a];else e[s]=r[s];e.selector=e.selector||"#"+t,i.destination=i.destination||e.selector,i.id=i.id||t+"_list_"+e.id,o.id=o.id||t+"_result",e.input=u(e.selector)}(this),x.call(this,e),E(this)}},"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).autoComplete=t(); diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.01.css b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.01.css new file mode 100644 index 000000000..4df567ee2 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.01.css @@ -0,0 +1,92 @@ +.autoComplete_wrapper { + display: inline-block; + position: relative; +} + +.autoComplete_wrapper > input { + width: 370px; + height: 40px; + padding-left: 20px; + font-size: 1rem; + color: rgba(123, 123, 123, 1); + border-radius: 8px; + border: 0; + outline: none; + background-color: #f1f3f4; +} + +.autoComplete_wrapper > input::placeholder { + color: rgba(123, 123, 123, 0.5); + transition: all 0.3s ease; +} + +.autoComplete_wrapper > ul { + position: absolute; + max-height: 226px; + overflow-y: scroll; + top: 100%; + left: 0; + right: 0; + padding: 0; + margin: 0.5rem 0 0 0; + border-radius: 0.6rem; + background-color: #fff; + box-shadow: 0 3px 6px rgba(149, 157, 165, 0.15); + border: 1px solid rgba(33, 33, 33, 0.07); + z-index: 1000; + outline: none; +} + +.autoComplete_wrapper > ul[hidden], +.autoComplete_wrapper > ul:empty { + display: block; + opacity: 0; + transform: scale(0); +} + +.autoComplete_wrapper > ul > li { + margin: 0.3rem; + padding: 0.3rem 0.5rem; + list-style: none; + text-align: left; + font-size: 1rem; + color: #212121; + transition: all 0.1s ease-in-out; + border-radius: 0.35rem; + background-color: rgba(255, 255, 255, 1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.2s ease; +} + +.autoComplete_wrapper > ul > li::selection { + color: rgba(#ffffff, 0); + background-color: rgba(#ffffff, 0); +} + +.autoComplete_wrapper > ul > li:hover { + cursor: pointer; + background-color: rgba(123, 123, 123, 0.1); +} + +.autoComplete_wrapper > ul > li mark { + background-color: transparent; + color: rgba(255, 122, 122, 1); + font-weight: bold; +} + +.autoComplete_wrapper > ul > li mark::selection { + color: rgba(#ffffff, 0); + background-color: rgba(#ffffff, 0); +} + +.autoComplete_wrapper > ul > li[aria-selected="true"] { + background-color: rgba(123, 123, 123, 0.1); +} + +@media only screen and (max-width: 600px) { + .autoComplete_wrapper > input { + width: 18rem; + } +} diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.02.css b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.02.css new file mode 100644 index 000000000..e9e233cc2 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.02.css @@ -0,0 +1,82 @@ +.autoComplete_wrapper { + display: inline-block; + position: relative; +} + +.autoComplete_wrapper > input { + width: 370px; + height: 40px; + padding-left: 10px; + font-size: 1rem; + color: rgb(116, 116, 116); + border-radius: 4px; + border: 1px solid rgba(33, 33, 33, 0.2); + outline: none; +} + +.autoComplete_wrapper > input::placeholder { + color: rgba(123, 123, 123, 0.5); + transition: all 0.3s ease; +} + +.autoComplete_wrapper > ul { + position: absolute; + max-height: 226px; + overflow-y: scroll; + top: 100%; + left: 0; + right: 0; + padding: 0; + margin: 0.5rem 0 0 0; + border-radius: 4px; + background-color: #fff; + border: 1px solid rgba(33, 33, 33, 0.1); + z-index: 1000; + outline: none; +} + +.autoComplete_wrapper > ul > li { + padding: 10px 20px; + list-style: none; + text-align: left; + font-size: 16px; + color: #212121; + transition: all 0.1s ease-in-out; + border-radius: 3px; + background-color: rgba(255, 255, 255, 1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.2s ease; +} + +.autoComplete_wrapper > ul > li::selection { + color: rgba(#ffffff, 0); + background-color: rgba(#ffffff, 0); +} + +.autoComplete_wrapper > ul > li:hover { + cursor: pointer; + background-color: rgba(123, 123, 123, 0.1); +} + +.autoComplete_wrapper > ul > li mark { + background-color: transparent; + color: rgba(255, 122, 122, 1); + font-weight: bold; +} + +.autoComplete_wrapper > ul > li mark::selection { + color: rgba(#ffffff, 0); + background-color: rgba(#ffffff, 0); +} + +.autoComplete_wrapper > ul > li[aria-selected="true"] { + background-color: rgba(123, 123, 123, 0.1); +} + +@media only screen and (max-width: 600px) { + .autoComplete_wrapper > input { + width: 18rem; + } +} diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.css b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.css new file mode 100644 index 000000000..3bd94d292 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/css/autoComplete.css @@ -0,0 +1,128 @@ +.autoComplete_wrapper { + display: inline-block; + position: relative; +} + +.autoComplete_wrapper > input { + height: 3rem; + width: 370px; + margin: 0; + padding: 0 2rem 0 3.2rem; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + font-size: 1rem; + text-overflow: ellipsis; + color: rgba(255, 122, 122, 0.3); + outline: none; + border-radius: 10rem; + border: 0.05rem solid rgba(255, 122, 122, 0.5); + background-image: url(./images/search.svg); + background-size: 1.4rem; + background-position: left 1.05rem top 0.8rem; + background-repeat: no-repeat; + background-origin: border-box; + background-color: #fff; + transition: all 0.4s ease; + -webkit-transition: all -webkit-transform 0.4s ease; +} + +.autoComplete_wrapper > input::placeholder { + color: rgba(255, 122, 122, 0.5); + transition: all 0.3s ease; + -webkit-transition: all -webkit-transform 0.3s ease; +} + +.autoComplete_wrapper > input:hover::placeholder { + color: rgba(255, 122, 122, 0.6); + transition: all 0.3s ease; + -webkit-transition: all -webkit-transform 0.3s ease; +} + +.autoComplete_wrapper > input:focus::placeholder { + padding: 0.1rem 0.6rem; + font-size: 0.95rem; + color: rgba(255, 122, 122, 0.4); +} + +.autoComplete_wrapper > input:focus::selection { + background-color: rgba(255, 122, 122, 0.15); +} + +.autoComplete_wrapper > input::selection { + background-color: rgba(255, 122, 122, 0.15); +} + +.autoComplete_wrapper > input:hover { + color: rgba(255, 122, 122, 0.8); + transition: all 0.3s ease; + -webkit-transition: all -webkit-transform 0.3s ease; +} + +.autoComplete_wrapper > input:focus { + color: rgba(255, 122, 122, 1); + border: 0.06rem solid rgba(255, 122, 122, 0.8); +} + +.autoComplete_wrapper > ul { + position: absolute; + max-height: 226px; + overflow-y: scroll; + box-sizing: border-box; + left: 0; + right: 0; + margin: 0.5rem 0 0 0; + padding: 0; + z-index: 1; + list-style: none; + border-radius: 0.6rem; + background-color: #fff; + border: 1px solid rgba(33, 33, 33, 0.07); + box-shadow: 0 3px 6px rgba(149, 157, 165, 0.15); + outline: none; + transition: opacity 0.15s ease-in-out; + -moz-transition: opacity 0.15s ease-in-out; + -webkit-transition: opacity 0.15s ease-in-out; +} + +.autoComplete_wrapper > ul[hidden], +.autoComplete_wrapper > ul:empty { + display: block; + opacity: 0; + transform: scale(0); +} + +.autoComplete_wrapper > ul > li { + margin: 0.3rem; + padding: 0.3rem 0.5rem; + text-align: left; + font-size: 1rem; + color: #212121; + border-radius: 0.35rem; + background-color: rgba(255, 255, 255, 1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.2s ease; +} + +.autoComplete_wrapper > ul > li mark { + background-color: transparent; + color: rgba(255, 122, 122, 1); + font-weight: bold; +} + +.autoComplete_wrapper > ul > li:hover { + cursor: pointer; + background-color: rgba(255, 122, 122, 0.15); +} + +.autoComplete_wrapper > ul > li[aria-selected="true"] { + background-color: rgba(255, 122, 122, 0.15); +} + +@media only screen and (max-width: 600px) { + .autoComplete_wrapper > input { + width: 18rem; + } +} diff --git a/app/static/libjs/autoComplete.js-10.2.7/dist/css/images/search.svg b/app/static/libjs/autoComplete.js-10.2.7/dist/css/images/search.svg new file mode 100644 index 000000000..8063ea104 --- /dev/null +++ b/app/static/libjs/autoComplete.js-10.2.7/dist/css/images/search.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/app/tables/recap.py b/app/tables/recap.py index 73764ed22..5fca29127 100644 --- a/app/tables/recap.py +++ b/app/tables/recap.py @@ -479,17 +479,18 @@ class TableRecap(tb.Table): for row in self.rows: etud = row.etud admission = etud.admission.first() - first = True - for cid, title in fields.items(): - cell = row.add_cell( - cid, - title, - getattr(admission, cid) or "", - "admission", - ) - if first: - cell.classes.append("admission_first") - first = False + if admission: + first = True + for cid, title in fields.items(): + cell = row.add_cell( + cid, + title, + getattr(admission, cid) or "", + "admission", + ) + if first: + cell.classes.append("admission_first") + first = False def add_cursus(self): """Ajoute colonne avec code cursus, eg 'S1 S2 S1' diff --git a/app/templates/base.j2 b/app/templates/base.j2 index 542a161cf..1be73c172 100644 --- a/app/templates/base.j2 +++ b/app/templates/base.j2 @@ -85,7 +85,15 @@ {{ super() }} {{ moment.include_moment() }} {{ moment.lang(g.locale) }} + + + + + + + + {% endblock %} \ No newline at end of file diff --git a/app/templates/entreprises/form_ajout_stage_apprentissage.j2 b/app/templates/entreprises/form_ajout_stage_apprentissage.j2 index ba25df2af..071e4d1fc 100644 --- a/app/templates/entreprises/form_ajout_stage_apprentissage.j2 +++ b/app/templates/entreprises/form_ajout_stage_apprentissage.j2 @@ -4,33 +4,27 @@ {% block styles %} {{super()}} - - + {% endblock %} {% block app_content %}

    {{ title }}


    -
    +

    (*) champs requis

    {{ wtf.quick_form(form, novalidate=True) }}
    +{% endblock %} +{% block scripts %} +{{super()}} + + -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/sco_value_error.j2 b/app/templates/sco_value_error.j2 index 840d38023..94b603101 100644 --- a/app/templates/sco_value_error.j2 +++ b/app/templates/sco_value_error.j2 @@ -5,7 +5,7 @@

    Erreur !

    -{{ exc }} +{{ exc | safe }}

    {% if g.scodoc_dept %} diff --git a/app/views/notes.py b/app/views/notes.py index 47f977843..4d8c38914 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -496,7 +496,7 @@ sco_publish( @permission_required(Permission.ScoView) @scodoc7func def formation_table_recap(formation_id, format="html"): - return sco_formation_recap.formation_table_recap(formation_id, format="html") + return sco_formation_recap.formation_table_recap(formation_id, format=format) sco_publish( @@ -1874,12 +1874,6 @@ sco_publish( Permission.ScoEnsView, ) sco_publish("/saisie_notes", sco_saisie_notes.saisie_notes, Permission.ScoEnsView) -sco_publish( - "/save_note", - sco_saisie_notes.save_note, - Permission.ScoEnsView, - methods=["GET", "POST"], -) sco_publish( "/do_evaluation_set_missing", sco_saisie_notes.do_evaluation_set_missing, diff --git a/app/views/scolar.py b/app/views/scolar.py index a53df3f31..19ec1c7d2 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -1306,6 +1306,8 @@ def _do_cancel_dem_or_def( db.session.delete(event) db.session.commit() + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) + flash(f"{operation_name} annulée.") return flask.redirect( url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) @@ -1755,9 +1757,7 @@ def _etudident_create_or_edit_form(edit): # Inval semesters with this student: to_inval = [s["formsemestre_id"] for s in etud["sems"]] for formsemestre_id in to_inval: - sco_cache.invalidate_formsemestre( - formsemestre_id=formsemestre_id - ) # > etudident_create_or_edit + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) # return flask.redirect( url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) @@ -1833,7 +1833,7 @@ def etudident_delete(etudid, dialog_confirmed=False): # Inval semestres où il était inscrit: to_inval = [s["formsemestre_id"] for s in etud["sems"]] for formsemestre_id in to_inval: - sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) # > + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) flash("Étudiant supprimé !") return flask.redirect(scu.ScoURL()) diff --git a/migrations/versions/d84bc592584e_extension_unaccent.py b/migrations/versions/d84bc592584e_extension_unaccent.py new file mode 100644 index 000000000..041da64ce --- /dev/null +++ b/migrations/versions/d84bc592584e_extension_unaccent.py @@ -0,0 +1,77 @@ +"""Extension unaccent + +Revision ID: d84bc592584e +Revises: b8df1b913c79 +Create Date: 2023-06-01 13:46:52.927951 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker # added by ev + + +# revision identifiers, used by Alembic. +revision = "d84bc592584e" +down_revision = "b8df1b913c79" +branch_labels = None +depends_on = None + +Session = sessionmaker() + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + bind = op.get_bind() + session = Session(bind=bind) + # Ajout extension pour recherches sans accents: + session.execute(sa.text("""CREATE EXTENSION IF NOT EXISTS "unaccent";""")) + + # Clé étrangère sur identite + session.execute( + sa.text( + """UPDATE are_stages_apprentissages + SET etudid = NULL + WHERE are_stages_apprentissages.etudid NOT IN ( + SELECT id + FROM Identite + ); + """ + ) + ) + with op.batch_alter_table("are_stages_apprentissages", schema=None) as batch_op: + batch_op.create_foreign_key( + "are_stages_apprentissages_etudid_fkey", + "identite", + ["etudid"], + ["id"], + ondelete="CASCADE", + ) + + # Les montants de taxe en float: + with op.batch_alter_table("are_taxe_apprentissage", schema=None) as batch_op: + batch_op.alter_column( + "montant", + existing_type=sa.INTEGER(), + type_=sa.Float(), + existing_nullable=True, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("are_taxe_apprentissage", schema=None) as batch_op: + batch_op.alter_column( + "montant", + existing_type=sa.Float(), + type_=sa.INTEGER(), + existing_nullable=True, + ) + + with op.batch_alter_table("are_stages_apprentissages", schema=None) as batch_op: + batch_op.drop_constraint( + "are_stages_apprentissages_etudid_fkey", type_="foreignkey" + ) + + # ### end Alembic commands ### diff --git a/sco_version.py b/sco_version.py index 071863e32..5b5fa3a00 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.4.77" +SCOVERSION = "9.4.82" SCONAME = "ScoDoc" diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py index 203c587ad..56f3193d3 100644 --- a/tests/api/test_api_etudiants.py +++ b/tests/api/test_api_etudiants.py @@ -224,6 +224,32 @@ def test_etudiants(api_headers): assert r.status_code == 404 +def test_etudiants_by_name(api_headers): + """ + Route: /etudiants/name/ + """ + r = requests.get( + API_URL + "/etudiants/name/A", + headers=api_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 200 + etuds = r.json() + assert etuds == [] + # + r = requests.get( + API_URL + "/etudiants/name/REG", + headers=api_headers, + verify=CHECK_CERTIFICATE, + timeout=scu.SCO_TEST_API_TIMEOUT, + ) + assert r.status_code == 200 + etuds = r.json() + assert len(etuds) == 1 + assert etuds[0]["nom"] == "RÉGNIER" + + def test_etudiant_formsemestres(api_headers): """ Route: /etudiant/etudid//formsemestres diff --git a/tests/api/test_api_evaluations.py b/tests/api/test_api_evaluations.py index 4a3064746..9e1de1e23 100644 --- a/tests/api/test_api_evaluations.py +++ b/tests/api/test_api_evaluations.py @@ -24,7 +24,7 @@ from tests.api.setup_test_api import API_URL, CHECK_CERTIFICATE, api_headers from tests.api.tools_test_api import ( verify_fields, EVALUATIONS_FIELDS, - EVALUATION_FIELDS, + NOTES_FIELDS, ) @@ -35,7 +35,7 @@ def test_evaluations(api_headers): Route : - /moduleimpl//evaluations """ - moduleimpl_id = 1 + moduleimpl_id = 20 r = requests.get( f"{API_URL}/moduleimpl/{moduleimpl_id}/evaluations", headers=api_headers, @@ -44,6 +44,7 @@ def test_evaluations(api_headers): ) assert r.status_code == 200 list_eval = r.json() + assert list_eval assert isinstance(list_eval, list) for eval in list_eval: assert verify_fields(eval, EVALUATIONS_FIELDS) is True @@ -63,16 +64,14 @@ def test_evaluations(api_headers): assert eval["moduleimpl_id"] == moduleimpl_id -def test_evaluation_notes( - api_headers, -): # XXX TODO changer la boucle pour parcourir le dict sans les indices +def test_evaluation_notes(api_headers): """ Test 'evaluation_notes' Route : - /evaluation//notes """ - eval_id = 1 + eval_id = 20 r = requests.get( f"{API_URL}/evaluation/{eval_id}/notes", headers=api_headers, @@ -81,14 +80,15 @@ def test_evaluation_notes( ) assert r.status_code == 200 eval_notes = r.json() - for i in range(1, len(eval_notes)): - assert verify_fields(eval_notes[f"{i}"], EVALUATION_FIELDS) - assert isinstance(eval_notes[f"{i}"]["id"], int) - assert isinstance(eval_notes[f"{i}"]["etudid"], int) - assert isinstance(eval_notes[f"{i}"]["evaluation_id"], int) - assert isinstance(eval_notes[f"{i}"]["value"], float) - assert isinstance(eval_notes[f"{i}"]["comment"], str) - assert isinstance(eval_notes[f"{i}"]["date"], str) - assert isinstance(eval_notes[f"{i}"]["uid"], int) + assert eval_notes + for etudid, note in eval_notes.items(): + assert int(etudid) == note["etudid"] + assert verify_fields(note, NOTES_FIELDS) + assert isinstance(note["etudid"], int) + assert isinstance(note["evaluation_id"], int) + assert isinstance(note["value"], float) + assert isinstance(note["comment"], str) + assert isinstance(note["date"], str) + assert isinstance(note["uid"], int) - assert eval_id == eval_notes[f"{i}"]["evaluation_id"] + assert eval_id == note["evaluation_id"] diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index 602834f1d..70d214467 100644 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -58,6 +58,7 @@ def test_permissions(api_headers): "nip": 1, "partition_id": 1, "role_name": "Ens", + "start": "abc", "uid": 1, "version": "long", } diff --git a/tests/api/tools_test_api.py b/tests/api/tools_test_api.py index b277dd281..b5aeae34f 100644 --- a/tests/api/tools_test_api.py +++ b/tests/api/tools_test_api.py @@ -568,8 +568,7 @@ EVALUATIONS_FIELDS = { "visi_bulletin", } -EVALUATION_FIELDS = { - "id", +NOTES_FIELDS = { "etudid", "evaluation_id", "value", diff --git a/tests/ressources/formations/scodoc_formation_BUT_INFO_v0514.xml b/tests/ressources/formations/scodoc_formation_BUT_INFO_v0514.xml new file mode 100644 index 000000000..dcaba6560 --- /dev/null +++ b/tests/ressources/formations/scodoc_formation_BUT_INFO_v0514.xml @@ -0,0 +1,554 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/unit/test_sco_basic.py b/tests/unit/test_sco_basic.py index 770fbf4e5..5472c17b6 100644 --- a/tests/unit/test_sco_basic.py +++ b/tests/unit/test_sco_basic.py @@ -103,14 +103,14 @@ def run_sco_basic(verbose=False) -> FormSemestre: # --- Saisie toutes les notes de l'évaluation for idx, etud in enumerate(etuds): - nb_changed, nb_suppress, existing_decisions = G.create_note( + etudids_changed, nb_suppress, existing_decisions = G.create_note( evaluation_id=e["id"], etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], ) assert not existing_decisions assert nb_suppress == 0 - assert nb_changed == 1 + assert len(etudids_changed) == 1 # --- Vérifie que les notes sont prises en compte: b = sco_bulletins.formsemestre_bulletinetud_dict(formsemestre_id, etud["etudid"]) @@ -136,7 +136,7 @@ def run_sco_basic(verbose=False) -> FormSemestre: ) # Saisie les notes des 5 premiers étudiants: for idx, etud in enumerate(etuds[:5]): - nb_changed, nb_suppress, existing_decisions = G.create_note( + etudids_changed, nb_suppress, existing_decisions = G.create_note( evaluation_id=e2["id"], etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], @@ -158,7 +158,7 @@ def run_sco_basic(verbose=False) -> FormSemestre: # Saisie des notes qui manquent: for idx, etud in enumerate(etuds[5:]): - nb_changed, nb_suppress, existing_decisions = G.create_note( + etudids_changed, nb_suppress, existing_decisions = G.create_note( evaluation_id=e2["id"], etudid=etud["etudid"], note=NOTES_T[idx % len(NOTES_T)], diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py index cd06048b0..401f33099 100644 --- a/tools/fakedatabase/create_test_api_database.py +++ b/tools/fakedatabase/create_test_api_database.py @@ -239,7 +239,7 @@ def create_evaluations(formsemestre: FormSemestre): "jour": datetime.date(2022, 3, 1) + datetime.timedelta(days=modimpl.id), "heure_debut": "8h00", "heure_fin": "9h00", - "description": None, + "description": f"Evaluation-{modimpl.module.code}", "note_max": 20, "coefficient": 1.0, "visibulletin": True,