From 238fbe887c5b177537c8111bc36ee0b8106a341e Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Sat, 20 Jan 2024 17:37:24 +0100 Subject: [PATCH] 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 a508ee3b5..d66c648d1 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 6fc38aea0..662ddd4dc 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 bc2d0560c..a03058e99 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 42fddde2d..6f174f15d 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 6aa773a78..fa63d54a4 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 c02a3e881..a404c7fa3 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 1bdb148c9..d34a933fd 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 a9c437b87..bf8712523 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 c6d62c491..948554f2f 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 ae9776824..a6e73ac35 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 d842dc1b2..7e1837ce6 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 208ff1e82..8b1c82e99 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 c7927952c..66c3cfc0f 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",