diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 386f900304d26810bbad21e92f0aab6a9fffdb63..eeac44d88533f2e24010eb2967c1b7da973fd962 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -80,9 +80,11 @@ def _menu_scolarite( title="Semestre verrouillé", ) return lockicon # no menu - if not authuser.has_permission( - Permission.EtudInscrit - ) and not authuser.has_permission(Permission.EtudChangeGroups): + if ( + not authuser.has_permission(Permission.EtudInscrit) + and not authuser.has_permission(Permission.EtudChangeGroups) + and authuser.id not in [resp.id for resp in formsemestre.responsables] + ): return "" # no menu args = {"etudid": etudid, "formsemestre_id": formsemestre.id} @@ -106,6 +108,13 @@ def _menu_scolarite( and authuser.has_permission(Permission.EtudInscrit) and not locked ) + note_enabled = etat_inscription == "I" and ( + etat_inscription != scu.DEMISSION + and ( + authuser.has_permission(Permission.EditAllNotes) + or (authuser.id in [resp.id for resp in formsemestre.responsables]) + ) + ) items = [ { "title": dem_title, @@ -161,6 +170,12 @@ def _menu_scolarite( "args": {"etudid": etudid}, "enabled": authuser.has_permission(Permission.EtudInscrit), }, + { + "title": "Gérer les notes", + "endpoint": "notes.form_saisie_notes_par_etu", + "args": {"etu_id": etudid, "semestre_id": formsemestre.id}, + "enabled": note_enabled, + }, ] return htmlutils.make_menu( @@ -215,9 +230,9 @@ def fiche_etud(etudid=None): info[ "modifadresse" ] = f"""<a class="stdlink" href="{ - url_for("scolar.form_change_coordonnees", - scodoc_dept=g.scodoc_dept, etudid=etudid) - }">modifier adresse</a>""" + url_for("scolar.form_change_coordonnees", + scodoc_dept=g.scodoc_dept, etudid=etudid) + }">modifier adresse</a>""" else: info["modifadresse"] = "" @@ -260,8 +275,8 @@ def fiche_etud(etudid=None): grlinks.append( f"""<a class="discretelink" href="{ url_for('scolar.groups_lists', - scodoc_dept=g.scodoc_dept, group_ids=partition['group_id']) - }" title="Liste du groupe {gr_name}">{gr_name}</a> + scodoc_dept=g.scodoc_dept, group_ids=partition['group_id']) + }" title="Liste du groupe {gr_name}">{gr_name}</a> """ ) grlink = ", ".join(grlinks) @@ -289,8 +304,8 @@ def fiche_etud(etudid=None): else f""" <span class="link_bul_pdf"> <a class="stdlink" href="{ - url_for("notes.etud_bulletins_pdf", scodoc_dept=g.scodoc_dept, etudid=etudid) - }">Tous les bulletins</a> + url_for("notes.etud_bulletins_pdf", scodoc_dept=g.scodoc_dept, etudid=etudid) + }">Tous les bulletins</a> </span> """ ) @@ -301,16 +316,16 @@ def fiche_etud(etudid=None): ] += f""" <span class="link_bul_pdf"> <a class="stdlink" href="{ - url_for("notes.validation_rcues", + url_for("notes.validation_rcues", scodoc_dept=g.scodoc_dept, etudid=etudid, formsemestre_id=last_formsemestre.id) - }">Visualiser les compétences BUT</a> + }">Visualiser les compétences BUT</a> </span> """ info["link_inscrire_ailleurs"] = ( f"""<span class="link_bul_pdf"><a class="stdlink" href="{ - url_for("notes.formsemestre_inscription_with_modules_form", - scodoc_dept=g.scodoc_dept, etudid=etudid) - }">Inscrire à un autre semestre</a></span> + url_for("notes.formsemestre_inscription_with_modules_form", + scodoc_dept=g.scodoc_dept, etudid=etudid) + }">Inscrire à un autre semestre</a></span> """ if current_user.has_permission(Permission.EtudInscrit) else "" @@ -320,26 +335,26 @@ def fiche_etud(etudid=None): "link_inscrire_ailleurs" ] += f""" <span class="link_bul_pdf"><a class="stdlink" href="{ - url_for("notes.jury_delete_manual", + url_for("notes.jury_delete_manual", scodoc_dept=g.scodoc_dept, etudid=etudid, read_only=not can_edit_jury) - }">{'Éditer' if can_edit_jury else 'Détail de'} toutes décisions de jury</a></span> + }">{'Éditer' if can_edit_jury else 'Détail de'} toutes décisions de jury</a></span> """ info[ "link_bilan_ects" ] = f"""<span class="link_bul_pdf"><a class="stdlink" href="{ - url_for("notes.etud_bilan_ects", + url_for("notes.etud_bilan_ects", scodoc_dept=g.scodoc_dept, etudid=etudid) - }">ECTS</a></span>""" + }">ECTS</a></span>""" else: # non inscrit l = [f"""<p><b>Étudiant{etud.e} non inscrit{etud.e}"""] if current_user.has_permission(Permission.EtudInscrit): l.append( f"""<a href="{ - url_for("notes.formsemestre_inscription_with_modules_form", - scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("notes.formsemestre_inscription_with_modules_form", + scodoc_dept=g.scodoc_dept, etudid=etudid) }">inscrire</a></li>""" ) l.append("</b></b>") @@ -488,10 +503,10 @@ def fiche_etud(etudid=None): validation_dut120_html = ( f"""Diplôme DUT décerné en <a class="stdlink" href="{ - url_for("notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=validation_dut120.formsemestre.id) - }">S{validation_dut120.formsemestre.semestre_id}</a> + url_for("notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=validation_dut120.formsemestre.id) + }">S{validation_dut120.formsemestre.semestre_id}</a> """ if validation_dut120 else "" @@ -502,16 +517,16 @@ def fiche_etud(etudid=None): ] = f""" <div class="section_but"> {render_template( - "but/cursus_etud.j2", - cursus=but_cursus, - scu=scu, - validation_dut120_html=validation_dut120_html, - ) if but_cursus else '<span class="pb-config">problème configuration formation BUT</span>'} + "but/cursus_etud.j2", + cursus=but_cursus, + scu=scu, + validation_dut120_html=validation_dut120_html, + ) if but_cursus else '<span class="pb-config">problème configuration formation BUT</span>'} <div class="fiche_but_col2"> <div class="link_validation_rcues"> <a class="stdlink" href="{url_for("notes.validation_rcues", - scodoc_dept=g.scodoc_dept, etudid=etudid, - formsemestre_id=last_formsemestre.id)}" + scodoc_dept=g.scodoc_dept, etudid=etudid, + formsemestre_id=last_formsemestre.id)}" title="Visualiser les compétences BUT" > <img src="/ScoDoc/static/icons/parcours-but.png" alt="validation_rcues" height="132px"/> @@ -726,7 +741,7 @@ def _infos_admission(etud: Identite, restrict_etud_data: bool) -> dict: return { # infos accessibles à tous: - "bac_specialite": f"{etud.admission.bac or ''}{(' '+(etud.admission.specialite or '')) if etud.admission.specialite else ''}", + "bac_specialite": f"{etud.admission.bac or ''}{(' ' + (etud.admission.specialite or '')) if etud.admission.specialite else ''}", "annee_bac": etud.admission.annee_bac or "", # infos protégées par ViewEtudData: "info_lycee": info_lycee, @@ -757,15 +772,15 @@ def get_html_annotations_list(etud: Identite) -> list[str]: for annot in annotations: del_link = ( f"""<td class="annodel"><a href="{ - url_for("scolar.doSuppressAnnotation", + url_for("scolar.doSuppressAnnotation", scodoc_dept=g.scodoc_dept, etudid=etud.id, annotation_id=annot.id)}">{ - scu.icontag( - "delete_img", - border="0", - alt="suppress", - title="Supprimer cette annotation", - ) - }</a></td>""" + scu.icontag( + "delete_img", + border="0", + alt="suppress", + title="Supprimer cette annotation", + ) + }</a></td>""" if sco_permissions_check.can_suppress_annotation(annot.id) else "" ) @@ -773,7 +788,7 @@ def get_html_annotations_list(etud: Identite) -> list[str]: author = User.query.filter_by(user_name=annot.author).first() html_annotations_list.append( f"""<tr><td><span class="annodate">Le { - annot.date.strftime(scu.DATE_FMT) if annot.date else "?"} + annot.date.strftime(scu.DATE_FMT) if annot.date else "?"} par {author.get_prenomnom() if author else "?"} : </span><span class="annoc">{annot.comment or ""}</span></td>{del_link}</tr> """ @@ -854,8 +869,8 @@ def etud_info_html(etudid, with_photo="1", debug=False): H = f"""<div class="etud_info_div"> <div class="eid_left"> <div class="eid_nom"><div><a class="stdlink" target="_blank" href="{ - url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) - }">{etud.nomprenom}</a></div></div> + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) + }">{etud.nomprenom}</a></div></div> <div class="eid_info eid_bac">Bac: <span class="eid_bac">{bac_abbrev}</span></div> <div class="eid_info eid_parcours">{code_cursus}</div> """ diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index 79d55c0b65c52f39f1b96f2c46ae6b67a1449af0..ea31e95380446ac4ad6ddfc9783a45590fb32291 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -29,7 +29,6 @@ Formulaire revu en juillet 2016 import html import time - import flask from flask import g, render_template, url_for from flask_login import current_user @@ -47,6 +46,7 @@ from app.models import ( ModuleImpl, ScolarNews, Assiduite, + NotesNotes, ) from app.models.etudiants import Identite @@ -68,6 +68,7 @@ from app.scodoc import sco_undo_notes import app.scodoc.notesdb as ndb from app.scodoc.TrivialFormulator import TF import app.scodoc.sco_utils as scu +from app.scodoc.sco_permissions import Permission from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import ModuleType from app.views import ScoData @@ -111,6 +112,26 @@ def convert_note_from_string( return note_value, invalid +def get_bounds(evaluation: Evaluation) -> tuple[int, int]: + """Calcule les notes min et max pour une évaluation.""" + 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, + ): + if evaluation.evaluation_type == Evaluation.EVALUATION_BONUS: + note_min, note_max = -20, 20 + else: + note_min = scu.NOTES_MIN + elif module.module_type == ModuleType.MALUS: + note_min = -20.0 + else: + raise ValueError("Invalid module type") # bug + return note_min, note_max + + def check_notes( notes: list[(int, float | str)], evaluation: Evaluation ) -> tuple[list[tuple[int, float]], list[int], list[int], list[int], list[int]]: @@ -128,24 +149,10 @@ def check_notes( etudids_non_inscrits : etudid non inscrits à ce module (ne considère pas l'inscr. au semestre) """ - 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, - ): - if evaluation.evaluation_type == Evaluation.EVALUATION_BONUS: - note_min, note_max = -20, 20 - else: - note_min = scu.NOTES_MIN - elif module.module_type == ModuleType.MALUS: - note_min = -20.0 - else: - raise ValueError("Invalid module type") # bug + note_min, note_max = get_bounds(evaluation) # Vérifie inscription au module (même DEM/DEF) etudids_inscrits_mod = { - i.etudid for i in evaluation.moduleimpl.query_inscriptions() + i.etudid for i in evaluation.moduleimpl.query_inscriptions().all() } valid_notes = [] etudids_invalids = [] @@ -256,7 +263,7 @@ def do_evaluation_set_missing( "sco_page.j2", content=f""" <h2>{diag}</h2> - <p><a href="{ dest_url }"> + <p><a href="{dest_url}"> Recommencer</a> </p> """, @@ -313,11 +320,11 @@ def do_evaluation_set_missing( Revenir au formulaire de saisie des notes</a> </li> <li><a class="stdlink" href="{ - url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=evaluation.moduleimpl_id, - )}">Tableau de bord du module</a> + url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=evaluation.moduleimpl_id, + )}">Tableau de bord du module</a> </li> </ul> """, @@ -455,11 +462,11 @@ def notes_add( i.etudid for i in evaluation.moduleimpl.query_inscriptions() } # Les étudiants inscrits au semestre et ceux "actifs" (ni DEM ni DEF) - etudids_inscrits_sem, etudids_actifs = ( - evaluation.moduleimpl.formsemestre.etudids_actifs() - ) + ( + etudids_inscrits_sem, + etudids_actifs, + ) = evaluation.moduleimpl.formsemestre.etudids_actifs() for etudid, value in notes: - if check_inscription: _check_inscription(etudid, etudids_inscrits_sem, etudids_inscrits_mod) @@ -610,7 +617,7 @@ def _record_note( if do_it: log( f"""notes_add, suppress, evaluation_id={evaluation_id}, etudid={ - etudid}, oldval={oldval}""" + etudid}, oldval={oldval}""" ) cursor.execute( """DELETE FROM notes_notes @@ -652,7 +659,7 @@ def saisie_notes(evaluation: Evaluation, group_ids: list[int] | tuple[int] = ()) <p>(vérifiez que le semestre n'est pas verrouillé et que vous avez l'autorisation d'effectuer cette opération)</p> - <p><a href="{ moduleimpl_status_url }">Continuer</a> + <p><a href="{moduleimpl_status_url}">Continuer</a> </p> """, ) @@ -762,11 +769,162 @@ def saisie_notes(evaluation: Evaluation, group_ids: list[int] | tuple[int] = ()) "sco_page.j2", content="\n".join(H), title=page_title, - javascripts=["js/groups_view.js", "js/saisie_notes.js"], + javascripts=[ + "js/groups_view.js", + "js/saisie_notes.js", + "js/saisie_notes_par_eval.js", + ], sco=ScoData(formsemestre=modimpl.formsemestre), ) +# US 1030 - DEB + + +class DataSection: + """Agrège les données sur un ensemble de module (Ressources, SAEs, Standard, Malus). + Nécessaire pour garantir l'ordre de présentation pour les formations en apc. + data_modmpl: la liste des modules de la section. + """ + + def __init__(self, module_type): + self.name = scu.MODULE_TYPE_NAMES[module_type] + self.data_modimpl = [] + + def add_modimpl(self, modimpl): + self.data_modimpl.append(modimpl) + + +class DataModimpl: + """Aggrèges les données sur un module pour un étudiants donné. + # récupére les notes sur les années précédentes pour aider à la saisie des cursus adaptés (cursus) + # (sportifs, étalement sur plusieurs années, etc.). + data_evals: lal iste des évaluations du module. + """ + + def __init__(self, etudiant: Identite, modimpl): + self.etudiant = etudiant + self.data_evals = [] + self.modimpl = modimpl + self.modimpl_id = modimpl.id + self.module_code = modimpl.module.code + self.titre = modimpl.module.titre + href = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=modimpl.id, + ) + self.link = ( + f'<a class="stdlink" href="{href}">{self.module_code} {self.titre}</a>.' + ) + # self.cursus = {} + formation_id = modimpl.formsemestre.formation_id + # for ins in etudiant.inscriptions(): + # if ins.formsemestre.formation_id == formation_id: + # self.cursus[ins.formsemestre.id] = ins.formsemestre + + def add_eval(self, data_eval): + self.data_evals.append(data_eval) + + +class DataEval: + """Récupère les données sur une évaluation appliquée à un étudiant""" + + def __init__(self, etudid, evaluation): + self.etudid = etudid + self.evaluation = evaluation + self.evaluation_id = evaluation.id + self.note_min, self.note_max = get_bounds(evaluation) + self.data_debut = evaluation.date_fin + self.date_fin = evaluation.date_fin + self.description = evaluation.description + self.ue_poids = evaluation.get_ue_poids_dict() + self.evaluation_type = evaluation.evaluation_type + self.note = NotesNotes.query.filter_by( + evaluation_id=self.evaluation.id, etudid=self.etudid + ).first() + self.note_value = "" if self.note is None else self.note.value + self.history = f"""<span id="formnotes_hist_{self.evaluation_id}">{ + get_note_history_menu(evaluation.id, etudid) + }</span>""" + + def forminput(self): + return f"""<input type="text" size="5" id="formnotes_note_{self.evaluation_id}" + class="note" onkeypress="return enter_focus_next(this, event);" + note_min="{self.note_min}" note_max="{self.note_max}" evaluation_id="{self.evaluation_id}" + original-value="{self.note_value}" value="{self.note_value}" />""" + + +def get_data(semestre: FormSemestre, etudiant: Identite) -> dict: + """Récupère les infos pour un étudiant (cf ticket 1030)""" + res = res_sem.load_formsemestre_results(semestre) + data = { + "decision_jury": res.etud_has_decision(etudiant.etudid, include_rcues=False), + "data_sections": {}, + } + if semestre.formation.is_apc(): + for module_type in ModuleType: + data["data_sections"][module_type] = DataSection(module_type) + for modimpl in semestre.modimpls_sorted: + module_type = modimpl.module.module_type + if res.modimpl_inscr_df[modimpl.id][etudiant.id]: + data_modimpl = DataModimpl(etudiant, modimpl) + for evaluation in modimpl.evaluations: + data_modimpl.add_eval(DataEval(etudiant.etudid, evaluation)) + data["data_sections"][module_type].add_modimpl(data_modimpl) + return data + + +def saisie_notes_par_etu(semestre: FormSemestre, etudiant: Identite): + # Check access + # (admin, respformation, and responsable_id) + locked = not semestre.etat + # readonly peut être uinutile si l accès à cette page n est pas autorisée au public + readonly = locked or not ( + current_user.has_permission(Permission.EditAllNotes) + or (current_user in semestre.responsables) + ) + inscriptions = etudiant.formsemestre_inscriptions + inscription = None + for ins in inscriptions: + if ins.formsemestre_id == semestre.id: + inscription = ins + # assert ins is not None + if ins is None: + raise ValueError("Tentative d accès à une inscription inexistante") # bug + if ins.etat != "I": + raise ValueError( + "Etudiant démissionnaire ou défaillant" + ) # ne devrait pas être accessible. bug + "Formulaire de saisie de note centré sur l'étudiant" + data = get_data(semestre, etudiant) + return render_template( + "etud/saisie_notes_par_etu.j2", + title="Saisie notes par étudiant", + javascripts=["js/saisie_notes.js", "js/saisie_notes_par_etu.js"], + sco=ScoData(formsemestre=semestre), + semestre=semestre, + etudiant=etudiant, + data=data, + readonly=readonly, + get_note_history_menu=get_note_history_menu, + module_type_order=[ + ModuleType.RESSOURCE, + ModuleType.SAE, + ModuleType.STANDARD, + ModuleType.MALUS, + ], + ) + + +# US 1030 - FIN + + +def get_evaluation_etud_note(evaluation: Evaluation, etudid: int): + a = Notes.query.filter_by(id=evaluation.id, etudid=etudid).first() + pass + + def get_sorted_etuds_notes( evaluation: Evaluation, etudids: list, formsemestre_id: int ) -> list[dict]: @@ -815,13 +973,13 @@ def get_sorted_etuds_notes( if notes_db[etudid]["uid"] else None ) - e["explanation"] = ( - f"""{ - notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M") + e[ + "explanation" + ] = f"""{ + notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M") } par {user.get_nomplogin() if user else '?' } {(' : ' + notes_db[etudid]["comment"]) if notes_db[etudid]["comment"] else ''} """ - ) else: e["val"] = "" e["explanation"] = "" @@ -941,13 +1099,13 @@ def _form_saisie_notes( etud_classes.append("group-" + str(group_info["group_id"])) label = f"""<span class="{classdem}">{e["civilite_str"]} { - scu.format_nomprenom(e, reverse=True)}{observation}</span>""" + scu.format_nomprenom(e, reverse=True)}{observation}</span>""" # Historique des saisies de notes: explanation = ( "" if disabled - else f"""<span id="hist_{etudid}">{ + else f"""<span id="formnotes_hist_{etudid}">{ get_note_history_menu(evaluation.id, etudid) }</span>""" ) @@ -1008,7 +1166,7 @@ def _form_saisie_notes( H.append(tf.getform()) # check and init H.append( f"""<a href="{url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, - moduleimpl_id=modimpl.id) + moduleimpl_id=modimpl.id) }" class="btn btn-primary link-terminer">Terminer</a> """ ) @@ -1021,14 +1179,14 @@ def _form_saisie_notes( <div> <form id="do_evaluation_set_missing" action="{ url_for("notes.do_evaluation_set_missing", scodoc_dept=g.scodoc_dept) - }" method="POST"> + }" method="POST"> Mettre les notes manquantes à <input type="text" size="5" name="value"/> <input type="submit" value="OK"/> <input type="hidden" name="evaluation_id" value="{evaluation.id}"/> <input class="group_ids_str" type="hidden" name="group_ids_str" value="{ ",".join([str(x) for x in groups_infos.group_ids]) - }"/> + }"/> <em>ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente"</em> </form> </div> @@ -1061,6 +1219,7 @@ def save_notes( 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, messages = notes_add( current_user, evaluation.id, valid_notes, comment=comment, do_it=True @@ -1069,7 +1228,7 @@ def save_notes( typ=ScolarNews.NEWS_NOTE, obj=evaluation.moduleimpl_id, text=f"""Notes dans <a href="{status_url}">{ - evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code}</a>""", + evaluation.moduleimpl.module.titre or evaluation.moduleimpl.module.code}</a>""", url=status_url, max_frequency=30 * 60, # 30 minutes ) @@ -1120,7 +1279,7 @@ def get_note_history_menu(evaluation_id: int, etudid: int) -> str: nv = "" # ne repete pas la valeur de la note courante else: # ancienne valeur - nv = f": {dispnote}" + nv = f": {scu.fmt_note(dispnote)}" first = False if i["comment"]: comment = f' <span class="histcomment">{i["comment"]}</span>' diff --git a/app/static/js/saisie_notes.js b/app/static/js/saisie_notes.js index 4ed8e15760f2ea88da8d3865551c05133f71a2c2..553c30016f7c4322a93da1c394244003d411dd94 100644 --- a/app/static/js/saisie_notes.js +++ b/app/static/js/saisie_notes.js @@ -1,4 +1,44 @@ -// Formulaire saisie des notes +// Procedures communes pour formulaires saisie des notes +/*************************************************** + Pré-requis pour l'utilisation des fonctionnalités communes à saisie_notes_par_etu et saisie_notes_par_eval + * il existe un champ d'id `formnotes_formsemestre_id` qui donne l'id du formsemestre courant + * Les champs de saisie de notes: + * répondent au sélecteur ("#formnotes .note") + * possèdent un id de la forme `formnotes_note_###` (### identifiant étudiant, évaluation ou autre) + * possèdent les attributs initialisés `data-last-saved-value`, `data-original-value` `data_modified` = false; + * Les champs historiques: + * possèdent un id de la forme `hist_###` (### identifiant étudiant, évaluation ou autre) + * les fonctions javascript suivantes sont implémentées: + * get_id(field): str (retourne le ### pour un champ note donné) + * get_evaluation_id(identifiant: str): int + * get formsemestre_id(identifiant: str): int + * get_note_min(identifiant: str): int + * get_note_max(identifiant: str): int + * update_jurylink(note_field: element, etudid: int) + + Fonctionnement: + Un identifiant (field_id) represente un chaîne caractéristique d'une ligne de saisie. + * etudid pour le formulaire de saisie par évaluation + * evaluation_id pour le formulaire de saisie par étudiant + un attribut data-modified inque si un changement a eu lieu sur une zone de saisie. + initialement + l attribut data-modified indique toute modification + dans le traitement on_blur on vérifie si la valeur a été changée ou pas + exemple '15' changée en '15.0' + input => data-modifed = true + blur => rien n'est fait (la valeur n est pas changée) + modification (JMP) + exemple '15' changée en '15.0' + input => data-modified = false + *****************************************************/ + + +const note_prefix = "formnotes_note_"; +const hist_prefix = "formnotes_hist_"; + +function get_formsemestre_id() { + return document.getElementById("formnotes_formsemestre_id").getAttribute("value");; +} let nbSaving = 0; // nombre de requêtes en cours @@ -24,52 +64,41 @@ function decSaving() { } } -document.addEventListener("DOMContentLoaded", function () { - let noteInputs = document.querySelectorAll("#formnotes .note"); +window.addEventListener('beforeunload', function (e) { + const noteInputs = document.querySelectorAll("#formnotes .note"); noteInputs.forEach(function (input) { - input.addEventListener("input", function() { - this.setAttribute("data-modified", "true"); - }); - input.addEventListener("blur", input.addEventListener("blur", function(event){write_on_blur(event.currentTarget)} )); - }); - - var formInputs = document.querySelectorAll("#formnotes input"); - formInputs.forEach(function (input) { - input.addEventListener("paste", paste_text); + if (input.getAttribute("data-modified") === "true" && input.value !== input.getAttribute("data-last-saved-value")) { + valid_note.call(input); + } }); - - var masquerBtn = document.querySelector(".btn_masquer_DEM"); - masquerBtn.addEventListener("click", masquer_DEM); + if (nbSaving > 0) { + // Display a confirmation dialog + const confirmationMessage = 'Des modifications sont en cours de sauvegarde. Êtes-vous sûr de vouloir quitter cette page ?'; + e.preventDefault(); // Standard for most modern browsers + e.returnValue = confirmationMessage; // For compatibility with older browsers + return confirmationMessage; // For compatibility with older browsers + } }); -function write_on_blur(elt) { - if (elt.getAttribute("data-modified") === "true" && elt.value !== elt.getAttribute("data-last-saved-value")) { - valid_note.call(elt); - elt.setAttribute("data-modified", "false"); // Reset the modified flag - } -} - -function is_valid_note(v) { +function is_valid_note(field_id, v) { if (!v) return true; - var note_min = parseFloat(document.querySelector("#eval_note_min").textContent); - var note_max = parseFloat(document.querySelector("#eval_note_max").textContent); - 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; + const x = parseFloat(v); + return x >= get_note_min(field_id) && x <= get_note_max(field_id); } } -function valid_note(e) { - var v = this.value.trim().toUpperCase().replace(",", "."); - if (is_valid_note(v)) { +function valid_note() { + const field_id = get_field_id(this) + const v = this.value.trim().toUpperCase().replace(",", "."); + if (is_valid_note(field_id, v)) { if (v && v != this.getAttribute("data-last-saved-value")) { this.className = "note_valid_new"; - const etudid = parseInt(this.getAttribute("data-etudid")); - save_note(this, v, etudid); + const etudid = get_etudid(field_id); + save_note(field_id, v); } } else { /* Saisie invalide */ @@ -78,10 +107,23 @@ function valid_note(e) { } } -async function save_note(elem, v, etudid) { - let evaluation_id = document.querySelector("#formnotes_evaluation_id").getAttribute("value"); - let formsemestre_id = document.querySelector("#formnotes_formsemestre_id").getAttribute("value"); - var scoMsg = document.getElementById("sco_msg"); +function get_field_id(elt) { // la forme des id est ????_#### (où #### est le field_id que l'on cherche) + return elt.id.split('_').pop(); // formnotes_note_#### ou eval_#### +} + +function write_on_blur(elt) { + if (elt.getAttribute("data-modified") === "true") { + valid_note.call(elt); + elt.setAttribute("data-modified", "false"); // Reset the modified flag + } +} + +async function save_note(field_id, v) { + const evaluation_id = get_evaluation_id(field_id); + const etudid = get_etudid(field_id); + const scoMsg = document.getElementById("sco_msg"); + const note_field = get_note_field(field_id); + const hist_field = get_hist_field(field_id); scoMsg.innerHTML = "en cours..."; scoMsg.style.display = "block"; incSaving(); // update counter to show one more saving in progress @@ -103,106 +145,51 @@ async function save_note(elem, v, etudid) { sco_message("Erreur: valeur non enregistrée"); } else { const data = await response.json(); - var scoMsg = document.getElementById("sco_msg"); scoMsg.style.display = "none"; if (data.etudids_changed.length > 0) { sco_message("enregistré"); - elem.className = "note_saved"; + note_field.className = "note_saved"; // Il y avait une decision de jury ? if (data.etudids_with_decision.includes(etudid)) { - if (v !== elem.getAttribute("data-orig-value")) { - var juryLink = document.getElementById("jurylink_" + etudid); - juryLink.innerHTML = `<a href="${SCO_URL}Notes/formsemestre_validation_etud_form?formsemestre_id=${formsemestre_id}&etudid=${etudid}" - >mettre à jour décision de jury</a>`; - } else { - var juryLink = document.getElementById("jurylink_" + etudid); - juryLink.innerHTML = ""; - } + update_jurylink(field_id, v); } // Mise à jour menu historique if (data.history_menu[etudid]) { - var historyElem = document.getElementById("hist_" + etudid); - historyElem.innerHTML = data.history_menu[etudid]; + hist_field.innerHTML = data.history_menu[etudid]; } - elem.setAttribute("data-last-saved-value", v); + note_field.setAttribute("data-last-saved-value", v); } } } catch (error) { console.error("Fetch error:", error); sco_message("Erreur réseau: valeur non enregistrée"); } finally { - decSaving(); // update counter to show one saving in progress less. May re-enable 'Terminer' button + decSaving(); // mise à jour du nombre de requêtes en instance. si devient nul, réactive le lien 'Terminer' } } -// Set up the beforeunload event listener -window.addEventListener('beforeunload', function (e) { - let noteInputs = document.querySelectorAll("#formnotes .note"); - noteInputs.forEach(function (input) { - if (input.getAttribute("data-modified") === "true" && input.value !== input.getAttribute("data-last-saved-value")) { - valid_note.call(input); - } - }); - if (nbSaving > 0) { - // Display a confirmation dialog - const confirmationMessage = 'Des modifications sont en cours de sauvegarde. Êtes-vous sûr de vouloir quitter cette page ?'; - e.preventDefault(); // Standard for most modern browsers - e.returnValue = confirmationMessage; // For compatibility with older browsers - return confirmationMessage; // For compatibility with older browsers - } -}); - -function change_history(e) { - let opt = e.selectedOptions[0]; - let val = opt.getAttribute("data-note"); - const etudid = parseInt(e.getAttribute("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); +function make_jurylink(field_id) { + const formsemestre_id = get_formsemestre_id(); + const etudid = get_etudid(field_id); + const href = `${SCO_URL}Notes/formsemestre_validation_etud_form?formsemestre_id=${formsemestre_id}&etudid=${etudid}`; + return '<a href="' + href +'">mettre à jour décision de jury</a>'; } -// Contribution S.L.: copier/coller des notes - -function paste_text(event) { - event.stopPropagation(); - event.preventDefault(); - var clipb = event.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++) { - if (!currentInput.disabled) { // skip DEM - currentInput.value = list[i]; - } - // --- trigger blur - currentInput.setAttribute("data-modified", "true"); - var evt = new Event("blur", { bubbles: true, cancelable: true}); - currentInput.dispatchEvent(evt); - // --- next input - 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; +function change_history(e) { + const opt = e.selectedOptions[0]; + const val = opt.getAttribute("data-note"); + if (val != '') { + const field_id = get_field_id(e.parentElement); + // le input associé a ce menu: + const input = get_note_field(field_id) + save_note(field_id, val); + input.value = val + // gère le style de l'input (note_saved ou pas selon que la valeur de la note a changé par rapport à la valeur initiale) + if (val == input.getAttribute("data-orig-value")) { + input.classList.remove("note_saved") + } else { + input.classList.add("note_saved"); } - } else { - return; - } } } -function masquer_DEM() { - document.querySelector("body").classList.toggle("masquer_DEM"); -} diff --git a/app/static/js/saisie_notes_par_etu.js b/app/static/js/saisie_notes_par_etu.js new file mode 100644 index 0000000000000000000000000000000000000000..5c907152298ad5b625a02b99a8bd76395c6f5635 --- /dev/null +++ b/app/static/js/saisie_notes_par_etu.js @@ -0,0 +1,81 @@ +/* + * Gestion des formulaires de note + */ + +// Formulaire saisie de notes pour une évaluation + +const form_etudid = parseInt(document.getElementById("etudiant_id").getAttribute("value")); + +function get_note_min(field_id) { + const note_field = get_note_field(field_id); + return parseFloat(note_field.getAttribute("note_min")); +} + +function get_note_max(field_id) { + const note_field = get_note_field(field_id); + return parseFloat(note_field.getAttribute("note_max")); +} + +function get_evaluation_id(field_id) { + const note_field = get_note_field(field_id); + return parseInt(note_field.getAttribute("evaluation_id")); +} + +function get_note_field(field_id) { + return document.getElementById(note_prefix + field_id); +} + +function get_hist_field(field_id) { + return document.getElementById(hist_prefix + field_id); +} + +function get_etudid(field_id) { + return parseInt(form_etudid); +} + +document.addEventListener("DOMContentLoaded", function () { + const noteInputs = document.querySelectorAll("#formnotes .note"); + noteInputs.forEach(function (input) { + input.addEventListener("input", function() { + if (this.value !== this.getAttribute("data-last-saved-value")) { + this.setAttribute("data-modified", "true"); + } + }); + input.addEventListener("blur", input.addEventListener("blur", function(event){ + write_on_blur(event.currentTarget) + })); + }); +}); + +function is_enter_key(event) { + if (event.keyCode == 13) return true; + if (event.which == 13) return true; + if (event.charCode == 13) return true; + return false; +} + +function find_next_field(current_element) { + const form_input = Array.from(document.querySelectorAll("#formnotes input")) + .filter(elem => ! elem.disabled) + .filter(elem => $(elem).is(':visible')); + const current_index = form_input.indexOf(current_element) + return ((current_index == form_input.length -1) ? form_input[current_index] : form_input[current_index+1]); +} +function enter_focus_next (elem, event) { + if (is_enter_key(event)) { + let next_elem = find_next_field(elem); + if (next_elem != elem) { + next_elem.focus(); + return true; + } else { + elem.blur(); + return false; + } + } +} + +function update_jurylink(evaluation_id, v) { + const note_field = get_note_field(evaluation_id); + const jurylink = document.getElementById('jurylink'); + jurylink.innerHTML = make_jurylink(evaluation_id); +} \ No newline at end of file diff --git a/app/static/js/saisie_notes_par_eval.js b/app/static/js/saisie_notes_par_eval.js new file mode 100644 index 0000000000000000000000000000000000000000..d4e226661f6df7e0aad961a0ad769bf0ee5ff567 --- /dev/null +++ b/app/static/js/saisie_notes_par_eval.js @@ -0,0 +1,105 @@ +// Formulaire saisie de notes pour une évaluation + +const note_min = parseFloat(document.querySelector("#eval_note_min").textContent); +const note_max = parseFloat(document.querySelector("#eval_note_max").textContent); +const form_evaluation_id = parseInt(document.getElementById("formnotes_evaluation_id").getAttribute("value")); + +function get_note_min(field_id) { + return note_min; +} + +function get_note_max(field_id) { + return note_max; +} + +function get_note_field(field_id) { + return document.getElementById(note_prefix + field_id); +} + +function get_hist_field(field_id) { + return document.getElementById(hist_prefix + field_id); +} + +function get_etudid(field_id) { + return parseInt(get_note_field(field_id).getAttribute("data-etudid")); +} + +document.addEventListener("DOMContentLoaded", function () { + const noteInputs = document.querySelectorAll("#formnotes .note"); + noteInputs.forEach(function (input) { + console.log(get_field_id(input)); + input.addEventListener("input", function() { + if (this.value !== this.getAttribute("data-last-saved-value")) { + this.setAttribute("data-modified", "true"); + } + }); + input.addEventListener("blur", input.addEventListener("blur", function(event){write_on_blur(event.currentTarget)} )); + }); + + const formInputs = document.querySelectorAll("#formnotes input"); + formInputs.forEach(function (input) { + input.addEventListener("paste", paste_text); + }); + + const masquerBtn = document.querySelector(".btn_masquer_DEM"); + masquerBtn.addEventListener("click", masquer_DEM); +}); + +function get_evaluation_id(field_id) { + return form_evaluation_id +} + +// Contribution S.L.: copier/coller des notes + +function paste_text(event) { + event.stopPropagation(); + event.preventDefault(); + var clipb = event.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++) { + if (!currentInput.disabled) { // skip DEM + currentInput.value = list[i]; + } + // --- trigger blur + currentInput.setAttribute("data-modified", "true"); + var evt = new Event("blur", { bubbles: true, cancelable: true}); + currentInput.dispatchEvent(evt); + // --- next input + 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"); +} + +function update_jurylink(etudid, v) { + const juryLink = document.getElementById("jurylink_" + etudid); + const note_field = get_note_field(etudid) + if (v !== note_field.getAttribute("data-orig-value")) { + juryLink.innerHTML = make_jurylink(etudid); + } else { + juryLink.innerHTML = ""; + } +} \ No newline at end of file diff --git a/app/templates/etud/saisie_notes_par_etu.j2 b/app/templates/etud/saisie_notes_par_etu.j2 new file mode 100644 index 0000000000000000000000000000000000000000..b4f55ba97d8be2ea0b1a7dc8b7bfeae470e0f391 --- /dev/null +++ b/app/templates/etud/saisie_notes_par_etu.j2 @@ -0,0 +1,109 @@ +{% extends 'sco_page.j2' %} + +{% block styles %} + {{super()}} + <link href="{{scu.STATIC_DIR}}/css/saisie_notes_par_etu.css" rel="stylesheet" type="text/css" /> + <style> + #formnotes { + display:inline-grid; + grid-template-columns: 40px 40px minmax(350px, 1fr) 70px minmax(200px, 1fr); + align-items: baseline; + } + #formnotes .section { + grid-column: 1 / span 5; + border-bottom: solid black; + margin-bottom: 5px; + font-weight: bold; + } + #formnotes .module { + grid-column: 2 / span 4; + margin-bottom: 3px; + background-color: lightsteelblue; + padding-left: 5px; + } + #formnotes .label { + grid-column: 3; + } + #formnotes .input { grid-column: 4 } + #formnotes .history { grid-column: 5;} + #formnotes .long_input { grid-column: 4 / span 2 } + #formnotes .validation { grid-column: 2 / span 2 } + </style> +{% endblock %} + +{% block app_content %} + <div id="saisie_notes_par_etu" class="table-container"> + <span class="titre">{{title}}</span> + <h3> Saisie des notes de l'étudiant : {{etudiant.nom}} {{etudiant.prenom}} </h3> + {% if data["decision_jury"] %} + <div class="warning"> + <ul class="tf-msg"> + <li class="tf-msg">Attention: il y a déjà des <b>décisions de jury</b> enregistrées pour + cet étudiant. Après changement des notes, vérifiez la situation !</li> + </ul> + </div> + {% endif %} + <div id="formnotes"> + <input type="hidden" id="etudiant_id" value="{{etudiant.id}}" /> + <input type="hidden" id="formnotes_formsemestre_id" value="{{semestre.id}}" /> + {% if data["decision_jury"] %} + {% endif %} + <div class="label">commentaire</div> + <div class="long_input"> + <input type="text" id="formnotes_comment" name="comment" class="input" size="44" onkeypress="return enter_focus_next(this, event);"> + </div> + {% for module_type in module_type_order %} + {% if data["data_sections"][module_type].data_modimpl %} + <div class="section">{{ data["data_sections"][module_type].name }}</div> + {% for data_modimpl in data["data_sections"][module_type].data_modimpl %} + <div class="module">{{data_modimpl.link | safe}}</div> + {% for data_eval in data_modimpl.data_evals %} + <div class="label">{{ data_eval.description }} + <input type="hidden" id="date_debut_{{ data_eval.evaluation_id }}" value="{{ data_eval.date_debut }}"> + <input type="hidden" id="date_fin_{{ data_eval.evaluation_id }}" value="{{ data_eval.date_fin }}"> + <input type="hidden" id="evaluation_type_{{ data_eval.evaluation_id }}" value="{{ data_eval.evaluation_type }}"> + {% for ue in data_eval.ue_poids %} + <div id="poids_{{ data_eval.evaluation_id }}_{{ ue }}"> + <input type="hidden" class="ue" value="{{ ue }}"> + <input type="hidden" class="evaluation" value="{{ data_eval.evaluation_id }}"> + <input type="hidden" value="{{ data_eval.ue_poids[ue] }}"> + </div> + {% endfor %} + </input> + </div> + <div class="input"> + {{ data_eval.forminput() | safe }} + </div> + <div class="history"> + {{ data_eval.history | safe }} + </div> + {% endfor %} + {% endfor %} + {% endif %} + {% endfor %} + <div class="validation"> + <a href="/ScoDoc/INFO/Scolarite/Notes/moduleimpl_status?moduleimpl_id=21574" + class="btn btn-primary link-terminer" + style="pointer-events: unset; + color: unset; text-decoration: unset; + cursor: unset;">Terminer</a> + {% if data["decision_jury"] %} + <span id="jurylink" class="jurylink"></span> + {% endif %} + </div> + </div> + </form> + </div> + <div class="sco_help"> + <p> + Les modifications sont enregistrées au fur et à mesure. + </p> + <h4> Codes spéciaux: </h4> + <ul> + <li> ABS: absent (compte comme un zéro) </li> + <li> EXC: excusé (note neutralisée) </li> + <li> SUPR: pour supprimer une note existante </li> + <li> ATT: note en attente (permet de publier une évaluation avec des notes manquantes) </li> + </ul> + </div> +{% endblock %} diff --git a/app/views/notes.py b/app/views/notes.py index 9a223f5059d5291e09f6f55a2db23a50e6c27b99..7dfa03bd23ff942b820e08bf4a21d4231460cf6d 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -1816,6 +1816,22 @@ def form_saisie_notes(evaluation_id: int): return sco_saisie_notes.saisie_notes(evaluation, group_ids) +# modification US 1030 - DEB + + +@bp.route("/form_saisie_notes_par_etu/<int:semestre_id>/<int:etu_id>") +@scodoc +@permission_required(Permission.EnsView) +def form_saisie_notes_par_etu(etu_id: int, semestre_id: int): + "Formulaire de saisie des notes centré sur l'étudiant" + etudiant = Identite.get_etud(etu_id) + semestre = FormSemestre.get_formsemestre(semestre_id) + return sco_saisie_notes.saisie_notes_par_etu(semestre, etudiant) + + +# modification US 1030 - FIN + + @bp.route("/formsemestre_import_notes/<int:formsemestre_id>", methods=["GET", "POST"]) @scodoc @permission_required(Permission.ScoView) # controle contextuel