RGPD: ViewEtudData. Implements #842

This commit is contained in:
Emmanuel Viennet 2024-01-20 17:37:24 +01:00
parent 9c1c316f14
commit 238fbe887c
13 changed files with 174 additions and 96 deletions

View File

@ -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/<int: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/<string:start>")
@ -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/<int: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

View File

@ -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

View File

@ -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"]:

View File

@ -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 = []

View File

@ -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",

View File

@ -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,
)
}
<style>
div.multiselect-container.dropdown-menu {{
min-width: 180px;
}}
span.warning_unauthorized {{
color: pink;
font-style: italic;
margin-left: 12px;
}}
</style>
<div id="group-tabs">
<!-- Menu choix groupe -->
{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,6 +534,7 @@ def groups_table(
if fmt != "html": # ne mentionne l'état que en Excel (style en html)
columns_ids.append("etat")
columns_ids.append("email")
if can_view_etud_data:
columns_ids.append("emailperso")
if fmt == "moodlecsv":
@ -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_codes": "Affiche codes",
}
if can_view_etud_data:
options.update(
{
"with_paiement": "Paiement inscription",
"with_archives": "Fichiers archivés",
"with_annotations": "Annotations",
"with_codes": "Codes",
"with_bourse": "Statut boursier",
}
for option in options:
)
for option, label in options.items():
if locals().get(option, False):
selected = "selected"
else:
selected = ""
Of.append(
"""<option value="%s" %s>%s</option>"""
% (option, selected, options[option])
menu_options.append(
f"""<option value="{option}" {selected}>{label}</option>"""
)
H.extend(
[
"""<span style="margin-left: 2em;"><select name="group_list_options" id="group_list_options" class="multiselect" multiple="multiple">""",
"\n".join(Of),
"""<span style="margin-left: 2em;">
<select name="group_list_options" id="group_list_options" class="multiselect" multiple="multiple">""",
"\n".join(menu_options),
"""</select></span>
<script type="text/javascript">
$(document).ready(function() {
@ -701,6 +714,9 @@ def groups_table(
});
</script>
""",
"""<span class="warning_unauthorized">accès aux données personnelles interdit</span>"""
if not can_view_etud_data
else "",
]
)
H.append("</div></form>")
@ -708,41 +724,45 @@ def groups_table(
H.extend(
[
tab.html(),
"<ul>",
'<li><a class="stdlink" href="%s&fmt=xlsappel">Feuille d\'appel Excel</a></li>'
% (tab.base_url,),
'<li><a class="stdlink" href="%s&fmt=xls">Table Excel</a></li>'
% (tab.base_url,),
'<li><a class="stdlink" href="%s&fmt=moodlecsv">Fichier CSV pour Moodle (groupe sélectionné)</a></li>'
% (tab.base_url,),
"""<li>
<a class="stdlink" href="export_groups_as_moodle_csv?formsemestre_id=%s">Fichier CSV pour Moodle (tous les groupes)</a>
f"""
<ul>
<li><a class="stdlink" href="{tab.base_url}&fmt=xlsappel">Feuille d'appel Excel</a>
</li>
<li><a class="stdlink" href="{tab.base_url}&fmt=xls">Table Excel</a>
</li>
<li><a class="stdlink" href="{tab.base_url}&fmt=moodlecsv">Fichier CSV pour Moodle (groupe sélectionné)</a>
</li>
<li>
<a class="stdlink" href="export_groups_as_moodle_csv?formsemestre_id={groups_infos.formsemestre_id}">
Fichier CSV pour Moodle (tous les groupes)</a>
<em>(voir le paramétrage pour modifier le format des fichiers Moodle exportés)</em>
</li>"""
% groups_infos.formsemestre_id,
</li>""",
]
)
if amail_inst:
H.append(
'<li><a class="stdlink" href="mailto:?bcc=%s">Envoyer un mail collectif au groupe de %s (via %d adresses institutionnelles)</a></li>'
% (
",".join(amail_inst),
groups_infos.groups_titles,
len(amail_inst),
)
f"""<li>
<a class="stdlink" href="mailto:?bcc={','.join(amail_inst)
}">Envoyer un mail collectif au groupe de {groups_infos.groups_titles}
(via {len(amail_inst)} adresses institutionnelles)</a>
</li>"""
)
if can_view_etud_data:
if amail_perso:
H.append(
'<li><a class="stdlink" href="mailto:?bcc=%s">Envoyer un mail collectif au groupe de %s (via %d adresses personnelles)</a></li>'
% (
",".join(amail_perso),
groups_infos.groups_titles,
len(amail_perso),
)
f"""<li>
<a class="stdlink" href="mailto:?bcc={','.join(amail_perso)
}">Envoyer un mail collectif au groupe de {groups_infos.groups_titles}
(via {len(amail_perso)} adresses personnelles)</a>
</li>"""
)
else:
H.append("<li><em>Adresses personnelles non renseignées</em></li>")
else:
H.append(
"""<li class="unauthorized">adresses mail personnelles protégées</li>"""
)
H.append("</ul>")
@ -772,6 +792,10 @@ def groups_table(
filename = "liste_%s" % groups_infos.groups_filename
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE)
elif fmt == "allxls":
if not can_view_etud_data:
raise ScoPermissionDenied(
"Vous n'avez pas la permission requise (ViewEtudData)"
)
# feuille Excel avec toutes les infos etudiants
if not groups_infos.members:
return ""
@ -881,8 +905,9 @@ def tab_absences_html(groups_infos, etat=None):
% groups_infos.groups_query_args,
"""<li><a class="stdlink" href="trombino?%s&fmt=pdflist">Liste d'appel avec photos</a></li>"""
% groups_infos.groups_query_args,
"""<li><a class="stdlink" href="groups_export_annotations?%s">Liste des annotations</a></li>"""
% groups_infos.groups_query_args,
f"""<li><a class="stdlink" href="groups_export_annotations?{groups_infos.groups_query_args}">Liste des annotations</a></li>"""
if authuser.has_permission(Permission.ViewEtudData)
else """<li class="unauthorized" title="non autorisé">Liste des annotations</li>""",
"</ul>",
]
)
@ -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"""<li><a class="stdlink" href="{
url_for('scolar.etudarchive_import_files_form',
scodoc_dept=g.scodoc_dept,
group_id=group_id
)}">Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)</a></li>"""
)}">{text}</a></li>"""
)
else:
H.append(f"""<li class="unauthorized" title="non autorisé">{text}</li>""")
H.append("</ul></div>")
return "".join(H)

View File

@ -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"""<a class="stdlink" href="{
@ -406,9 +408,13 @@ def fiche_etud(etudid=None):
# Fichiers archivés:
info["fichiers_archive_htm"] = (
""
if restrict_etud_data
else (
'<div class="fichetitre">Fichiers associés</div>'
+ sco_archives_etud.etud_list_archives_html(etud)
)
)
# Devenir de l'étudiant:
has_debouche = True
@ -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):
<div class="eid_left">
<div class="eid_nom"><div><a class="stdlink" target="_blank" href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">{etud["nomprenom"]}</a></div></div>
}">{etud.nomprenom}</a></div></div>
<div class="eid_info eid_bac">Bac: <span class="eid_bac">{bac_abbrev}</span></div>
<div class="eid_info eid_parcours">{code_cursus}</div>
"""
# 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)

View File

@ -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"),

View File

@ -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%;

View File

@ -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",

View File

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

View File

@ -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",
{

View File

@ -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",