From c6910fc76e786a171de26f54e0fe09264b59903f Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 14:49:36 +0100 Subject: [PATCH 1/9] =?UTF-8?q?RGPD:=20protection=20optionnelle=20des=20do?= =?UTF-8?q?nn=C3=A9es=20perso=20=C3=A9tudiantes=20(ViewEtudData)=20sur=20f?= =?UTF-8?q?iche=5Fetud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/jury.py | 2 +- app/but/jury_but_pv.py | 2 +- app/but/jury_but_view.py | 2 +- app/comp/res_classic.py | 2 +- app/models/etudiants.py | 43 +- app/pe/pe_jurype.py | 6 +- app/scodoc/html_sidebar.py | 2 +- app/scodoc/sco_abs_billets.py | 2 +- app/scodoc/sco_abs_notification.py | 17 +- app/scodoc/sco_archives_etud.py | 8 +- app/scodoc/sco_cursus_dut.py | 1 - app/scodoc/sco_debouche.py | 4 +- app/scodoc/sco_etape_apogee_view.py | 6 +- app/scodoc/sco_etape_bilan.py | 2 +- app/scodoc/sco_etud.py | 97 +--- app/scodoc/sco_evaluation_check_abs.py | 2 +- app/scodoc/sco_export_results.py | 7 +- app/scodoc/sco_find_etud.py | 11 +- app/scodoc/sco_formsemestre_exterieurs.py | 4 +- app/scodoc/sco_formsemestre_inscriptions.py | 16 +- app/scodoc/sco_formsemestre_status.py | 6 +- app/scodoc/sco_formsemestre_validation.py | 34 +- app/scodoc/sco_groups_view.py | 6 +- app/scodoc/sco_inscr_passage.py | 2 +- app/scodoc/sco_lycee.py | 6 +- app/scodoc/sco_moduleimpl_inscriptions.py | 8 +- app/scodoc/sco_page_etud.py | 518 ++++++++++-------- app/scodoc/sco_permissions.py | 1 + app/scodoc/sco_poursuite_dut.py | 2 +- app/scodoc/sco_pv_forms.py | 4 +- app/scodoc/sco_report.py | 86 ++- app/scodoc/sco_roles_default.py | 12 +- app/scodoc/sco_trombino.py | 2 +- app/scodoc/sco_utils.py | 23 +- app/static/css/scodoc.css | 7 +- app/templates/bul_head.j2 | 4 +- app/templates/entreprises/fiche_entreprise.j2 | 2 +- app/templates/scolar/partition_editor.j2 | 2 +- app/templates/sidebar.j2 | 2 +- app/views/absences.py | 2 +- app/views/notes.py | 16 +- app/views/scolar.py | 36 +- ...88ff8970_config_permission_viewetuddata.py | 44 ++ tests/unit/test_etudiants.py | 5 - 44 files changed, 609 insertions(+), 457 deletions(-) create mode 100644 migrations/versions/3fa988ff8970_config_permission_viewetuddata.py diff --git a/app/api/jury.py b/app/api/jury.py index e71531eb..864dfe22 100644 --- a/app/api/jury.py +++ b/app/api/jury.py @@ -66,7 +66,7 @@ def _news_delete_jury_etud(etud: Identite): "génère news sur effacement décision" # n'utilise pas g.scodoc_dept, pas toujours dispo en mode API url = url_for( - "scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=etud.departement.acronym, etudid=etud.id ) ScolarNews.add( typ=ScolarNews.NEWS_JURY, diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py index 9b970a61..8ae09800 100644 --- a/app/but/jury_but_pv.py +++ b/app/but/jury_but_pv.py @@ -154,7 +154,7 @@ def pvjury_table_but( "_nom_target_attrs": f'class="etudinfo" id="{etud.id}"', "_nom_td_attrs": f'id="{etud.id}" class="etudinfo"', "_nom_target": url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id, ), diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py index 544d13f2..8536ac63 100644 --- a/app/but/jury_but_view.py +++ b/app/but/jury_but_view.py @@ -447,7 +447,7 @@ def jury_but_semestriel(
{etud.nomprenom}
{etud.photo_html(title="fiche de " + etud.nomprenom)}
diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 89ff95e7..673668b9 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -234,7 +234,7 @@ class ResultatsSemestreClassic(NotesTableCompat): raise ScoValueError( f"""

Coefficient de l'UE capitalisée {ue.acronyme} impossible à déterminer pour l'étudiant {etud.nom_disp()}

Il faut " @@ -176,7 +179,7 @@ class Identite(models.ScoDocModel): def url_fiche(self) -> str: "url de la fiche étudiant" return url_for( - "scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id + "scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id ) @classmethod @@ -433,9 +436,10 @@ class Identite(models.ScoDocModel): "prenom_etat_civil": self.prenom_etat_civil, } - def to_dict_scodoc7(self) -> dict: + def to_dict_scodoc7(self, restrict=False) -> dict: """Représentation dictionnaire, - compatible ScoDoc7 mais sans infos admission + compatible ScoDoc7 mais sans infos admission. + Si restrict, cache les infos "personnelles" si pas permission ViewEtudData """ e_dict = self.__dict__.copy() # dict(self.__dict__) e_dict.pop("_sa_instance_state", None) @@ -446,7 +450,7 @@ class Identite(models.ScoDocModel): e_dict["nomprenom"] = self.nomprenom adresse = self.adresses.first() if adresse: - e_dict.update(adresse.to_dict()) + e_dict.update(adresse.to_dict(restrict=restrict)) return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty def to_dict_bul(self, include_urls=True): @@ -481,7 +485,7 @@ class Identite(models.ScoDocModel): if include_urls and has_request_context(): # test request context so we can use this func in tests under the flask shell d["fiche_url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id ) d["photo_url"] = sco_photos.get_etud_photo_url(self.id) adresse = self.adresses.first() @@ -825,12 +829,25 @@ class Adresse(models.ScoDocModel): ) description = db.Column(db.Text) - def to_dict(self, convert_nulls_to_str=False): - """Représentation dictionnaire,""" + # Champs "protégés" par ViewEtudData (RGPD) + protected_attrs = { + "emailperso", + "domicile", + "codepostaldomicile", + "villedomicile", + "telephone", + "telephonemobile", + "fax", + } + + def to_dict(self, convert_nulls_to_str=False, restrict=False): + """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) if convert_nulls_to_str: - return {k: e[k] or "" for k in e} + e = {k: v or "" for k, v in e.items()} + if restrict: + e = {k: v for (k, v) in e.items() if k not in self.protected_attrs} return e @@ -885,12 +902,16 @@ class Admission(models.ScoDocModel): # classement (1..Ngr) par le jury dans le groupe APB apb_classement_gr = db.Column(db.Integer) + # Tous les champs sont "protégés" par ViewEtudData (RGPD) + # sauf: + not_protected_attrs = {"bac", "specialite", "anne_bac"} + def get_bac(self) -> Baccalaureat: "Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères." return Baccalaureat(self.bac, specialite=self.specialite) - def to_dict(self, no_nulls=False): - """Représentation dictionnaire,""" + def to_dict(self, no_nulls=False, restrict=False): + """Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD).""" d = dict(self.__dict__) d.pop("_sa_instance_state", None) if no_nulls: @@ -905,6 +926,8 @@ class Admission(models.ScoDocModel): d[key] = 0 elif isinstance(col_type, sqlalchemy.Boolean): d[key] = False + if restrict: + d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs} return d @classmethod diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py index 01687f7c..d6416c2e 100644 --- a/app/pe/pe_jurype.py +++ b/app/pe/pe_jurype.py @@ -455,7 +455,9 @@ class JuryPE(object): reponse = False etud = self.get_cache_etudInfo_d_un_etudiant(etudid) - (_, parcours) = sco_report.get_code_cursus_etud(etud) + (_, parcours) = sco_report.get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) if ( len(codes_cursus.CODES_SEM_REO & set(parcours.values())) > 0 ): # Eliminé car NAR apparait dans le parcours @@ -527,7 +529,7 @@ class JuryPE(object): etud = self.get_cache_etudInfo_d_un_etudiant(etudid) (code, parcours) = sco_report.get_code_cursus_etud( - etud + etud["etudid"], sems=etud["sems"] ) # description = '1234:A', parcours = {1:ADM, 2:NAR, ...} sonDernierSemestreValide = max( [ diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index 9806b115..dce59627 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -107,7 +107,7 @@ def sidebar(etudid: int = None): etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] params.update(etud) params["fiche_url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ) # compte les absences du semestre en cours H.append( diff --git a/app/scodoc/sco_abs_billets.py b/app/scodoc/sco_abs_billets.py index 18fe777f..4a9da0af 100644 --- a/app/scodoc/sco_abs_billets.py +++ b/app/scodoc/sco_abs_billets.py @@ -129,7 +129,7 @@ def table_billets( ] = f'id="{billet.etudiant.id}" class="etudinfo"' if with_links: billet_dict["_nomprenom_target"] = url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=billet_dict["etudid"], ) diff --git a/app/scodoc/sco_abs_notification.py b/app/scodoc/sco_abs_notification.py index 82caff29..50c50ae4 100644 --- a/app/scodoc/sco_abs_notification.py +++ b/app/scodoc/sco_abs_notification.py @@ -34,7 +34,7 @@ Il suffit d'appeler abs_notify() après chaque ajout d'absence. import datetime from typing import Optional -from flask import current_app, g, url_for +from flask import g, url_for from flask_mail import Message from app import db @@ -42,6 +42,7 @@ from app import email from app import log from app.auth.models import User from app.models.absences import AbsenceNotification +from app.models.etudiants import Identite from app.models.events import Scolog from app.models.formsemestre import FormSemestre import app.scodoc.notesdb as ndb @@ -174,9 +175,15 @@ def abs_notify_get_destinations( if prefs["abs_notify_email"]: destinations.append(prefs["abs_notify_email"]) if prefs["abs_notify_etud"]: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - if etud["email_default"]: - destinations.append(etud["email_default"]) + etud = Identite.get_etud(etudid) + adresse = etud.adresses.first() + if adresse: + # Mail à utiliser pour les envois vers l'étudiant: + # choix qui pourrait être controlé par une preference + # ici priorité au mail institutionnel: + email_default = adresse.email or adresse.emailperso + if email_default: + destinations.append(email_default) # Notification (à chaque fois) des resp. de modules ayant des évaluations # à cette date @@ -270,7 +277,7 @@ def abs_notification_message( values["nbabsjust"] = nbabsjust values["nbabsnonjust"] = nbabs - nbabsjust values["url_ficheetud"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True ) template = prefs["abs_notification_mail_tmpl"] diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 1dca9f02..42fddde2 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -177,7 +177,7 @@ def etud_upload_file_form(etudid): return "\n".join(H) + tf[1] + html_sco_header.sco_footer() elif tf[0] == -1: return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) else: data = tf[2]["datafile"].read() @@ -188,7 +188,7 @@ def etud_upload_file_form(etudid): etud_archive_id, data, filename, description=descr ) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -228,7 +228,7 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False): ), dest_url="", cancel_url=url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, head_message="annulation", @@ -239,7 +239,7 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False): ETUDS_ARCHIVER.delete_archive(archive_id, dept_id=etud["dept_id"]) flash("Archive supprimée") return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) diff --git a/app/scodoc/sco_cursus_dut.py b/app/scodoc/sco_cursus_dut.py index b9e26e32..47e7a5fe 100644 --- a/app/scodoc/sco_cursus_dut.py +++ b/app/scodoc/sco_cursus_dut.py @@ -39,7 +39,6 @@ from app import log from app.scodoc.scolog import logdb from app.scodoc import sco_cache, sco_etud from app.scodoc import sco_formsemestre -from app.scodoc import sco_formations from app.scodoc.codes_cursus import ( CMP, ADC, diff --git a/app/scodoc/sco_debouche.py b/app/scodoc/sco_debouche.py index cc08e8d9..f027b553 100644 --- a/app/scodoc/sco_debouche.py +++ b/app/scodoc/sco_debouche.py @@ -134,10 +134,10 @@ def table_debouche_etudids(etudids, keep_numeric=True): "nom": etud["nom"], "prenom": etud["prenom"], "_nom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), "_prenom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), "_nom_td_attrs": 'id="%s" class="etudinfo"' % (etud["etudid"]), # 'debouche' : etud['debouche'], diff --git a/app/scodoc/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py index 6247d986..da833d5c 100644 --- a/app/scodoc/sco_etape_apogee_view.py +++ b/app/scodoc/sco_etape_apogee_view.py @@ -542,7 +542,9 @@ def view_scodoc_etuds(semset_id, title="", nip_list="", fmt="html"): etuds = [sco_etud.get_etud_info(code_nip=nip, filled=True)[0] for nip in nips] for e in etuds: - tgt = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"]) + tgt = url_for( + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"] + ) e["_nom_target"] = tgt e["_prenom_target"] = tgt e["_nom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """ @@ -770,7 +772,7 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"): e["in_scodoc_str"] = {True: "oui", False: "non"}[e["in_scodoc"]] if e["in_scodoc"]: e["_in_scodoc_str_target"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, code_nip=e["nip"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"] ) e.update(sco_etud.get_etud_info(code_nip=e["nip"], filled=True)[0]) e["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"],) diff --git a/app/scodoc/sco_etape_bilan.py b/app/scodoc/sco_etape_bilan.py index 575d2596..86da7166 100644 --- a/app/scodoc/sco_etape_bilan.py +++ b/app/scodoc/sco_etape_bilan.py @@ -692,7 +692,7 @@ class EtapeBilan: @staticmethod def link_etu(etudid, nom): return '%s' % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), nom, ) diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index e7041021..f6b8a18d 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -64,7 +64,7 @@ def format_etud_ident(etud: dict): Note: par rapport à Identite.to_dict_bul(), ajoute les champs: - 'email_default', 'nom_disp', 'nom_usuel', 'civilite_etat_civil_str', 'ne', 'civilite_str' + 'nom_disp', 'nom_usuel', 'civilite_etat_civil_str', 'ne', 'civilite_str' """ etud["nom"] = format_nom(etud["nom"]) if "nom_usuel" in etud: @@ -98,10 +98,6 @@ def format_etud_ident(etud: dict): etud["ne"] = "e" else: # 'X' etud["ne"] = "(e)" - # Mail à utiliser pour les envois vers l'étudiant: - # choix qui pourrait être controé par une preference - # ici priorité au mail institutionnel: - etud["email_default"] = etud.get("email", "") or etud.get("emailperso", "") def force_uppercase(s): @@ -117,36 +113,6 @@ def _format_etat_civil(etud: dict) -> str: return etud["nomprenom"] -def format_lycee(nomlycee): - nomlycee = nomlycee.strip() - s = nomlycee.lower() - if s[:5] == "lycee" or s[:5] == "lycée": - return nomlycee[5:] - else: - return nomlycee - - -def format_telephone(n): - if n is None: - return "" - if len(n) < 7: - return n - else: - n = n.replace(" ", "").replace(".", "") - i = 0 - r = "" - j = len(n) - 1 - while j >= 0: - r = n[j] + r - if i % 2 == 1 and j != 0: - r = " " + r - i += 1 - j -= 1 - if len(r) == 13 and r[0] != "0": - r = "0" + r - return r - - def format_pays(s): "laisse le pays seulement si != FRANCE" if s.upper() != "FRANCE": @@ -283,14 +249,14 @@ def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True) listh.append( f"""Autre étudiant: {e['nom']} {e['prenom']}""" ) if etudid: OK = "retour à la fiche étudiant" - dest_endpoint = "scolar.ficheEtud" + dest_endpoint = "scolar.fiche_etud" parameters = {"etudid": etudid} else: if "tf_submitted" in args: @@ -619,7 +585,7 @@ def create_etud(cnx, args: dict = None): etud_dict = etudident_list(cnx, {"etudid": etudid})[0] fill_etuds_info([etud_dict]) etud_dict["url"] = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ) ScolarNews.add( typ=ScolarNews.NEWS_INSCR, @@ -724,19 +690,28 @@ def get_etablissements(): def get_lycee_infos(codelycee): - E = get_etablissements() - return E.get(codelycee, None) + etablissements = get_etablissements() + return etablissements.get(codelycee, None) -def format_lycee_from_code(codelycee): +def format_lycee_from_code(codelycee: str) -> str: "Description lycee à partir du code" - E = get_etablissements() - if codelycee in E: - e = E[codelycee] + etablissements = get_etablissements() + if codelycee in etablissements: + e = etablissements[codelycee] nomlycee = e["name"] - return "%s (%s)" % (nomlycee, e["commune"]) + return f"{nomlycee} ({e['commune']})" + return f"{codelycee} (établissement inconnu)" + + +def format_lycee(nomlycee: str) -> str: + "mise en forme nom de lycée" + nomlycee = nomlycee.strip() + s = nomlycee.lower() + if s[:5] == "lycee" or s[:5] == "lycée": + return nomlycee[5:] else: - return "%s (établissement inconnu)" % codelycee + return nomlycee def etud_add_lycee_infos(etud): @@ -821,36 +796,6 @@ def fill_etuds_info(etuds: list[dict], add_admission=True): # nettoyage champs souvent vides etud["codepostallycee"] = etud.get("codepostallycee", "") or "" etud["nomlycee"] = etud.get("nomlycee", "") or "" - if etud.get("nomlycee"): - etud["ilycee"] = "Lycée " + format_lycee(etud["nomlycee"]) - if etud["villelycee"]: - etud["ilycee"] += " (%s)" % etud.get("villelycee", "") - etud["ilycee"] += "
" - else: - if etud.get("codelycee"): - etud["ilycee"] = format_lycee_from_code(etud["codelycee"]) - else: - etud["ilycee"] = "" - rap = "" - if etud.get("rapporteur") or etud.get("commentaire"): - rap = "Note du rapporteur" - if etud.get("rapporteur"): - rap += " (%s)" % etud["rapporteur"] - rap += ": " - if etud.get("commentaire"): - rap += "%s" % etud["commentaire"] - etud["rap"] = rap - - if etud.get("telephone"): - etud["telephonestr"] = "Tél.: " + format_telephone(etud["telephone"]) - else: - etud["telephonestr"] = "" - if etud.get("telephonemobile"): - etud["telephonemobilestr"] = "Mobile: " + format_telephone( - etud["telephonemobile"] - ) - else: - etud["telephonemobilestr"] = "" def etud_inscriptions_infos(etudid: int, ne="") -> dict: diff --git a/app/scodoc/sco_evaluation_check_abs.py b/app/scodoc/sco_evaluation_check_abs.py index bd6b8ded..eb35312d 100644 --- a/app/scodoc/sco_evaluation_check_abs.py +++ b/app/scodoc/sco_evaluation_check_abs.py @@ -156,7 +156,7 @@ def evaluation_check_absences_html( H.append( f"""

  • {etud.nomprenom}""" ) diff --git a/app/scodoc/sco_export_results.py b/app/scodoc/sco_export_results.py index d9a89b2b..beaf7378 100644 --- a/app/scodoc/sco_export_results.py +++ b/app/scodoc/sco_export_results.py @@ -173,9 +173,10 @@ def _build_results_list(dpv_by_sem, etuds_infos): "nom_usuel": etud["nom_usuel"], "prenom": etud["prenom"], "civilite_str": etud["civilite_str"], - "_nom_target": "%s" - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - "_nom_td_attrs": 'id="%s" class="etudinfo"' % etudid, + "_nom_target": url_for( + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + "_nom_td_attrs": f'id="{etudid}" class="etudinfo"', "bac": bac.abbrev(), "parcours": dec["parcours"], } diff --git a/app/scodoc/sco_find_etud.py b/app/scodoc/sco_find_etud.py index 579992da..d86f4dee 100644 --- a/app/scodoc/sco_find_etud.py +++ b/app/scodoc/sco_find_etud.py @@ -145,7 +145,7 @@ def search_etud_in_dept(expnom=""): if "dest_url" in vals: endpoint = vals["dest_url"] else: - endpoint = "scolar.ficheEtud" + endpoint = "scolar.fiche_etud" if "parameters_keys" in vals: for key in vals["parameters_keys"].split(","): url_args[key] = vals[key] @@ -328,8 +328,9 @@ def table_etud_in_accessible_depts(expnom=None): """ result, accessible_depts = search_etud_in_accessible_depts(expnom=expnom) H = [ - """
    """, - """

    Recherche multi-département de "%s"

    """ % expnom, + f"""
    +

    Recherche multi-département de "{expnom}"

    + """, ] for etuds in result: if etuds: @@ -337,9 +338,9 @@ def table_etud_in_accessible_depts(expnom=None): # H.append('

    Département %s

    ' % DeptId) for e in etuds: e["_nomprenom_target"] = url_for( - "scolar.ficheEtud", scodoc_dept=dept_id, etudid=e["etudid"] + "scolar.fiche_etud", scodoc_dept=dept_id, etudid=e["etudid"] ) - e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) + e["_nomprenom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """ tab = GenTable( titles={"nomprenom": "Étudiants en " + dept_id}, diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index 22b600af..7c2cc31b 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -102,7 +102,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id): scodoc_dept=g.scodoc_dept, etudid=etudid, only_ext=1) }"> inscrire à un autre semestre"

    -

    Étudiant {etud.nomprenom}

    """, @@ -221,7 +221,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id): tf[2]["formation_id"] = orig_sem["formation_id"] formsemestre_ext_create(etudid, tf[2]) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index b1d73150..11c6b07d 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -400,7 +400,7 @@ def formsemestre_inscription_with_modules_form(etudid, only_ext=False): H.append("

    aucune session de formation !

    ") H.append( f"""

    ou

    retour à la fiche de {etud.nomprenom}""" ) return "\n".join(H) + footer @@ -440,7 +440,7 @@ def formsemestre_inscription_with_modules( dans le semestre {formsemestre.titre_mois()}

      -
    • retour à la fiche de {etud.nomprenom}
    • Aucune modification à effectuer

      retour à la fiche étudiant

      """ - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + % url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) return "\n".join(H) + footer @@ -755,7 +755,7 @@ function chkbx_select(field_id, state) { etudid, modulesimpls_ainscrire, modulesimpls_adesinscrire, - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), ) ) return "\n".join(H) + footer @@ -820,7 +820,7 @@ def do_moduleimpl_incription_options(

      Retour à la fiche étudiant

      """ - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + % url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), html_sco_header.sco_footer(), ] return "\n".join(H) @@ -885,7 +885,7 @@ def formsemestre_inscrits_ailleurs(formsemestre_id): '
    • %s : ' % ( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"], ), diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 365a44c5..96a0d638 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1457,7 +1457,7 @@ def formsemestre_warning_etuds_sans_note( noms = ", ".join( [ f"""{etud.nomprenom}""" for etud in etuds ] @@ -1519,13 +1519,13 @@ def formsemestre_note_etuds_sans_notes( a déjà des notes""" ) return redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) else: noms = "
    • ".join( [ f"""{etud.nomprenom}""" for etud in etuds ] diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index d11b334c..c8c95562 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -117,8 +117,8 @@ def formsemestre_validation_etud_form( if read_only: check = True - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) + etud_d = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + Se = sco_cursus.get_situation_etud_cursus(etud_d, formsemestre_id) if not Se.sem["etat"]: raise ScoValueError("validation: semestre verrouille") @@ -132,7 +132,7 @@ def formsemestre_validation_etud_form( H = [ html_sco_header.sco_header( - page_title=f"Parcours {etud['nomprenom']}", + page_title=f"Parcours {etud.nomprenom}", javascripts=["js/recap_parcours.js"], ) ] @@ -177,26 +177,22 @@ def formsemestre_validation_etud_form( H.append('
      ') if not check: H.append( - '

      %s: validation %s%s

      Parcours: %s' - % ( - etud["nomprenom"], - Se.parcours.SESSION_NAME_A, - Se.parcours.SESSION_NAME, - Se.get_cursus_descr(), - ) + f"""

      {etud.nomprenom}: validation { + Se.parcours.SESSION_NAME_A}{Se.parcours.SESSION_NAME + }

      Parcours: {Se.get_cursus_descr()} + """ ) else: H.append( - '

      Parcours de %s

      %s' - % (etud["nomprenom"], Se.get_cursus_descr()) + f"""

      Parcours de {etud.nomprenom}

      {Se.get_cursus_descr()}""" ) H.append( - '
      %s
      ' - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]), - ) + f"""{etud.photo_html(title="fiche de " + etud.nomprenom)} + + """ ) etud_etat = nt.get_etud_etat(etudid) @@ -210,7 +206,7 @@ def formsemestre_validation_etud_form(
      Impossible de statuer sur cet étudiant: il est démissionnaire ou défaillant (voir sa fiche)
      """ @@ -289,7 +285,7 @@ def formsemestre_validation_etud_form( etudid=etudid, origin_formsemestre_id=formsemestre_id ).all() if autorisations: - H.append(". Autorisé%s à s'inscrire en " % etud["ne"]) + H.append(f". Autorisé{etud.e} à s'inscrire en ") H.append(", ".join([f"S{aut.semestre_id}" for aut in autorisations]) + ".") H.append("

      ") diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index aacbae64..c02a3e88 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -561,7 +561,7 @@ def groups_table( else: etud["_emailperso_target"] = "" fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_disp_target"] = fiche_url etud["_nom_disp_order"] = etud_sort_key(etud) @@ -829,7 +829,9 @@ def groups_table( etud, groups_infos.formsemestre_id ) m["parcours"] = Se.get_cursus_descr() - m["code_cursus"], _ = sco_report.get_code_cursus_etud(etud) + m["code_cursus"], _ = sco_report.get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) rows = [[m.get(k, "") for k in keys] for m in groups_infos.members] title = "etudiants_%s" % groups_infos.groups_filename xls = sco_excel.excel_simple_table(titles=titles, lines=rows, sheet_name=title) diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index 8a9ef08f..f97e0d44 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -669,7 +669,7 @@ def etuds_select_boxes( elink = """%s""" % ( c, url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"], ), diff --git a/app/scodoc/sco_lycee.py b/app/scodoc/sco_lycee.py index 606fabf2..da274dc9 100644 --- a/app/scodoc/sco_lycee.py +++ b/app/scodoc/sco_lycee.py @@ -143,7 +143,9 @@ def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False) if not no_links: for etud in etuds: fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", + scodoc_dept=g.scodoc_dept, + etudid=etud["etudid"], ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url @@ -232,7 +234,7 @@ def js_coords_lycees(etuds_by_lycee): '%s' % ( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"], ), diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index 487368f0..c110dacf 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -186,7 +186,7 @@ def moduleimpl_inscriptions_edit( H.append( f""" H.append( f""" str: [ 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 2a23059f..1bdb148c 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -25,38 +25,37 @@ # ############################################################################## -"""ScoDoc ficheEtud +"""ScoDoc fiche_etud Fiche description d'un étudiant et de son parcours """ -from flask import abort, url_for, g, render_template, request +from flask import url_for, g, render_template, request from flask_login import current_user +import sqlalchemy as sa -from app import db, log +from app import log +from app.auth.models import User from app.but import cursus_but -from app.models.etudiants import make_etud_args -from app.models import Identite, FormSemestre, ScoDocSiteConfig -from app.scodoc import html_sco_header -from app.scodoc import htmlutils -from app.scodoc import sco_archives_etud -from app.scodoc import sco_bac -from app.scodoc import codes_cursus -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_status -from app.scodoc import sco_groups -from app.scodoc import sco_cursus -from app.scodoc import sco_permissions_check -from app.scodoc import sco_photos -from app.scodoc import sco_users -from app.scodoc import sco_report -from app.scodoc import sco_etud +from app.models import Adresse, EtudAnnotation, FormSemestre, Identite, ScoDocSiteConfig +from app.scodoc import ( + codes_cursus, + html_sco_header, + htmlutils, + sco_archives_etud, + sco_bac, + sco_cursus, + sco_etud, + sco_formsemestre_status, + sco_groups, + sco_permissions_check, + sco_report, +) from app.scodoc.sco_bulletins import etud_descr_situation_semestre from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table from app.scodoc.sco_permissions import Permission import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb def _menu_scolarite( @@ -157,29 +156,18 @@ def _menu_scolarite( ) -def ficheEtud(etudid=None): +def fiche_etud(etudid=None): "fiche d'informations sur un etudiant" - authuser = current_user - cnx = ndb.GetDBConnexion() - if etudid: - try: # pour les bookmarks avec d'anciens ids... - etudid = int(etudid) - except ValueError: - raise ScoValueError("id invalide !") from ValueError - # la sidebar est differente s'il y a ou pas un etudid - # voir html_sidebar.sidebar() - g.etudid = etudid - args = make_etud_args(etudid=etudid) - etuds = sco_etud.etudident_list(cnx, args) - if not etuds: - log(f"ficheEtud: etudid={etudid!r} request.args={request.args!r}") - raise ScoValueError("Étudiant inexistant !") - etud_ = etuds[0] # transition: etud_ à éliminer et remplacer par etud - etudid = etud_["etudid"] - etud = Identite.get_etud(etudid) - sco_etud.fill_etuds_info([etud_]) - # - info = etud_ + restrict_etud_data = not current_user.has_permission(Permission.ViewEtudData) + try: + etud = Identite.get_etud(etudid) + except Exception as exc: + log(f"fiche_etud: etudid={etudid!r} request.args={request.args!r}") + raise ScoValueError("Étudiant inexistant !") from exc + # la sidebar est differente s'il y a ou pas un etudid + # voir html_sidebar.sidebar() + g.etudid = etudid + info = etud.to_dict_scodoc7(restrict=restrict_etud_data) if etud.prenom_etat_civil: info["etat_civil"] = ( "

      Etat-civil: " @@ -193,45 +181,24 @@ def ficheEtud(etudid=None): else: info["etat_civil"] = "" info["ScoURL"] = scu.ScoURL() - info["authuser"] = authuser - info["info_naissance"] = info["date_naissance"] - if info["lieu_naissance"]: - info["info_naissance"] += " à " + info["lieu_naissance"] - if info["dept_naissance"]: - info["info_naissance"] += f" ({info['dept_naissance']})" - info["etudfoto"] = sco_photos.etud_photo_html(etud_) - if ( - (not info["domicile"]) - and (not info["codepostaldomicile"]) - and (not info["villedomicile"]) - ): - info["domicile"] = "inconnue" - if info["paysdomicile"]: - pays = sco_etud.format_pays(info["paysdomicile"]) - if pays: - info["paysdomicile"] = "(%s)" % pays - else: - info["paysdomicile"] = "" - if info["telephone"] or info["telephonemobile"]: - info["telephones"] = "
      %s    %s" % ( - info["telephonestr"], - info["telephonemobilestr"], - ) + info["authuser"] = current_user + if restrict_etud_data: + info["info_naissance"] = "" + adresse = None else: - info["telephones"] = "" - # e-mail: - if info["email_default"]: - info["emaillink"] = ", ".join( - [ - '%s' % (m, m) - for m in [etud_["email"], etud_["emailperso"]] - if m - ] - ) - else: - info["emaillink"] = "(pas d'adresse e-mail)" + info["info_naissance"] = info["date_naissance"] + if info["lieu_naissance"]: + info["info_naissance"] += " à " + info["lieu_naissance"] + if info["dept_naissance"]: + info["info_naissance"] += f" ({info['dept_naissance']})" + adresse = etud.adresses.first() + info.update(_format_adresse(adresse)) + + info.update(etud.inscription_descr()) + info["etudfoto"] = etud.photo_html() + # Champ dépendant des permissions: - if authuser.has_permission(Permission.EtudChangeAdr): + if current_user.has_permission(Permission.EtudChangeAdr): info[ "modifadresse" ] = f"""{descr["situation"]}""" else: e = {"etudid": etudid} - sco_groups.etud_add_group_infos( - e, - sem["formsemestre_id"], - only_to_show=True, - ) + sco_groups.etud_add_group_infos(e, formsemestre.id, only_to_show=True) grlinks = [] for partition in e["partitions"].values(): @@ -289,16 +252,16 @@ def ficheEtud(etudid=None): ) grlink = ", ".join(grlinks) # infos ajoutées au semestre dans le parcours (groupe, menu) - menu = _menu_scolarite(authuser, formsemestre, etudid, sem["ins"]["etat"]) + menu = _menu_scolarite(current_user, formsemestre, etudid, inscription.etat) if menu: - sem_info[sem["formsemestre_id"]] = ( + sem_info[formsemestre.id] = ( "
      " + grlink + "" + menu + "
      " ) else: - sem_info[sem["formsemestre_id"]] = grlink + sem_info[formsemestre.id] = grlink - if info["sems"]: - Se = sco_cursus.get_situation_etud_cursus(etud_, info["last_formsemestre_id"]) + if inscriptions: + Se = sco_cursus.get_situation_etud_cursus(info, info["last_formsemestre_id"]) info["liste_inscriptions"] = formsemestre_recap_parcours_table( Se, etudid, @@ -318,20 +281,19 @@ def ficheEtud(etudid=None): """ ) - last_formsemestre: FormSemestre = db.session.get( - FormSemestre, info["sems"][0]["formsemestre_id"] - ) + last_formsemestre: FormSemestre = inscriptions[0].formsemestre if last_formsemestre.formation.is_apc() and last_formsemestre.semestre_id > 2: info[ "link_bul_pdf" ] += f"""
      Visualiser les compétences BUT """ - if authuser.has_permission(Permission.EtudInscrit): + if current_user.has_permission(Permission.EtudInscrit): info[ "link_inscrire_ailleurs" ] = f"""Étudiant{info["ne"]} non inscrit{info["ne"]}"""] - if authuser.has_permission(Permission.EtudInscrit): + l = [f"""

      Étudiant{etud.e} non inscrit{etud.e}"""] + if current_user.has_permission(Permission.EtudInscrit): l.append( f"""%s' - % ( - etudid, - a["id"], - scu.icontag( + annotations_list = [] + annotations = EtudAnnotation.query.filter_by(etudid=etud.id).order_by( + sa.desc(EtudAnnotation.date) + ) + for annot in annotations: + del_link = ( + f"""{ + scu.icontag( "delete_img", border="0", alt="suppress", title="Supprimer cette annotation", - ), ) - ) - author = sco_users.user_info(a["author"]) - alist.append( - f"""Le {a['date']} par {author['prenomnom']} : - {a['comment']}{a['dellink']} + }""" + if sco_permissions_check.can_suppress_annotation(annot.id) + else "" + ) + + author = User.query.filter_by(user_name=annot.author).first() + annotations_list.append( + f"""Le {annot.date.strftime("%d/%m/%Y") if annot.date else "?"} + par {author.get_prenomnom() if author else "?"} : + {annot.comment or ""}{del_link} """ ) - info["liste_annotations"] = "\n".join(alist) + info["liste_annotations"] = "\n".join(annotations_list) # fiche admission - has_adm_notes = ( - info["math"] or info["physique"] or info["anglais"] or info["francais"] + infos_admission = _infos_admission(etud, restrict_etud_data) + has_adm_notes = any( + infos_admission[k] for k in ("math", "physique", "anglais", "francais") ) - has_bac_info = ( - info["bac"] - or info["specialite"] - or info["annee_bac"] - or info["rapporteur"] - or info["commentaire"] - or info["classement"] - or info["type_admission"] + has_bac_info = any( + infos_admission[k] + for k in ( + "bac_specialite", + "annee_bac", + "rapporteur", + "commentaire", + "classement", + "type_admission", + "rap", + ) ) if has_bac_info or has_adm_notes: adm_tmpl = """ @@ -411,7 +379,7 @@ def ficheEtud(etudid=None): BacAnnéeRg MathPhysiqueAnglaisFrançais -%(bac)s (%(specialite)s) +%(bac_specialite)s %(annee_bac)s %(classement)s %(math)s%(physique)s%(anglais)s%(francais)s @@ -419,22 +387,22 @@ def ficheEtud(etudid=None): """ adm_tmpl += """ -

      Bac %(bac)s (%(specialite)s) obtenu en %(annee_bac)s
      -
      %(ilycee)s
      """ - if info["type_admission"] or info["classement"]: +
      Bac %(bac_specialite)s obtenu en %(annee_bac)s
      +
      %(info_lycee)s
      """ + if infos_admission["type_admission"] or infos_admission["classement"]: adm_tmpl += """
      """ - if info["type_admission"]: + if infos_admission["type_admission"]: adm_tmpl += """Voie d'admission: %(type_admission)s """ - if info["classement"]: + if infos_admission["classement"]: adm_tmpl += """Rang admission: %(classement)s""" - if info["type_admission"] or info["classement"]: + if infos_admission["type_admission"] or infos_admission["classement"]: adm_tmpl += "
      " - if info["rap"]: + if infos_admission["rap"]: adm_tmpl += """
      %(rap)s
      """ adm_tmpl += """

    """ else: adm_tmpl = "" # pas de boite "info admission" - info["adm_data"] = adm_tmpl % info + info["adm_data"] = adm_tmpl % infos_admission # Fichiers archivés: info["fichiers_archive_htm"] = ( @@ -455,18 +423,16 @@ def ficheEtud(etudid=None): if has_debouche: info[ "debouche_html" - ] = """
    + ] = f"""
    Devenir:
      - %s + {link_add_suivi}
    -
    """ % ( - suivi_readonly, - info["etudid"], - link_add_suivi, - ) +
    """ else: info["debouche_html"] = "" # pas de boite "devenir" # @@ -492,70 +458,92 @@ def ficheEtud(etudid=None): else: info["groupes_row"] = "" info["menus_etud"] = menus_etud(etudid) - if info["boursier"]: + if info["boursier"] and not restrict_etud_data: info["bourse_span"] = """boursier""" else: info["bourse_span"] = "" - # raccordement provisoire pour juillet 2022, avant refonte complète de cette fiche... - # info["but_infos_mkup"] = jury_but_view.infos_fiche_etud_html(etudid) - - # XXX dev - info["but_cursus_mkup"] = "" - if info["sems"]: - last_sem = FormSemestre.query.get_or_404(info["sems"][0]["formsemestre_id"]) - if last_sem.formation.is_apc(): - but_cursus = cursus_but.EtudCursusBUT(etud, last_sem.formation) - info[ - "but_cursus_mkup" - ] = f""" -
    - {render_template( - "but/cursus_etud.j2", - cursus=but_cursus, - scu=scu, - )} - + # Liens vers compétences BUT + if last_formsemestre and last_formsemestre.formation.is_apc(): + but_cursus = cursus_but.EtudCursusBUT(etud, last_formsemestre.formation) + info[ + "but_cursus_mkup" + ] = f""" +
    + {render_template( + "but/cursus_etud.j2", + cursus=but_cursus, + scu=scu, + )} + - """ +
    + """ + else: + info["but_cursus_mkup"] = "" - tmpl = """ -
    + adresse_template = ( + "" + if restrict_etud_data + else """ + +
    +
    + + + + +
    Adresse : %(domicile)s %(codepostaldomicile)s %(villedomicile)s %(paysdomicile)s + %(modifadresse)s + %(telephones)s +
    +
    + """ + ) + + info_naissance = ( + f"""Né{etud.e} le :{info["info_naissance"]}""" + if info["info_naissance"] + else "" + ) + situation_template = ( + f""" +
    +
    + + + %(groupes_row)s + {info_naissance} +
    Situation :%(situation)s %(bourse_span)s
    + """ + + adresse_template + + """ +
    +
    + """ + ) + + tmpl = ( + """ +

    %(nomprenom)s (%(inscription)s)

    %(etat_civil)s -%(emaillink)s +%(email_link)s
    %(etudfoto)s
    - -
    -
    - - -%(groupes_row)s - -
    Situation :%(situation)s %(bourse_span)s
    Né%(ne)s le :%(info_naissance)s
    - - - -
    - -
    Adresse : %(domicile)s %(codepostaldomicile)s %(villedomicile)s %(paysdomicile)s -%(modifadresse)s -%(telephones)s -
    -
    -
    -
    +""" + + situation_template + + """ %(inscriptions_mkup)s @@ -595,8 +583,9 @@ def ficheEtud(etudid=None):
    """ + ) header = html_sco_header.sco_header( - page_title="Fiche étudiant %(prenom)s %(nom)s" % info, + page_title=f"Fiche étudiant {etud.nomprenom}", cssstyles=[ "libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/jury_but.css", @@ -614,6 +603,92 @@ def ficheEtud(etudid=None): return header + tmpl % info + html_sco_header.sco_footer() +def _format_adresse(adresse: Adresse | None) -> dict: + """{ "telephonestr" : ..., "telephonemobilestr" : ... } (formats html)""" + d = { + "telephonestr": ("Tél.: " + scu.format_telephone(adresse.telephone)) + if (adresse and adresse.telephone) + else "", + "telephonemobilestr": ( + "Mobile: " + scu.format_telephone(adresse.telephonemobile) + ) + if (adresse and adresse.telephonemobile) + else "", + # e-mail: + "email_link": ", ".join( + [ + f"""{m}""" + for m in [adresse.email, adresse.emailperso] + if m + ] + ) + if adresse and (adresse.email or adresse.emailperso) + else "", + "domicile": (adresse.domicile or "") + if adresse + and (adresse.domicile or adresse.codepostaldomicile or adresse.villedomicile) + else "inconnue", + "paysdomicile": f"{sco_etud.format_pays(adresse.paysdomicile)}" + if adresse and adresse.paysdomicile + else "", + } + d["telephones"] = ( + f"
    {d['telephonestr']}    {d['telephonemobilestr']}" + if adresse and (adresse.telephone or adresse.telephonemobile) + else "" + ) + return d + + +def _infos_admission(etud: Identite, restrict_etud_data: bool) -> dict: + """dict with adminission data, restricted or not""" + # info sur rapporteur et son commentaire + rap = "" + if not restrict_etud_data: + if etud.admission.rapporteur or etud.admission.commentaire: + rap = "Note du rapporteur" + if etud.admission.rapporteur: + rap += f" ({etud.admission.rapporteur})" + rap += ": " + if etud.admission.commentaire: + rap += f"{etud.admission.commentaire}" + # nom du lycée + if restrict_etud_data: + info_lycee = "" + elif etud.admission.nomlycee: + info_lycee = "Lycée " + sco_etud.format_lycee(etud.admission.nomlycee) + if etud.admission.villelycee: + info_lycee += f" ({etud.admission.villelycee})" + info_lycee += "
    " + elif etud.admission.codelycee: + info_lycee = sco_etud.format_lycee_from_code(etud.admission.codelycee) + else: + info_lycee = "" + + return { + # infos accessibles à tous: + "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, + "rapporteur": etud.admission.rapporteur if not restrict_etud_data else "", + "rap": rap, + "commentaire": (etud.admission.commentaire or "") + if not restrict_etud_data + else "", + "classement": (etud.admission.classement or "") + if not restrict_etud_data + else "", + "type_admission": (etud.admission.type_admission or "") + if not restrict_etud_data + else "", + "math": (etud.admission.math or "") if not restrict_etud_data else "", + "physique": (etud.admission.physique or "") if not restrict_etud_data else "", + "anglais": (etud.admission.anglais or "") if not restrict_etud_data else "", + "francais": (etud.admission.francais or "") if not restrict_etud_data else "", + } + + def menus_etud(etudid): """Menu etudiant (operations sur l'etudiant)""" authuser = current_user @@ -623,7 +698,7 @@ def menus_etud(etudid): menuEtud = [ { "title": etud["nomprenom"], - "endpoint": "scolar.ficheEtud", + "endpoint": "scolar.fiche_etud", "args": {"etudid": etud["etudid"]}, "enabled": True, "helpmsg": "Fiche étudiant", @@ -671,36 +746,33 @@ def etud_info_html(etudid, with_photo="1", debug=False): """ formsemestre_id = sco_formsemestre_status.retreive_formsemestre_from_request() with_photo = int(with_photo) - etuds = sco_etud.get_etud_info(filled=True) - if etuds: - etud = etuds[0] - else: - abort(404, "etudiant inconnu") - photo_html = sco_photos.etud_photo_html(etud, title="fiche de " + etud["nom"]) - # experimental: may be too slow to be here - code_cursus, _ = sco_report.get_code_cursus_etud(etud, prefix="S", separator=", ") + etud = Identite.get_etud(etudid) - bac = sco_bac.Baccalaureat(etud["bac"], etud["specialite"]) + photo_html = etud.photo_html(etud, title="fiche de " + etud.nomprenom) + code_cursus, _ = sco_report.get_code_cursus_etud( + etud, formsemestres=etud.get_formsemestres(), prefix="S", separator=", " + ) + bac = sco_bac.Baccalaureat(etud.admission.bac, etud.admission.specialite) bac_abbrev = bac.abbrev() H = f"""
    Bac: {bac_abbrev}
    {code_cursus}
    """ # Informations sur l'etudiant dans le semestre courant: - sem = None + formsemestre = None if formsemestre_id: # un semestre est spécifié par la page - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - elif etud["cursem"]: # le semestre "en cours" pour l'étudiant - sem = etud["cursem"] - if sem: - groups = sco_groups.get_etud_groups(etudid, formsemestre_id) + formsemestre = FormSemestre.get_formsemestre(formsemestre_id) + elif inscription_courante: # le semestre "en cours" pour l'étudiant + formsemestre = inscription_courante.formsemestre + if formsemestre: + groups = sco_groups.get_etud_groups(etudid, formsemestre.id) grc = sco_groups.listgroups_abbrev(groups) - H += f"""
    En S{sem["semestre_id"]}: {grc}
    """ + H += f"""
    En S{formsemestre.semestre_id}: {grc}
    """ H += "
    " # fin partie gauche (eid_left) if with_photo: H += '' + photo_html + "" diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index d371833c..a9c437b8 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -55,6 +55,7 @@ _SCO_PERMISSIONS = ( "Exporter les données de l'application relations entreprises", ), (1 << 29, "UsersChangeCASId", "Paramétrer l'id CAS"), + (1 << 30, "ViewEtudData", "Accéder aux données personnelles des étudiants"), # # XXX inutilisée ? (1 << 40, "EtudChangePhoto", "Modifier la photo d'un étudiant"), # Permissions du module Assiduité) diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index cfb4a2ed..f4038962 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -187,7 +187,7 @@ def formsemestre_poursuite_report(formsemestre_id, fmt="html"): ids = [] for etud in etuds: fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url diff --git a/app/scodoc/sco_pv_forms.py b/app/scodoc/sco_pv_forms.py index 54722be1..ce5e624c 100644 --- a/app/scodoc/sco_pv_forms.py +++ b/app/scodoc/sco_pv_forms.py @@ -144,7 +144,7 @@ def pvjury_table( "code_nip": e["identite"]["code_nip"], "nomprenom": e["identite"]["nomprenom"], "_nomprenom_target": url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["identite"]["etudid"], ), @@ -351,7 +351,7 @@ def formsemestre_pvjury_pdf(formsemestre_id, group_ids: list[int] = None, etudid # PV pour ce seul étudiant: etud = Identite.get_etud(etudid) etuddescr = f"""{etud.nomprenom}""" etudids = [etudid] else: diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index d532ea2c..769c202f 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -1017,34 +1017,60 @@ EXP_LIC = re.compile(r"licence", re.I) EXP_LPRO = re.compile(r"professionnelle", re.I) -def _codesem(sem, short=True, prefix=""): +def _code_sem( + semestre_id: int, titre: str, mois_debut: int, short=True, prefix="" +) -> str: "code semestre: S1 ou S1d" - idx = sem["semestre_id"] + idx = semestre_id # semestre décalé ? # les semestres pairs normaux commencent entre janvier et mars # les impairs normaux entre aout et decembre d = "" - if idx and idx > 0 and sem["date_debut"]: - mois_debut = int(sem["date_debut"].split("/")[1]) + if idx > 0: if (idx % 2 and mois_debut < 3) or (idx % 2 == 0 and mois_debut >= 8): d = "d" if idx == -1: if short: idx = "Autre " else: - idx = sem["titre"] + " " + idx = titre + " " idx = EXP_LPRO.sub("pro.", idx) idx = EXP_LIC.sub("Lic.", idx) prefix = "" # indique titre au lieu de Sn - return "%s%s%s" % (prefix, idx, d) + return prefix + str(idx) + d -def get_code_cursus_etud(etud, prefix="", separator=""): +def _code_sem_formsemestre(formsemestre: FormSemestre, short=True, prefix="") -> str: + "code semestre: S1 ou S1d" + titre = formsemestre.titre + mois_debut = formsemestre.date_debut.month + semestre_id = formsemestre.semestre_id + return _code_sem(semestre_id, titre, mois_debut, short=short, prefix=prefix) + + +def _code_sem_dict(sem, short=True, prefix="") -> str: + "code semestre: S1 ou S1d, à parit d'un dict (sem ScoDoc 7)" + titre = sem["titre"] + mois_debut = int(sem["date_debut"].split("/")[1]) if sem["date_debut"] else 0 + semestre_id = sem["semestre_id"] + return _code_sem(semestre_id, titre, mois_debut, short=short, prefix=prefix) + + +def get_code_cursus_etud( + etudid: int, + sems: list[dict] = None, + formsemestres: list[FormSemestre] | None = None, + prefix="", + separator="", +) -> tuple[str, dict]: """calcule un code de cursus (parcours) pour un etudiant exemples: 1234A pour un etudiant ayant effectué S1, S2, S3, S4 puis diplome 12D pour un étudiant en S1, S2 puis démission en S2 12R pour un etudiant en S1, S2 réorienté en fin de S2 + + On peut passer soir la liste des semestres dict (anciennes fonctions ScoDoc7) + soit la liste des FormSemestre. Construit aussi un dict: { semestre_id : decision_jury | None } """ # Nota: approche plus moderne: @@ -1054,31 +1080,37 @@ def get_code_cursus_etud(etud, prefix="", separator=""): # p = [] decisions_jury = {} - # élimine les semestres spéciaux hors cursus (LP en 1 sem., ...) - sems = [s for s in etud["sems"] if s["semestre_id"] >= 0] - i = len(sems) - 1 - while i >= 0: - s = sems[i] # 'sems' est a l'envers, du plus recent au plus ancien - s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) - p.append(_codesem(s, prefix=prefix)) + if formsemestres is None: + formsemestres = [ + FormSemestre.query.get_or_404(s["formsemestre_id"]) for s in (sems or []) + ] + + # élimine les semestres spéciaux hors cursus (LP en 1 sem., ...) + formsemestres = [s for s in formsemestres if s.semestre_id >= 0] + i = len(formsemestres) - 1 + while i >= 0: + # 'sems' est a l'envers, du plus recent au plus ancien + formsemestre = formsemestres[i] + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + p.append(_code_sem_formsemestre(formsemestre, prefix=prefix)) # code decisions jury de chaque semestre: - if nt.get_etud_etat(etud["etudid"]) == "D": - decisions_jury[s["semestre_id"]] = "DEM" + if nt.get_etud_etat(etudid) == "D": + decisions_jury[formsemestre.semestre_id] = "DEM" else: - dec = nt.get_etud_decision_sem(etud["etudid"]) + dec = nt.get_etud_decision_sem(etudid) if not dec: - decisions_jury[s["semestre_id"]] = "" + decisions_jury[formsemestre.semestre_id] = "" else: - decisions_jury[s["semestre_id"]] = dec["code"] + decisions_jury[formsemestre.semestre_id] = dec["code"] # code etat dans le code_cursus sur dernier semestre seulement if i == 0: # Démission - if nt.get_etud_etat(etud["etudid"]) == "D": + if nt.get_etud_etat(etudid) == "D": p.append(":D") else: - dec = nt.get_etud_decision_sem(etud["etudid"]) + dec = nt.get_etud_decision_sem(etudid) if dec and dec["code"] in codes_cursus.CODES_SEM_REO: p.append(":R") if ( @@ -1176,14 +1208,16 @@ def table_suivi_cursus(formsemestre_id, only_primo=False, grouped_parcours=True) ) = tsp_etud_list(formsemestre_id, only_primo=only_primo) codes_etuds = collections.defaultdict(list) for etud in etuds: - etud["code_cursus"], etud["decisions_jury"] = get_code_cursus_etud(etud) + etud["code_cursus"], etud["decisions_jury"] = get_code_cursus_etud( + etud["etudid"], sems=etud["sems"] + ) codes_etuds[etud["code_cursus"]].append(etud) fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + etud["_nom_td_attrs"] = f'''id="{etud['etudid']}" class="etudinfo"''' titles = { "parcours": "Code cursus", @@ -1461,7 +1495,7 @@ def graph_cursus( else: modalite = "" label = "%s%s\\n%d/%s - %d/%s\\n%d" % ( - _codesem(s, short=False, prefix="S"), + _code_sem_dict(s, short=False, prefix="S"), modalite, s["mois_debut_ord"], s["annee_debut"][2:], diff --git a/app/scodoc/sco_roles_default.py b/app/scodoc/sco_roles_default.py index 75cbdedd..a727a94b 100644 --- a/app/scodoc/sco_roles_default.py +++ b/app/scodoc/sco_roles_default.py @@ -13,8 +13,9 @@ SCO_ROLES_DEFAULTS = { p.EnsView, p.EtudAddAnnotations, p.Observateur, - p.UsersView, p.ScoView, + p.ViewEtudData, + p.UsersView, ), "Secr": ( p.AbsAddBillet, @@ -23,8 +24,9 @@ SCO_ROLES_DEFAULTS = { p.EtudAddAnnotations, p.EtudChangeAdr, p.Observateur, - p.UsersView, p.ScoView, + p.UsersView, + p.ViewEtudData, ), # Admin est le chef du département, pas le "super admin" # on doit donc lister toutes ses permissions: @@ -44,9 +46,10 @@ SCO_ROLES_DEFAULTS = { p.EtudInscrit, p.EditFormSemestre, p.Observateur, + p.ScoView, p.UsersAdmin, p.UsersView, - p.ScoView, + p.ViewEtudData, ), # Rôles pour l'application relations entreprises # ObservateurEntreprise est un observateur de l'application entreprise @@ -57,7 +60,8 @@ SCO_ROLES_DEFAULTS = { p.RelationsEntrepEdit, p.RelationsEntrepViewCorrs, ), - # AdminEntreprise est un admin de l'application entreprise (toutes les actions possibles de l'application) + # AdminEntreprise est un admin de l'application entreprise + # (toutes les actions possibles de l'application) "AdminEntreprise": ( p.RelationsEntrepView, p.RelationsEntrepEdit, diff --git a/app/scodoc/sco_trombino.py b/app/scodoc/sco_trombino.py index c197cefd..a77e6ec3 100644 --- a/app/scodoc/sco_trombino.py +++ b/app/scodoc/sco_trombino.py @@ -156,7 +156,7 @@ def trombino_html(groups_infos): '%s' % ( url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=t["etudid"] + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=t["etudid"] ), foto, ) diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 9c9ba39e..e68f2e15 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -427,7 +427,7 @@ APO_MISSING_CODE_STR = "----" # shown in HTML pages in place of missing code Ap EDIT_NB_ETAPES = 6 # Nombre max de codes étapes / semestre presentés dans l'UI IT_SITUATION_MISSING_STR = ( - "____" # shown on ficheEtud (devenir) in place of empty situation + "____" # shown on fiche_etud (devenir) in place of empty situation ) RANG_ATTENTE_STR = "(attente)" # rang affiché sur bulletins quand notes en attente @@ -1281,6 +1281,27 @@ def format_prenom(s): return " ".join(r) +def format_telephone(n: str | None) -> str: + "Format a phone number for display" + if n is None: + return "" + if len(n) < 7: + return n + n = n.replace(" ", "").replace(".", "") + i = 0 + r = "" + j = len(n) - 1 + while j >= 0: + r = n[j] + r + if i % 2 == 1 and j != 0: + r = " " + r + i += 1 + j -= 1 + if len(r) == 13 and r[0] != "0": + r = "0" + r + return r + + # def timedate_human_repr(): "representation du temps courant pour utilisateur" diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index ac2c691a..c6d62c49 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -724,7 +724,7 @@ div.scoinfos { /* ----- fiches etudiants ------ */ -div.ficheEtud { +div.fiche_etud { background-color: #f5edc8; /* rgb(255,240,128); */ border: 1px solid gray; @@ -739,7 +739,7 @@ div.menus_etud { margin-top: 1px; } -div.ficheEtud h2 { +div.fiche_etud h2 { padding-top: 10px; } @@ -925,7 +925,7 @@ td.fichetitre2 { vertical-align: top; } -.ficheEtud span.boursier { +.fiche_etud span.boursier { background-color: red; color: white; margin-left: 12px; @@ -963,6 +963,7 @@ div.section_but { div.section_but > div.link_validation_rcues { align-self: center; + text-align: center; } .ficheannotations { diff --git a/app/templates/bul_head.j2 b/app/templates/bul_head.j2 index 8c725f01..0635c12c 100644 --- a/app/templates/bul_head.j2 +++ b/app/templates/bul_head.j2 @@ -7,7 +7,7 @@ {% if not is_apc %}

    {{etud.nomprenom}}

    {% endif %}
    @@ -81,7 +81,7 @@
    {% if not is_apc %} {% endif %} diff --git a/app/templates/entreprises/fiche_entreprise.j2 b/app/templates/entreprises/fiche_entreprise.j2 index e2c0da71..08bf7ff9 100644 --- a/app/templates/entreprises/fiche_entreprise.j2 +++ b/app/templates/entreprises/fiche_entreprise.j2 @@ -183,7 +183,7 @@ {{ (stage_apprentissage.date_fin-stage_apprentissage.date_debut).days//7 }} semaines {{ stage_apprentissage.type_offre }} {{ + href="{{ url_for('scolar.fiche_etud', scodoc_dept=etudiant.dept_id|get_dept_acronym, etudid=stage_apprentissage.etudid) }}">{{ etudiant.nom|format_nom }} {{ etudiant.prenom|format_prenom }} {% if stage_apprentissage.formation_text %}{{ stage_apprentissage.formation_text }}{% endif %} {{ stage_apprentissage.notes }} diff --git a/app/templates/scolar/partition_editor.j2 b/app/templates/scolar/partition_editor.j2 index ef0e5345..6d5bcf72 100644 --- a/app/templates/scolar/partition_editor.j2 +++ b/app/templates/scolar/partition_editor.j2 @@ -165,7 +165,7 @@ span.calendarEdit { etudiants.forEach(etudiant => { output += `
    - + ${(() => { let output = "
    "; arrayPartitions.forEach((partition) => { diff --git a/app/templates/sidebar.j2 b/app/templates/sidebar.j2 index 865208c1..884931ad 100755 --- a/app/templates/sidebar.j2 +++ b/app/templates/sidebar.j2 @@ -51,7 +51,7 @@
    {% if sco.etud %}

    + 'scolar.fiche_etud', scodoc_dept=g.scodoc_dept, etudid=sco.etud.id )}}" class="sidebar"> {{sco.etud.nomprenom}}

    Absences diff --git a/app/views/absences.py b/app/views/absences.py index 52082b2f..75e61f67 100644 --- a/app/views/absences.py +++ b/app/views/absences.py @@ -358,7 +358,7 @@ def process_billet_absence_form(billet_id: int): page_title=f"Traitement billet d'absence de {etud.nomprenom}", ), f"""

    Traitement du billet {billet.id} : {etud.nomprenom}

    """, ] diff --git a/app/views/notes.py b/app/views/notes.py index 08086b44..ae977682 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -1193,7 +1193,7 @@ def view_module_abs(moduleimpl_id, fmt="html"): "nojust": nb_abs - nb_abs_just, "total": nb_abs, "_nomprenom_target": url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ), } ) @@ -1492,7 +1492,7 @@ def formsemestre_desinscription(etudid, formsemestre_id, dialog_confirmed=False) flash("Étudiant désinscrit") return redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -2371,13 +2371,13 @@ def formsemestre_validation_but(
    {etud.nomprenom}
    Impossible de statuer sur cet étudiant: il est démissionnaire ou défaillant (voir sa fiche)
    @@ -2450,7 +2450,7 @@ def formsemestre_validation_but(
    {etud.nomprenom}
    @@ -2725,7 +2725,7 @@ def formsemestre_validation_suppress_etud( etud = Identite.get_etud(etudid) if formsemestre.formation.is_apc(): next_url = url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, ) @@ -2915,7 +2915,7 @@ def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int): flash("Décisions de jury effacées") return redirect( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id, ) @@ -2931,7 +2931,7 @@ def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int): "jury/erase_decisions_annee_formation.j2", annee=annee, cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ), etud=etud, formation=formation, diff --git a/app/views/scolar.py b/app/views/scolar.py index 7be21ee0..d842dc1b 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -328,7 +328,7 @@ def showEtudLog(etudid, fmt="html"): filename="log_" + scu.make_filename(etud["nomprenom"]), html_next_section=f""" """, preferences=sco_preferences.SemPreferences(), @@ -625,7 +625,7 @@ def etud_info(etudid=None, fmt="xml"): # -------------------------- FICHE ETUDIANT -------------------------- -sco_publish("/ficheEtud", sco_page_etud.ficheEtud, Permission.ScoView) +sco_publish("/fiche_etud", sco_page_etud.fiche_etud, Permission.ScoView) sco_publish( "/etud_upload_file_form", @@ -720,7 +720,7 @@ def doAddAnnotation(etudid, comment): ) logdb(cnx, method="addAnnotation", etudid=etudid) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -745,7 +745,7 @@ def doSuppressAnnotation(etudid, annotation_id): flash("Annotation supprimée") return flask.redirect( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, ) @@ -809,7 +809,7 @@ def form_change_coordonnees(etudid): initvalues=adr, submitlabel="Valider le formulaire", ) - dest_url = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + dest_url = url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) if tf[0] == 0: return "\n".join(H) + tf[1] + html_sco_header.sco_footer() elif tf[0] == -1: @@ -1009,7 +1009,7 @@ def etud_photo_orig_page(etudid=None): html_sco_header.sco_header(page_title=etud["nomprenom"]), "

    %s

    " % etud["nomprenom"], '", html_sco_header.sco_footer(), @@ -1053,7 +1053,7 @@ def form_change_photo(etudid=None): submitlabel="Valider", cancelbutton="Annuler", ) - dest_url = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) + dest_url = url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id) if tf[0] == 0: return ( "\n".join(H) @@ -1092,7 +1092,7 @@ def form_suppress_photo(etudid=None, dialog_confirmed=False): f"

    Confirmer la suppression de la photo de {etud.nom_disp()} ?

    ", dest_url="", cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id ), parameters={"etudid": etud.id}, ) @@ -1100,7 +1100,7 @@ def form_suppress_photo(etudid=None, dialog_confirmed=False): sco_photos.suppress_photo(etud) return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id) ) @@ -1229,7 +1229,7 @@ def _do_dem_or_def_etud( ) if redirect: return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -1301,7 +1301,7 @@ def _do_cancel_dem_or_def( f"

    Confirmer l'annulation de la {operation_name} ?

    ", dest_url="", cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, ) @@ -1325,7 +1325,7 @@ def _do_cancel_dem_or_def( flash(f"{operation_name} annulée.") return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -1784,7 +1784,7 @@ def _etudident_create_or_edit_form(edit): sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) # return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid) ) @@ -1802,7 +1802,7 @@ def etud_copy_in_other_dept(etudid: int): action = request.form.get("action") if action == "cancel": return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id) ) try: formsemestre_id = int(request.form.get("formsemestre_id")) @@ -1833,7 +1833,7 @@ def etud_copy_in_other_dept(etudid: int): # Attention, ce redirect change de département ! return flask.redirect( url_for( - "scolar.ficheEtud", + "scolar.fiche_etud", scodoc_dept=formsemestre.departement.acronym, etudid=new_etud.id, ) @@ -1881,12 +1881,12 @@ def etudident_delete(etudid: int = -1, dialog_confirmed=False): d'un semestre ! (pour cela, passez par sa fiche, menu associé au semestre)

    Vérifier la fiche de {etud.nomprenom}

    """, dest_url="", cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid ), OK="Supprimer définitivement cet étudiant", parameters={"etudid": etudid}, @@ -2018,7 +2018,7 @@ def check_group_apogee(group_id, etat=None, fix=False, fixmail=False): H.append( '%s%s%s%s%s%s' % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), nom, nom_usuel, prenom, diff --git a/migrations/versions/3fa988ff8970_config_permission_viewetuddata.py b/migrations/versions/3fa988ff8970_config_permission_viewetuddata.py new file mode 100644 index 00000000..b4f6d62d --- /dev/null +++ b/migrations/versions/3fa988ff8970_config_permission_viewetuddata.py @@ -0,0 +1,44 @@ +"""config nouvelle permission ViewEtudData: donne aux rôles Ens, Secr, Admin + +Revision ID: 3fa988ff8970 +Revises: b4859c04205f +Create Date: 2024-01-20 13:59:31.491442 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3fa988ff8970" +down_revision = "b4859c04205f" +branch_labels = None +depends_on = None + + +def upgrade(): + # Donne la permission ViewEtudData aux rôles Admin, Ens, Secr + # cette permission est 1<<30 + op.execute( + "UPDATE role SET permissions = permissions | (1<<30) where role.name = 'Admin';" + ) + op.execute( + "UPDATE role SET permissions = permissions | (1<<30) where role.name = 'Ens';" + ) + op.execute( + "UPDATE role SET permissions = permissions | (1<<30) where role.name = 'Secr';" + ) + + +def downgrade(): + # retire la permission ViewEtudData aux rôles Admin, Ens, Secr + # cette permission est 1<<30 + op.execute( + "UPDATE role SET permissions = permissions & ~(1<<30) where role.name = 'Admin';" + ) + op.execute( + "UPDATE role SET permissions = permissions & ~(1<<30) where role.name = 'Ens';" + ) + op.execute( + "UPDATE role SET permissions = permissions & ~(1<<30) where role.name = 'Secr';" + ) diff --git a/tests/unit/test_etudiants.py b/tests/unit/test_etudiants.py index 47346ca8..f2d83d2e 100644 --- a/tests/unit/test_etudiants.py +++ b/tests/unit/test_etudiants.py @@ -357,16 +357,11 @@ def test_import_etuds_xlsx(test_client): "civilite_etat_civil_str": "Mme", "nom_disp": "NOM_USUEL10 (NOM10)", "ne": "(e)", - "email_default": "", "inscription": "ancien", "situation": "ancien élève", "inscriptionstr": "ancien", "inscription_formsemestre_id": None, "etatincursem": "?", - "ilycee": "", - "rap": "", - "telephonestr": "", - "telephonemobilestr": "", }, ) # Test de search_etud_in_dept From 81915b152251edf8e3001be5647df6312596abb6 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 17:37:24 +0100 Subject: [PATCH 2/9] RGPD: ViewEtudData. Implements #842 --- app/api/etudiants.py | 22 +++-- app/api/formsemestres.py | 5 +- app/models/etudiants.py | 14 ++- app/scodoc/sco_archives_etud.py | 2 +- app/scodoc/sco_formsemestre_status.py | 10 +- app/scodoc/sco_groups_view.py | 136 ++++++++++++++++---------- app/scodoc/sco_page_etud.py | 29 ++++-- app/scodoc/sco_permissions.py | 6 +- app/static/css/scodoc.css | 5 + app/views/notes.py | 2 +- app/views/scolar.py | 14 ++- tests/api/test_api_etudiants.py | 17 ++-- tests/api/tools_test_api.py | 8 +- 13 files changed, 174 insertions(+), 96 deletions(-) diff --git a/app/api/etudiants.py b/app/api/etudiants.py index a508ee3b..d66c648d 100755 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -104,7 +104,8 @@ def etudiants_courants(long=False): or_(Departement.acronym == acronym for acronym in allowed_depts) ) if long: - data = [etud.to_dict_api() for etud in etuds] + restrict = not current_user.has_permission(Permission.ViewEtudData) + data = [etud.to_dict_api(restrict=restrict) for etud in etuds] else: data = [etud.to_dict_short() for etud in etuds] return data @@ -138,8 +139,8 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None): 404, message="étudiant inconnu", ) - - return etud.to_dict_api() + restrict = not current_user.has_permission(Permission.ViewEtudData) + return etud.to_dict_api(restrict=restrict) @bp.route("/etudiant/etudid//photo") @@ -251,7 +252,8 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None): query = query.join(Departement).filter( or_(Departement.acronym == acronym for acronym in allowed_depts) ) - return [etud.to_dict_api() for etud in query] + restrict = not current_user.has_permission(Permission.ViewEtudData) + return [etud.to_dict_api(restrict=restrict) for etud in query] @bp.route("/etudiants/name/") @@ -278,7 +280,11 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32): ) 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"))] + restrict = not current_user.has_permission(Permission.ViewEtudData) + return [ + etud.to_dict_api(restrict=restrict) + for etud in sorted(etuds, key=attrgetter("sort_key")) + ] @bp.route("/etudiant/etudid//formsemestres") @@ -543,7 +549,8 @@ def etudiant_create(force=False): # Note: je ne comprends pas pourquoi un refresh est nécessaire ici # sans ce refresh, etud.__dict__ est incomplet (pas de 'nom'). db.session.refresh(etud) - r = etud.to_dict_api() + + r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer return r @@ -590,5 +597,6 @@ def etudiant_edit( # Note: je ne comprends pas pourquoi un refresh est nécessaire ici # sans ce refresh, etud.__dict__ est incomplet (pas de 'nom'). db.session.refresh(etud) - r = etud.to_dict_api() + restrict = not current_user.has_permission(Permission.ViewEtudData) + r = etud.to_dict_api(restrict=restrict) return r diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 6fc38aea..662ddd4d 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -11,7 +11,7 @@ from operator import attrgetter, itemgetter from flask import g, make_response, request from flask_json import as_json -from flask_login import login_required +from flask_login import current_user, login_required import app from app import db @@ -360,7 +360,8 @@ def formsemestre_etudiants( inscriptions = formsemestre.inscriptions if long: - etuds = [ins.etud.to_dict_api() for ins in inscriptions] + restrict = not current_user.has_permission(Permission.ViewEtudData) + etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions] else: etuds = [ins.etud.to_dict_short() for ins in inscriptions] # Ajout des groupes de chaque étudiants diff --git a/app/models/etudiants.py b/app/models/etudiants.py index bc2d0560..a03058e9 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -421,7 +421,7 @@ class Identite(models.ScoDocModel): return args_dict def to_dict_short(self) -> dict: - """Les champs essentiels""" + """Les champs essentiels (aucune donnée perso protégée)""" return { "id": self.id, "civilite": self.civilite, @@ -494,16 +494,22 @@ class Identite(models.ScoDocModel): d["id"] = self.id # a été écrasé par l'id de adresse return d - def to_dict_api(self) -> dict: - """Représentation dictionnaire pour export API, avec adresses et admission.""" + def to_dict_api(self, restrict=False) -> dict: + """Représentation dictionnaire pour export API, avec adresses et admission. + Si restrict, supprime les infos "personnelles" (boursier) + """ e = dict(self.__dict__) e.pop("_sa_instance_state", None) admission = self.admission e["admission"] = admission.to_dict() if admission is not None else None - e["adresses"] = [adr.to_dict() for adr in self.adresses] + e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses] e["dept_acronym"] = self.departement.acronym e.pop("departement", None) e["sort_key"] = self.sort_key + if restrict: + # Met à None les attributs protégés: + for attr in self.protected_attrs: + e[attr] = None return e def inscriptions(self) -> list["FormSemestreInscription"]: diff --git a/app/scodoc/sco_archives_etud.py b/app/scodoc/sco_archives_etud.py index 42fddde2..6f174f15 100644 --- a/app/scodoc/sco_archives_etud.py +++ b/app/scodoc/sco_archives_etud.py @@ -62,7 +62,7 @@ def can_edit_etud_archive(authuser): def etud_list_archives_html(etud: Identite): - """HTML snippet listing archives""" + """HTML snippet listing archives.""" can_edit = can_edit_etud_archive(current_user) etud_archive_id = etud.id L = [] diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 96a0d638..1cea6daf 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -51,13 +51,14 @@ from app.models import ( NotesNotes, ) from app.scodoc.codes_cursus import UE_SPORT -import app.scodoc.sco_utils as scu -from app.scodoc.sco_utils import ModuleType -from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( ScoValueError, ScoInvalidIdType, ) +from app.scodoc.sco_permissions import Permission +import app.scodoc.sco_utils as scu +from app.scodoc.sco_utils import ModuleType + from app.scodoc import html_sco_header from app.scodoc import htmlutils from app.scodoc import sco_archives_formsemestre @@ -109,7 +110,7 @@ def _build_menu_stats(formsemestre_id): "title": "Lycées d'origine", "endpoint": "notes.formsemestre_etuds_lycees", "args": {"formsemestre_id": formsemestre_id}, - "enabled": True, + "enabled": current_user.has_permission(Permission.ViewEtudData), }, { "title": 'Table "poursuite"', @@ -336,6 +337,7 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: formsemestre_id, fix_if_missing=True ), }, + "enabled": current_user.has_permission(Permission.ViewEtudData), }, { "title": "Vérifier inscriptions multiples", diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index c02a3e88..a404c7fa 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -52,7 +52,7 @@ from app.scodoc import sco_preferences from app.scodoc import sco_etud from app.scodoc.sco_etud import etud_sort_key from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_exceptions import ScoValueError, ScoPermissionDenied from app.scodoc.sco_permissions import Permission JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ @@ -118,6 +118,16 @@ def groups_view( init_qtip=True, ) } +
    {form_groups_choice(groups_infos, submit_on_change=True)} @@ -474,15 +484,12 @@ def groups_table( """ from app.scodoc import sco_report - # log( - # "enter groups_table %s: %s" - # % (groups_infos.members[0]["nom"], groups_infos.members[0].get("etape", "-")) - # ) + can_view_etud_data = int(current_user.has_permission(Permission.ViewEtudData)) with_codes = int(with_codes) - with_paiement = int(with_paiement) - with_archives = int(with_archives) - with_annotations = int(with_annotations) - with_bourse = int(with_bourse) + with_paiement = int(with_paiement) and can_view_etud_data + with_archives = int(with_archives) and can_view_etud_data + with_annotations = int(with_annotations) and can_view_etud_data + with_bourse = int(with_bourse) and can_view_etud_data base_url_np = groups_infos.base_url + f"&with_codes={with_codes}" base_url = ( @@ -527,7 +534,8 @@ def groups_table( if fmt != "html": # ne mentionne l'état que en Excel (style en html) columns_ids.append("etat") columns_ids.append("email") - columns_ids.append("emailperso") + if can_view_etud_data: + columns_ids.append("emailperso") if fmt == "moodlecsv": columns_ids = ["email", "semestre_groupe"] @@ -616,7 +624,7 @@ def groups_table( + "+".join(sorted(moodle_groupenames)) ) else: - filename = "etudiants_%s" % groups_infos.groups_filename + filename = f"etudiants_{groups_infos.groups_filename}" prefs = sco_preferences.SemPreferences(groups_infos.formsemestre_id) tab = GenTable( @@ -664,28 +672,33 @@ def groups_table( """ ] if groups_infos.members: - Of = [] + menu_options = [] options = { - "with_paiement": "Paiement inscription", - "with_archives": "Fichiers archivés", - "with_annotations": "Annotations", - "with_codes": "Codes", - "with_bourse": "Statut boursier", + "with_codes": "Affiche codes", } - for option in options: + if can_view_etud_data: + options.update( + { + "with_paiement": "Paiement inscription", + "with_archives": "Fichiers archivés", + "with_annotations": "Annotations", + "with_bourse": "Statut boursier", + } + ) + for option, label in options.items(): if locals().get(option, False): selected = "selected" else: selected = "" - Of.append( - """""" - % (option, selected, options[option]) + menu_options.append( + f"""""" ) H.extend( [ - """""", + "\n".join(menu_options), """ """, + """accès aux données personnelles interdit""" + if not can_view_etud_data + else "", ] ) H.append("
    ") @@ -708,41 +724,45 @@ def groups_table( H.extend( [ tab.html(), - "", ] ) @@ -901,14 +926,19 @@ def tab_absences_html(groups_infos, etat=None): """ ) # Lien pour ajout fichiers étudiants - if authuser.has_permission(Permission.EtudAddAnnotations): + text = "Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)" + if authuser.has_permission( + Permission.EtudAddAnnotations + ) and authuser.has_permission(Permission.ViewEtudData): H.append( f"""
  • Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)
  • """ + )}">{text}""" ) + else: + H.append(f"""
  • {text}
  • """) H.append("
    ") return "".join(H) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 1bdb148c..d34a933f 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -198,7 +198,9 @@ def fiche_etud(etudid=None): info["etudfoto"] = etud.photo_html() # Champ dépendant des permissions: - if current_user.has_permission(Permission.EtudChangeAdr): + if current_user.has_permission( + Permission.EtudChangeAdr + ) and current_user.has_permission(Permission.ViewEtudData): info[ "modifadresse" ] = f"""Fichiers associés' - + sco_archives_etud.etud_list_archives_html(etud) + "" + if restrict_etud_data + else ( + '
    Fichiers associés
    ' + + sco_archives_etud.etud_list_archives_html(etud) + ) ) # Devenir de l'étudiant: @@ -713,7 +719,8 @@ def menus_etud(etudid): "title": "Changer les données identité/admission", "endpoint": "scolar.etudident_edit_form", "args": {"etudid": etud["etudid"]}, - "enabled": authuser.has_permission(Permission.EtudInscrit), + "enabled": authuser.has_permission(Permission.EtudInscrit) + and authuser.has_permission(Permission.ViewEtudData), }, { "title": "Copier dans un autre département...", @@ -748,7 +755,7 @@ def etud_info_html(etudid, with_photo="1", debug=False): with_photo = int(with_photo) etud = Identite.get_etud(etudid) - photo_html = etud.photo_html(etud, title="fiche de " + etud.nomprenom) + photo_html = etud.photo_html(title="fiche de " + etud.nomprenom) code_cursus, _ = sco_report.get_code_cursus_etud( etud, formsemestres=etud.get_formsemestres(), prefix="S", separator=", " ) @@ -758,17 +765,21 @@ def etud_info_html(etudid, with_photo="1", debug=False):
    + }">{etud.nomprenom}
    Bac: {bac_abbrev}
    {code_cursus}
    """ # Informations sur l'etudiant dans le semestre courant: - formsemestre = None if formsemestre_id: # un semestre est spécifié par la page formsemestre = FormSemestre.get_formsemestre(formsemestre_id) - elif inscription_courante: # le semestre "en cours" pour l'étudiant - formsemestre = inscription_courante.formsemestre + else: + # le semestre "en cours" pour l'étudiant + inscription_courante = etud.inscription_courante() + formsemestre = ( + inscription_courante.formsemestre if inscription_courante else None + ) + if formsemestre: groups = sco_groups.get_etud_groups(etudid, formsemestre.id) grc = sco_groups.listgroups_abbrev(groups) diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index a9c437b8..bf871252 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -37,7 +37,11 @@ _SCO_PERMISSIONS = ( # aussi pour demissions, diplomes: (1 << 17, "EtudInscrit", "Inscrire des étudiants"), # aussi pour archives: - (1 << 18, "EtudAddAnnotations", "Éditer les annotations"), + ( + 1 << 18, + "EtudAddAnnotations", + "Éditer les annotations (et fichiers) sur étudiants", + ), # inutilisée (1 << 19, "ScoEntrepriseView", "Voir la section 'entreprises'"), # inutilisée (1 << 20, "EntrepriseChange", "Modifier les entreprises"), # XXX inutilisée ? (1 << 21, "EditPVJury", "Éditer les PV de jury"), diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index c6d62c49..948554f2 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -172,6 +172,11 @@ form#group_selector { margin-bottom: 3px; } +/* Text lien ou itms ,non autorisés pour l'utilisateur courant */ +.unauthorized { + color: grey; +} + /* ----- bandeau haut ------ */ span.bandeaugtr { width: 100%; diff --git a/app/views/notes.py b/app/views/notes.py index ae977682..a6e73ac3 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -3230,7 +3230,7 @@ sco_publish( sco_publish( "/formsemestre_etuds_lycees", sco_lycee.formsemestre_etuds_lycees, - Permission.ScoView, + Permission.ViewEtudData, ) sco_publish( "/scodoc_table_etuds_lycees", diff --git a/app/views/scolar.py b/app/views/scolar.py index d842dc1b..7e1837ce 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -442,7 +442,7 @@ sco_publish( sco_publish( "/groups_export_annotations", sco_groups_exports.groups_export_annotations, - Permission.ScoView, + Permission.ViewEtudData, ) @@ -630,27 +630,27 @@ sco_publish("/fiche_etud", sco_page_etud.fiche_etud, Permission.ScoView) sco_publish( "/etud_upload_file_form", sco_archives_etud.etud_upload_file_form, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) sco_publish( "/etud_delete_archive", sco_archives_etud.etud_delete_archive, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) sco_publish( "/etud_get_archived_file", sco_archives_etud.etud_get_archived_file, - Permission.ScoView, + Permission.ViewEtudData, ) sco_publish( "/etudarchive_import_files_form", sco_archives_etud.etudarchive_import_files_form, - Permission.ScoView, + Permission.ViewEtudData, methods=["GET", "POST"], ) @@ -758,6 +758,8 @@ def doSuppressAnnotation(etudid, annotation_id): @scodoc7func def form_change_coordonnees(etudid): "edit coordonnees etudiant" + if not current_user.has_permission(Permission.ViewEtudData): + raise ScoPermissionDenied() etud = Identite.get_etud(etudid) cnx = ndb.GetDBConnexion() adrs = sco_etud.adresse_list(cnx, {"etudid": etudid}) @@ -1344,6 +1346,8 @@ def etudident_create_form(): @scodoc7func def etudident_edit_form(): "formulaire edition individuelle etudiant" + if not current_user.has_permission(Permission.ViewEtudData): + raise ScoPermissionDenied() return _etudident_create_or_edit_form(edit=True) diff --git a/tests/api/test_api_etudiants.py b/tests/api/test_api_etudiants.py index 208ff1e8..8b1c82e9 100644 --- a/tests/api/test_api_etudiants.py +++ b/tests/api/test_api_etudiants.py @@ -63,6 +63,7 @@ from tests.api.tools_test_api import ( BULLETIN_UES_UE_RESSOURCES_RESSOURCE_FIELDS, BULLETIN_UES_UE_SAES_SAE_FIELDS, ETUD_FIELDS, + ETUD_FIELDS_RESTRICTED, FSEM_FIELDS, verify_fields, verify_occurences_ids_etuds, @@ -113,7 +114,7 @@ def test_etudiants_courant(api_headers): assert len(etudiants) == 16 # HARDCODED etud = etudiants[-1] - assert verify_fields(etud, ETUD_FIELDS) is True + assert verify_fields(etud, ETUD_FIELDS_RESTRICTED) is True assert re.match(r"^\d{4}-\d\d-\d\d$", etud["date_naissance"]) @@ -131,7 +132,7 @@ def test_etudiant(api_headers): ) assert r.status_code == 200 etud = r.json() - assert verify_fields(etud, ETUD_FIELDS) is True + assert verify_fields(etud, ETUD_FIELDS_RESTRICTED) is True code_nip = r.json()["code_nip"] code_ine = r.json()["code_ine"] @@ -183,7 +184,7 @@ def test_etudiants(api_headers): assert isinstance(etud, list) assert len(etud) == 1 - fields_ok = verify_fields(etud[0], ETUD_FIELDS) + fields_ok = verify_fields(etud[0], ETUD_FIELDS_RESTRICTED) assert fields_ok is True ######### Test code nip ######### @@ -964,8 +965,10 @@ def test_etudiant_create(api_headers): assert etud["admission"]["commentaire"] == args["admission"]["commentaire"] assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"] assert len(etud["adresses"]) == 1 - assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] - assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # cette fois les données perso ne sont pas publiées + # assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] + # assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # Edition etud = POST_JSON( f"/etudiant/etudid/{etudid}/edit", @@ -981,8 +984,8 @@ def test_etudiant_create(api_headers): assert etud["admission"]["commentaire"] == args["admission"]["commentaire"] assert etud["admission"]["annee_bac"] == args["admission"]["annee_bac"] assert len(etud["adresses"]) == 1 - assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] - assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] + # assert etud["adresses"][0]["villedomicile"] == args["adresses"][0]["villedomicile"] + # assert etud["adresses"][0]["emailperso"] == args["adresses"][0]["emailperso"] etud = POST_JSON( f"/etudiant/etudid/{etudid}/edit", { diff --git a/tests/api/tools_test_api.py b/tests/api/tools_test_api.py index c7927952..66c3cfc0 100644 --- a/tests/api/tools_test_api.py +++ b/tests/api/tools_test_api.py @@ -44,10 +44,13 @@ DEPARTEMENT_FIELDS = [ "date_creation", ] +# Champs "données personnelles" +ETUD_FIELDS_RESTRICTED = { + "boursier", +} ETUD_FIELDS = { "admission", "adresses", - "boursier", "civilite", "code_ine", "code_nip", @@ -60,7 +63,8 @@ ETUD_FIELDS = { "nationalite", "nom", "prenom", -} +} | ETUD_FIELDS_RESTRICTED + FORMATION_FIELDS = { "dept_id", From 4917034b6d7eab66be29aa59325128a41fcd1b39 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:29:32 +0100 Subject: [PATCH 3/9] =?UTF-8?q?RGPD:=20config.=20coordonn=C3=A9es=20DPO.?= =?UTF-8?q?=20Closes=20#648?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/templates/about.j2 | 9 ++++++++ app/templates/configuration.j2 | 6 +++++ app/views/scodoc.py | 41 ++++++++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/app/templates/about.j2 b/app/templates/about.j2 index b78172cf..4d960b1c 100644 --- a/app/templates/about.j2 +++ b/app/templates/about.j2 @@ -29,6 +29,15 @@

    +
    +

    Coordonnées du délégué à la protection des données (DPO)

    +{% if ScoDocSiteConfig.get("rgpd_coordonnees_dpo") %} + {{ ScoDocSiteConfig.get("rgpd_coordonnees_dpo") }} +{% else %} + non renseigné +{% endif %} +
    +

    Dernières évolutions

    {{ news|safe }} diff --git a/app/templates/configuration.j2 b/app/templates/configuration.j2 index 604310fc..dfe87597 100644 --- a/app/templates/configuration.j2 +++ b/app/templates/configuration.j2 @@ -97,6 +97,12 @@ Heure: {{ time.strftime("%d/%m/%Y %H:%M") }} +

    Protection des données et RGPD

    +
    + +
    + {% endblock %} {% block scripts %} diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 9461a2fc..0967ff16 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -68,6 +68,7 @@ from app.forms.main.config_cas import ConfigCASForm from app.forms.main.config_personalized_links import PersonalizedLinksForm from app.forms.main.create_dept import CreateDeptForm from app.forms.main.role_create import CreateRoleForm +from app.forms.main.config_rgpd import ConfigRGPDForm from app import models from app.models import ( @@ -163,6 +164,31 @@ def config_roles(): ) +@bp.route("/ScoDoc/config_rgpd", methods=["GET", "POST"]) +@admin_required +def config_rgpd(): + """Form configuration RGPD""" + form = ConfigRGPDForm() + if request.method == "POST" and form.cancel.data: # cancel button + return redirect(url_for("scodoc.configuration")) + if form.validate_on_submit(): + if ScoDocSiteConfig.set( + "rgpd_coordonnees_dpo", form.data["rgpd_coordonnees_dpo"] + ): + flash("coordonnées DPO enregistrées") + return redirect(url_for("scodoc.configuration")) + elif request.method == "GET": + form.rgpd_coordonnees_dpo.data = ScoDocSiteConfig.get( + "rgpd_coordonnees_dpo", "" + ) + + return render_template( + "config_rgpd.j2", + form=form, + title="Configuration des fonctions liées au RGPD", + ) + + @bp.route("/ScoDoc/permission_info/") @admin_required def permission_info(perm_name: str): @@ -246,7 +272,7 @@ def config_cas(): """Form config CAS""" form = ConfigCASForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): if ScoDocSiteConfig.set("cas_enable", form.data["cas_enable"]): flash("CAS " + ("activé" if form.data["cas_enable"] else "désactivé")) @@ -322,7 +348,7 @@ def config_assiduites(): """Form config Assiduites""" form = ConfigAssiduitesForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) edt_options = ( ("edt_ics_path", "Chemin vers les calendriers ics"), @@ -409,12 +435,12 @@ def config_codes_decisions(): """Form config codes decisions""" form = CodesDecisionsForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): for code in models.config.CODES_SCODOC_TO_APO: ScoDocSiteConfig.set_code_apo(code, getattr(form, code).data) flash("Codes décisions enregistrés") - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) elif request.method == "GET": for code in models.config.CODES_SCODOC_TO_APO: getattr(form, code).data = ScoDocSiteConfig.get_code_apo(code) @@ -432,7 +458,7 @@ def config_personalized_links(): """Form config liens perso""" form = PersonalizedLinksForm() if request.method == "POST" and form.cancel.data: # cancel button - return redirect(url_for("scodoc.index")) + return redirect(url_for("scodoc.configuration")) if form.validate_on_submit(): links = [] for idx in list(form.links_by_id) + ["new"]: @@ -535,9 +561,10 @@ def about(scodoc_dept=None): "version info" return render_template( "about.j2", - version=scu.get_scodoc_version(), - news=sco_version.SCONEWS, logo=scu.icontag("borgne_img"), + news=sco_version.SCONEWS, + ScoDocSiteConfig=ScoDocSiteConfig, + version=scu.get_scodoc_version(), ) From b8eb8bb77fad3fe9acaf5aa741af1ee0a396cb06 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:30:42 +0100 Subject: [PATCH 4/9] =?UTF-8?q?Deux=20fichiers=20oubli=C3=A9s,=20pour=20#6?= =?UTF-8?q?48?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/forms/main/config_rgpd.py | 49 +++++++++++++++++++++++++++++++++++ app/templates/config_rgpd.j2 | 24 +++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 app/forms/main/config_rgpd.py create mode 100644 app/templates/config_rgpd.j2 diff --git a/app/forms/main/config_rgpd.py b/app/forms/main/config_rgpd.py new file mode 100644 index 00000000..d20ff717 --- /dev/null +++ b/app/forms/main/config_rgpd.py @@ -0,0 +1,49 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Formulaire configuration RGPD +""" + +from flask_wtf import FlaskForm +from wtforms import SubmitField +from wtforms.fields.simple import TextAreaField + + +class ConfigRGPDForm(FlaskForm): + "Formulaire paramétrage RGPD" + rgpd_coordonnees_dpo = TextAreaField( + label="Optionnel: coordonnées du DPO", + description="""Le délégué à la protection des données (DPO) est chargé de mettre en œuvre + la conformité au règlement européen sur la protection des données (RGPD) au sein de l’organisme. + Indiquer ici les coordonnées (format libre) qui seront affichées aux utilisateurs de ScoDoc. + """, + render_kw={"rows": 5, "cols": 72}, + ) + + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/templates/config_rgpd.j2 b/app/templates/config_rgpd.j2 new file mode 100644 index 00000000..f02c674e --- /dev/null +++ b/app/templates/config_rgpd.j2 @@ -0,0 +1,24 @@ +{% extends "base.j2" %} +{% import 'wtf.j2' as wtf %} + +{% block app_content %} +

    {{title}}

    + +
    +

    Certaines fonctionnalités de ScoDoc vous aident à vous conformer + au règlement général de protection des données (RGPD) européen. +

    +

    Rappelons que le logiciel ScoDoc est fourni sans aucune garantie, + selon les termes de sa licence GNU GPL et que ni ses auteurs ni + l'association ScoDoc ne sauraient être tenus responsables de l'usage + qui en est fait. +

    +
    + +
    +
    + {{ wtf.quick_form(form) }} +
    +
    + +{% endblock %} \ No newline at end of file From a65c1d3c4a7f34b8a12ad8fcf5356837eb2c82f2 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 19:36:20 +0100 Subject: [PATCH 5/9] =?UTF-8?q?RGPD:=20dur=C3=A9e=20conservation=20logs=20?= =?UTF-8?q?par=20d=C3=A9faut=20(1=20an).=20Closes=20#647?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/etc/scodoc-logrotate | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/etc/scodoc-logrotate b/tools/etc/scodoc-logrotate index 95b0aa3f..42fc2d3f 100644 --- a/tools/etc/scodoc-logrotate +++ b/tools/etc/scodoc-logrotate @@ -1,7 +1,7 @@ /opt/scodoc-data/log/scodoc.log { weekly missingok - rotate 64 + rotate 53 compress notifempty dateext @@ -10,7 +10,7 @@ /opt/scodoc-data/log/scodoc_exc.log { weekly missingok - rotate 64 + rotate 53 compress notifempty dateext From 7d2d5a3ea9a38063ccbdc7db2754649748fe9dc5 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 20:19:50 +0100 Subject: [PATCH 6/9] typos --- app/api/justificatifs.py | 3 ++- app/scodoc/sco_utils.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index ff1487a9..9fd61fdc 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -152,7 +152,8 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal @permission_required(Permission.ScoView) def justificatifs_dept(dept_id: int = None, with_query: bool = False): """ - Renvoie tous les justificatifs d'un département (en ajoutant un champs "formsemestre" si possible) + Renvoie tous les justificatifs d'un département + (en ajoutant un champ "formsemestre" si possible) """ # Récupération du département et des étudiants du département diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index e68f2e15..038f0310 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -279,7 +279,7 @@ class NonWorkDays(int, BiDirectionalEnum): ] -def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None: +def is_iso_formated(date: str, convert=False) -> bool | datetime.datetime | None: """ Vérifie si une date est au format iso From f09b2028e2557c99442f310f48f22290ef119bbc Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 21:34:28 +0100 Subject: [PATCH 7/9] Adaptation a minima de la table 'poursuites' pour le BUT. Closes #849. --- app/scodoc/sco_poursuite_dut.py | 54 +++++++++++++++++---------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index f4038962..ba866e22 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -38,20 +38,19 @@ from app.comp.res_compat import NotesTableCompat from app.models import FormSemestre import app.scodoc.sco_utils as scu from app.scodoc import sco_assiduites -from app.scodoc import sco_cache from app.scodoc import sco_formsemestre from app.scodoc import sco_groups from app.scodoc import sco_preferences from app.scodoc import sco_etud -import sco_version from app.scodoc.gen_tables import GenTable from app.scodoc.codes_cursus import code_semestre_validant, code_semestre_attente +import sco_version -def etud_get_poursuite_info(sem, etud): +def etud_get_poursuite_info(sem: dict, etud: dict) -> dict: """{ 'nom' : ..., 'semlist' : [ { 'semestre_id': , 'moy' : ... }, {}, ...] }""" - I = {} - I.update(etud) # copie nom, prenom, civilite, ... + infos = {} + infos.update(etud) # copie nom, prenom, civilite, ... # Now add each semester, starting from the first one semlist = [] @@ -92,25 +91,28 @@ def etud_get_poursuite_info(sem, etud): for ue in ues: # on parcourt chaque UE for modimpl in modimpls: # dans chaque UE les modules if modimpl["module"]["ue_id"] == ue["ue_id"]: - codeModule = modimpl["module"]["code"] or "" - noteModule = scu.fmt_note( + code_module = modimpl["module"]["code"] or "" + note_module = scu.fmt_note( nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) ) - if noteModule != "NI": # si étudiant inscrit au module + # si étudiant inscrit au module, sauf BUT + if (note_module != "NI") and not nt.is_apc: if nt.mod_rangs is not None: - rangModule = nt.mod_rangs[modimpl["moduleimpl_id"]][ - 0 - ][etudid] + rang_module = nt.mod_rangs[ + modimpl["moduleimpl_id"] + ][0][etudid] else: - rangModule = "" - modules.append([codeModule, noteModule]) - rangs.append(["rang_" + codeModule, rangModule]) + rang_module = "" + modules.append([code_module, note_module]) + rangs.append(["rang_" + code_module, rang_module]) # Absences nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, nt.sem) - if ( + # En BUT, prend tout, sinon ne prend que les semestre validés par le jury + if nt.is_apc or ( dec - and not sem_descr # not sem_descr pour ne prendre que le semestre validé le plus récent + # not sem_descr pour ne prendre que le semestre validé le plus récent: + and not sem_descr and ( code_semestre_validant(dec["code"]) or code_semestre_attente(dec["code"]) @@ -128,9 +130,8 @@ def etud_get_poursuite_info(sem, etud): ("AbsNonJust", nbabs - nbabsjust), ("AbsJust", nbabsjust), ] - d += ( - moy_ues + rg_ues + modules + rangs - ) # ajout des 2 champs notes des modules et classement dans chaque module + # ajout des 2 champs notes des modules et classement dans chaque module + d += moy_ues + rg_ues + modules + rangs sem_descr = collections.OrderedDict(d) if not sem_descr: sem_descr = collections.OrderedDict( @@ -147,13 +148,14 @@ def etud_get_poursuite_info(sem, etud): sem_descr["semestre_id"] = sem_id semlist.append(sem_descr) - I["semlist"] = semlist - return I + infos["semlist"] = semlist + return infos def _flatten_info(info): - # met la liste des infos semestres "a plat" - # S1_moy, S1_rang, ..., S2_moy, ... + """met la liste des infos semestres "a plat" + S1_moy, S1_rang, ..., S2_moy, ... + """ ids = [] for s in info["semlist"]: for k, v in s.items(): @@ -164,7 +166,7 @@ def _flatten_info(info): return ids -def _getEtudInfoGroupes(group_ids, etat=None): +def _get_etud_info_groupes(group_ids, etat=None): """liste triée d'infos (dict) sur les etudiants du groupe indiqué. Attention: lent, car plusieurs requetes SQL par etudiant ! """ @@ -181,7 +183,7 @@ def _getEtudInfoGroupes(group_ids, etat=None): def formsemestre_poursuite_report(formsemestre_id, fmt="html"): """Table avec informations "poursuite" """ sem = sco_formsemestre.get_formsemestre(formsemestre_id) - etuds = _getEtudInfoGroupes([sco_groups.get_default_group(formsemestre_id)]) + etuds = _get_etud_info_groupes([sco_groups.get_default_group(formsemestre_id)]) infos = [] ids = [] @@ -191,7 +193,7 @@ def formsemestre_poursuite_report(formsemestre_id, fmt="html"): ) etud["_nom_target"] = fiche_url etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + etud["_nom_td_attrs"] = f"""id="{etud['etudid']}" class="etudinfo" """ info = etud_get_poursuite_info(sem, etud) idd = _flatten_info(info) # On recupere la totalite des UEs dans ids From 555e8af818ec4e245c9787d6b1d6d912158dab95 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sun, 21 Jan 2024 18:07:56 +0100 Subject: [PATCH 8/9] =?UTF-8?q?Acc=C3=A8s=20au=20d=C3=A9tail=20d'un=20just?= =?UTF-8?q?ificatif=20avec=20AbsJustifView:=20closes=20#824?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/__init__.py | 17 ++- app/api/justificatifs.py | 22 ++-- app/models/assiduites.py | 18 +-- app/scodoc/html_sco_header.py | 4 +- app/scodoc/html_sidebar.py | 60 ++++++++- app/scodoc/sco_formsemestre_status.py | 52 +------- app/scodoc/sco_page_etud.py | 4 +- app/scodoc/sco_permissions.py | 8 +- app/tables/liste_assiduites.py | 22 +++- .../pages/ajout_justificatif_etud.j2 | 33 ++++- .../pages/tableau_assiduite_actions.j2 | 6 +- .../widgets/tableau_actions/details.j2 | 116 +++++++++++------- .../widgets/tableau_actions/modifier.j2 | 10 +- app/templates/sidebar_dept.j2 | 9 +- app/views/__init__.py | 5 +- app/views/assiduites.py | 47 +++---- sco_version.py | 2 +- 17 files changed, 274 insertions(+), 161 deletions(-) diff --git a/app/api/__init__.py b/app/api/__init__.py index a6f2b680..fb994bfd 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -3,9 +3,11 @@ from flask_json import as_json from flask import Blueprint from flask import request, g +from flask_login import current_user from app import db from app.scodoc import sco_utils as scu from app.scodoc.sco_exceptions import AccessDenied, ScoException +from app.scodoc.sco_permissions import Permission api_bp = Blueprint("api", __name__) api_web_bp = Blueprint("apiweb", __name__) @@ -48,13 +50,21 @@ def requested_format(default_format="json", allowed_formats=None): @as_json -def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None): +def get_model_api_object( + model_cls: db.Model, + model_id: int, + join_cls: db.Model = None, + restrict: bool | None = None, +): """ Retourne une réponse contenant la représentation api de l'objet "Model[model_id]" Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> join_cls exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py + + L'agument restrict est passé to_dict, est signale que l'on veut une version restreinte + (sans données personnelles, ou sans informations sur le justificatif d'absence) """ query = model_cls.query.filter_by(id=model_id) if g.scodoc_dept and join_cls is not None: @@ -66,8 +76,9 @@ def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model 404, message=f"{model_cls.__name__} inexistant(e)", ) - - return unique.to_dict(format_api=True) + if restrict is None: + return unique.to_dict(format_api=True) + return unique.to_dict(format_api=True, restrict=restrict) from app.api import tokens diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index 9fd61fdc..a24b0f27 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -53,14 +53,19 @@ def justificatif(justif_id: int = None): "date_fin": "2022-10-31T10:00+01:00", "etat": "valide", "fichier": "archive_id", - "raison": "une raison", + "raison": "une raison", // VIDE si pas le droit "entry_date": "2022-10-31T08:00+01:00", "user_id": 1 or null, } """ - return get_model_api_object(Justificatif, justif_id, Identite) + return get_model_api_object( + Justificatif, + justif_id, + Identite, + restrict=not current_user.has_permission(Permission.AbsJustifView), + ) # etudid @@ -133,8 +138,9 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal # Mise en forme des données puis retour en JSON data_set: list[dict] = [] + restrict = not current_user.has_permission(Permission.AbsJustifView) for just in justificatifs_query.all(): - data = just.to_dict(format_api=True) + data = just.to_dict(format_api=True, restrict=restrict) data_set.append(data) return data_set @@ -172,14 +178,15 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False): justificatifs_query: Query = _filter_manager(request, justificatifs_query) # Mise en forme des données et retour JSON + restrict = not current_user.has_permission(Permission.AbsJustifView) data_set: list[dict] = [] for just in justificatifs_query: - data_set.append(_set_sems(just)) + data_set.append(_set_sems(just, restrict=restrict)) return data_set -def _set_sems(justi: Justificatif) -> dict: +def _set_sems(justi: Justificatif, restrict: bool) -> dict: """ _set_sems Ajoute le formsemestre associé au justificatif s'il existe @@ -192,7 +199,7 @@ def _set_sems(justi: Justificatif) -> dict: dict: La représentation de l'assiduité en dictionnaire """ # Conversion du justificatif en dictionnaire - data = justi.to_dict(format_api=True) + data = justi.to_dict(format_api=True, restrict=restrict) # Récupération du formsemestre de l'assiduité formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict()) @@ -246,9 +253,10 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False): justificatifs_query: Query = _filter_manager(request, justificatifs_query) # Retour des justificatifs en JSON + restrict = not current_user.has_permission(Permission.AbsJustifView) data_set: list[dict] = [] for justi in justificatifs_query.all(): - data = justi.to_dict(format_api=True) + data = justi.to_dict(format_api=True, restrict=restrict) data_set.append(data) return data_set diff --git a/app/models/assiduites.py b/app/models/assiduites.py index c7cf8fa3..b4087e4e 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -88,8 +88,10 @@ class Assiduite(ScoDocModel): lazy="select", ) - def to_dict(self, format_api=True) -> dict: - """Retourne la représentation json de l'assiduité""" + def to_dict(self, format_api=True, restrict: bool | None = None) -> dict: + """Retourne la représentation json de l'assiduité + restrict n'est pas utilisé ici. + """ etat = self.etat user: User | None = None if format_api: @@ -453,8 +455,10 @@ class Justificatif(ScoDocModel): query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id) return query.first_or_404() - def to_dict(self, format_api: bool = False) -> dict: - """transformation de l'objet en dictionnaire sérialisable""" + def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict: + """L'objet en dictionnaire sérialisable. + Si restrict, ne donne par la raison et les fichiers et external_data + """ etat = self.etat user: User = self.user if self.user_id is not None else None @@ -469,13 +473,13 @@ class Justificatif(ScoDocModel): "date_debut": self.date_debut, "date_fin": self.date_fin, "etat": etat, - "raison": self.raison, - "fichier": self.fichier, + "raison": None if restrict else self.raison, + "fichier": None if restrict else self.fichier, "entry_date": self.entry_date, "user_id": None if user is None else user.id, # l'uid "user_name": None if user is None else user.user_name, # le login "user_nom_complet": None if user is None else user.get_nomcomplet(), - "external_data": self.external_data, + "external_data": None if restrict else self.external_data, } return data diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 2e06370a..b76a8e77 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -145,7 +145,9 @@ def sco_header( etudid=None, formsemestre_id=None, ): - "Main HTML page header for ScoDoc" + """Main HTML page header for ScoDoc + Utilisé dans les anciennes pages. Les nouvelles pages utilisent le template Jinja. + """ from app.scodoc.sco_formsemestre_status import formsemestre_page_title if etudid is not None: diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py index dce59627..c0c732ce 100755 --- a/app/scodoc/html_sidebar.py +++ b/app/scodoc/html_sidebar.py @@ -32,12 +32,66 @@ from flask import render_template, url_for from flask import g, request from flask_login import current_user +from app import db +from app.models import Evaluation, GroupDescr, ModuleImpl, Partition import app.scodoc.sco_utils as scu from app.scodoc import sco_preferences from app.scodoc.sco_permissions import Permission from sco_version import SCOVERSION +def retreive_formsemestre_from_request() -> int: + """Cherche si on a de quoi déduire le semestre affiché à partir des + arguments de la requête: + formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id + Returns None si pas défini. + """ + if request.method == "GET": + args = request.args + elif request.method == "POST": + args = request.form + else: + return None + formsemestre_id = None + # Search formsemestre + group_ids = args.get("group_ids", []) + if "formsemestre_id" in args: + formsemestre_id = args["formsemestre_id"] + elif "moduleimpl_id" in args and args["moduleimpl_id"]: + modimpl = db.session.get(ModuleImpl, args["moduleimpl_id"]) + if not modimpl: + return None # suppressed ? + formsemestre_id = modimpl.formsemestre_id + elif "evaluation_id" in args: + evaluation = db.session.get(Evaluation, args["evaluation_id"]) + if not evaluation: + return None # evaluation suppressed ? + formsemestre_id = evaluation.moduleimpl.formsemestre_id + elif "group_id" in args: + group = db.session.get(GroupDescr, args["group_id"]) + if not group: + return None + formsemestre_id = group.partition.formsemestre_id + elif group_ids: + if isinstance(group_ids, str): + group_ids = group_ids.split(",") + group_id = group_ids[0] + group = db.session.get(GroupDescr, group_id) + if not group: + return None + formsemestre_id = group.partition.formsemestre_id + elif "partition_id" in args: + partition = db.session.get(Partition, args["partition_id"]) + if not partition: + return None + formsemestre_id = partition.formsemestre_id + + if formsemestre_id is None: + return None # no current formsemestre + + return int(formsemestre_id) + + def sidebar_common(): "partie commune à toutes les sidebar" home_link = url_for("scodoc.index", scodoc_dept=g.scodoc_dept) @@ -129,13 +183,17 @@ def sidebar(etudid: int = None): ) H.append("
      ") if current_user.has_permission(Permission.AbsChange): + # essaie de conserver le semestre actuellement en vue + cur_formsemestre_id = retreive_formsemestre_from_request() H.append( f"""
    • Ajouter
    • Justifier
    • """ ) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 1cea6daf..20d3ec65 100755 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -76,6 +76,7 @@ from app.scodoc import sco_moduleimpl from app.scodoc import sco_preferences from app.scodoc import sco_users from app.scodoc.gen_tables import GenTable +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_formsemestre_custommenu import formsemestre_custommenu_html import sco_version @@ -476,57 +477,6 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: return "\n".join(H) -def retreive_formsemestre_from_request() -> int: - """Cherche si on a de quoi déduire le semestre affiché à partir des - arguments de la requête: - formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id - Returns None si pas défini. - """ - if request.method == "GET": - args = request.args - elif request.method == "POST": - args = request.form - else: - return None - formsemestre_id = None - # Search formsemestre - group_ids = args.get("group_ids", []) - if "formsemestre_id" in args: - formsemestre_id = args["formsemestre_id"] - elif "moduleimpl_id" in args and args["moduleimpl_id"]: - modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=args["moduleimpl_id"]) - if not modimpl: - return None # suppressed ? - modimpl = modimpl[0] - formsemestre_id = modimpl["formsemestre_id"] - elif "evaluation_id" in args: - E = sco_evaluation_db.get_evaluations_dict( - {"evaluation_id": args["evaluation_id"]} - ) - if not E: - return None # evaluation suppressed ? - E = E[0] - modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] - formsemestre_id = modimpl["formsemestre_id"] - elif "group_id" in args: - group = sco_groups.get_group(args["group_id"]) - formsemestre_id = group["formsemestre_id"] - elif group_ids: - if isinstance(group_ids, str): - group_ids = group_ids.split(",") - group_id = group_ids[0] - group = sco_groups.get_group(group_id) - formsemestre_id = group["formsemestre_id"] - elif "partition_id" in args: - partition = sco_groups.get_partition(args["partition_id"]) - formsemestre_id = partition["formsemestre_id"] - - if not formsemestre_id: - return None # no current formsemestre - - return int(formsemestre_id) - - # Element HTML decrivant un semestre (barre de menu et infos) def formsemestre_page_title(formsemestre_id=None): """Element HTML decrivant un semestre (barre de menu et infos) diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index d34a933f..1f5d572f 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -46,11 +46,11 @@ from app.scodoc import ( sco_bac, sco_cursus, sco_etud, - sco_formsemestre_status, sco_groups, sco_permissions_check, sco_report, ) +from app.scodoc.html_sidebar import retreive_formsemestre_from_request from app.scodoc.sco_bulletins import etud_descr_situation_semestre from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table @@ -751,7 +751,7 @@ def etud_info_html(etudid, with_photo="1", debug=False): """An HTML div with basic information and links about this etud. Used for popups information windows. """ - formsemestre_id = sco_formsemestre_status.retreive_formsemestre_from_request() + formsemestre_id = retreive_formsemestre_from_request() with_photo = int(with_photo) etud = Identite.get_etud(etudid) diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index bf871252..6e5e53ee 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -24,7 +24,7 @@ _SCO_PERMISSIONS = ( (1 << 10, "EditAllNotes", "Modifier toutes les notes"), (1 << 11, "EditAllEvals", "Modifier toutes les évaluations"), (1 << 12, "EditFormSemestre", "Mettre en place une formation (créer un semestre)"), - (1 << 13, "AbsChange", "Saisir des absences"), + (1 << 13, "AbsChange", "Saisir des absences ou justificatifs"), (1 << 14, "AbsAddBillet", "Saisir des billets d'absences"), # changer adresse/photo ou pour envoyer bulletins par mail ou pour debouche (1 << 15, "EtudChangeAdr", "Changer les adresses d'étudiants"), @@ -63,7 +63,11 @@ _SCO_PERMISSIONS = ( # # XXX inutilisée ? (1 << 40, "EtudChangePhoto", "Modifier la photo d'un étudiant"), # Permissions du module Assiduité) - (1 << 50, "AbsJustifView", "Visualisation des fichiers justificatifs"), + ( + 1 << 50, + "AbsJustifView", + "Visualisation du détail des justificatifs (motif, fichiers)", + ), # Attention: les permissions sont codées sur 64 bits. ) diff --git a/app/tables/liste_assiduites.py b/app/tables/liste_assiduites.py index 24a449b5..e2f93788 100644 --- a/app/tables/liste_assiduites.py +++ b/app/tables/liste_assiduites.py @@ -1,6 +1,7 @@ from datetime import datetime from flask import url_for +from flask_login import current_user from flask_sqlalchemy.query import Query from sqlalchemy import desc, literal, union, asc @@ -10,6 +11,7 @@ from app.models import Assiduite, Identite, Justificatif from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool from app.tables import table_builder as tb from app.scodoc.sco_cache import RequeteTableauAssiduiteCache +from app.scodoc.sco_permissions import Permission class Pagination: @@ -107,6 +109,11 @@ class ListeAssiJusti(tb.Table): self.total_page: int = None + # Accès aux détail des justificatifs ? + self.can_view_justif_detail = current_user.has_permission( + Permission.AbsJustifView + ) + # les lignes du tableau self.rows: list["RowAssiJusti"] = [] @@ -342,7 +349,7 @@ class RowAssiJusti(tb.Row): # Type d'objet self._type() - # En excel, on export les "vraes dates". + # En excel, on export les "vraies dates". # En HTML, on écrit en français (on laisse les dates pour le tri) multi_days = self.ligne["date_debut"].date() != self.ligne["date_fin"].date() @@ -470,10 +477,21 @@ class RowAssiJusti(tb.Row): def _optionnelles(self) -> None: if self.table.options.show_desc: + if self.ligne.get("type") == "justificatif": + # protection de la "raison" + if ( + self.ligne["user_id"] == current_user.id + or self.table.can_view_justif_detail + ): + description = self.ligne["desc"] if self.ligne["desc"] else "" + else: + description = "(cachée)" + else: + description = self.ligne["desc"] if self.ligne["desc"] else "" self.add_cell( "description", "Description", - self.ligne["desc"] if self.ligne["desc"] else "", + description, ) if self.table.options.show_module: if self.ligne["type"] == "assiduite": diff --git a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 index 61bd3197..27349ba5 100644 --- a/app/templates/assiduites/pages/ajout_justificatif_etud.j2 +++ b/app/templates/assiduites/pages/ajout_justificatif_etud.j2 @@ -17,6 +17,9 @@ form#ajout-justificatif-etud { form#ajout-justificatif-etud > div { margin-bottom: 16px; } +fieldset > div { + margin-bottom: 12px; +} div.fichiers { margin-top: 16px; margin-bottom: 32px; @@ -33,9 +36,20 @@ div.submit { div.submit > input { margin-right: 16px; } +.info-saisie { + margin-top: 12px; + margin-bottom: 12px; + font-style: italic; +}
      -

      Justifier des absences ou retards

      +

      {{title|safe}}

      + + {% if justif %} +
      + Saisie par {{justif.user.get_prenomnom()}} le {{justif.entry_date.strftime("%d/%m/%Y à %H:%M")}} +
      + {% endif %}
      @@ -72,16 +86,24 @@ div.submit > input {
      {# Raison #}
      -
      {{ form.raison.label }}
      - {{ form.raison() }} - {{ render_field_errors(form, 'raison') }} + {% if (not justif) or can_view_justif_detail %} +
      {{ form.raison.label }}
      + {{ form.raison() }} + {{ render_field_errors(form, 'raison') }} +
      La raison sera visible aux utilisateurs ayant le droit + AbsJustifView et à celui ayant déposé le justificatif + {%- if justif %} ({{justif.user.get_prenomnom()}}){%- endif -%}. +
      + {% else %} +
      raison confidentielle
      + {% endif %}
      {# Liste des fichiers existants #} {% if justif and nb_files > 0 %}
      {{nb_files}} fichiers justificatifs déposés {% if filenames|length < nb_files %} - , dont {{filenames|length}} vous sont accessibles + , dont {{filenames|length}} vous {{'sont accessibles' if filenames|length > 1 else 'est accessible'}} {% endif %}
      @@ -104,6 +126,7 @@ div.submit > input { {{ form.entry_date.label }} : {{ form.entry_date }} laisser vide pour date courante {{ render_field_errors(form, 'entry_date') }} + {# Submit #}
      {{ form.submit }} {{ form.cancel }} diff --git a/app/templates/assiduites/pages/tableau_assiduite_actions.j2 b/app/templates/assiduites/pages/tableau_assiduite_actions.j2 index 903fceba..705aaec3 100644 --- a/app/templates/assiduites/pages/tableau_assiduite_actions.j2 +++ b/app/templates/assiduites/pages/tableau_assiduite_actions.j2 @@ -10,11 +10,11 @@ {% if action == "modifier" %} {% include "assiduites/widgets/tableau_actions/modifier.j2" %} -{% else%} +{% else %} {% include "assiduites/widgets/tableau_actions/details.j2" %} {% endif %} -{% if not current_user.has_permission(sco.Permission.AbsJustifView)%} +{% if not current_user.has_permission(sco.Permission.AbsJustifView) %}
      Vous n'avez pas la permission d'ouvrir les fichiers justificatifs déposés par d'autres personnes. @@ -22,7 +22,7 @@ {% endif %}