diff --git a/ZNotes.py b/ZNotes.py old mode 100644 new mode 100755 index c1d30eba0..ff667fd30 --- a/ZNotes.py +++ b/ZNotes.py @@ -90,6 +90,7 @@ import sco_compute_moy import sco_recapcomplet import sco_liste_notes import sco_saisie_notes +import sco_saisie_notes_moodle import sco_placement import sco_undo_notes import sco_formations @@ -2460,6 +2461,9 @@ class ZNotes(ObjectManager, PropertyManager, RoleManager, Item, Persistent, Impl security.declareProtected(ScoEnsView, "saisie_notes_tableur") saisie_notes_tableur = sco_saisie_notes.saisie_notes_tableur + security.declareProtected(ScoEnsView, "import_eval_notes_from_moodle") + import_eval_notes_from_moodle = sco_saisie_notes_moodle.import_from_moodle + security.declareProtected(ScoEnsView, "feuille_saisie_notes") feuille_saisie_notes = sco_saisie_notes.feuille_saisie_notes diff --git a/sco_preferences.py b/sco_preferences.py old mode 100644 new mode 100755 index 888696938..94cfd9cd3 --- a/sco_preferences.py +++ b/sco_preferences.py @@ -1744,6 +1744,26 @@ Année scolaire: %(anneescolaire)s "category": "edt", }, ), + ( + "moodle_server_url", + { + "title": "URL pour accéder au service web de Moodle", + "initvalue": "", + "explanation": "cette URL est du type https://nom_du_serveur/moodle/webservice/rest/server.php", + "size": 50, + "category": "portal", + }, + ), + ( + "moodle_ws_token", + { + "title": "jeton d'identification pour le service web de Moodle", + "initvalue": "", + "explanation": "ce jeton est créé par moodle dans la gestion du plugin service web: consultez l'administrateur de Moodle", + "size": 30, + "category": "portal", + }, + ), ) PREFS_NAMES = set([x[0] for x in PREFS]) diff --git a/sco_saisie_notes.py b/sco_saisie_notes.py index 5309ee9cd..7b5b6cfd6 100644 --- a/sco_saisie_notes.py +++ b/sco_saisie_notes.py @@ -921,27 +921,37 @@ def saisie_notes(context, evaluation_id, group_ids=[], REQUEST=None): H.append("""
""") H.append(sco_groups_view.form_groups_choice(context, groups_infos)) H.append('') + + # Pour savoir si l'interface Moodle est configurée: + moodle_token = context.get_preference("moodle_ws_token", formsemestre_id) + moodle_serveur = context.get_preference("moodle_server_url", formsemestre_id) + menu_items = [ + { + "title": "Saisie par fichier tableur", + "id": "menu_saisie_tableur", + "url": "/saisie_notes_tableur?evaluation_id=%s&%s" + % (E["evaluation_id"], groups_infos.groups_query_args), + }, + { + "id": "import_moodle", + "title": "Importer les notes depuis Moodle", + "url": "/import_from_moodle?evaluation_id=%s" % (E["evaluation_id"],), + "enabled": moodle_serveur and moodle_token, + }, + { + "title": "Voir toutes les notes du module", + "url": "/evaluation_listenotes?moduleimpl_id=%s" % E["moduleimpl_id"], + }, + { + "title": "Effacer toutes les notes de cette évaluation", + "url": "/evaluation_suppress_alln?evaluation_id=%s" % (E["evaluation_id"],), + }, + ] + H.append( htmlutils.make_menu( "Autres opérations", - [ - { - "title": "Saisie par fichier tableur", - "id": "menu_saisie_tableur", - "url": "/saisie_notes_tableur?evaluation_id=%s&%s" - % (E["evaluation_id"], groups_infos.groups_query_args), - }, - { - "title": "Voir toutes les notes du module", - "url": "/evaluation_listenotes?moduleimpl_id=%s" - % E["moduleimpl_id"], - }, - { - "title": "Effacer toutes les notes de cette évaluation", - "url": "/evaluation_suppress_alln?evaluation_id=%s" - % (E["evaluation_id"],), - }, - ], + menu_items, base_url=context.absolute_url(), alone=True, ) diff --git a/sco_saisie_notes_moodle.py b/sco_saisie_notes_moodle.py new file mode 100644 index 000000000..563256506 --- /dev/null +++ b/sco_saisie_notes_moodle.py @@ -0,0 +1,474 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2021 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 +# +############################################################################## + +"""Importation de notes depuis Moodle + Contrib. Pierre-Alain Jacquot, mai 2021 +""" + +# QUESTION: un (long) commentaire expliquant le principe de base de ce module + +import requests +import re + + +def cleanhtml(raw_html): + cleanr = re.compile("<.*?>") + cleantext = re.sub(cleanr, " ", raw_html) + cleantext = cleantext.strip() + cleantext = cleantext.encode("utf-8") + return cleantext + + +# +def get_moodle_course_id(moodle_serveur, moodle_token, courses_short_name): + param_cours = { + "wstoken": moodle_token, + "moodlewsrestformat": "json", + "wsfunction": "core_course_get_courses_by_field", + "field": "shortname", + "value": courses_short_name, + } + try: + r = requests.post(url=moodle_serveur, data=param_cours).json() + except ValueError: + raise ValueError("Erreur de connexion vérifiez l'URL de Moodle") + if "exception" in r: + raise ValueError( + "Connexion au service web de Moodle impossible %s : Vérifiez votre paramétrage" + % r["message"] + ) + if len(r["courses"]) == 0: + courseid = 0 + else: + courseid = r["courses"][0]["id"] + return courseid + + +def has_student_role(user): + """ + Retourne vrai si l'utilisateur a le role 5 : «etudiant» ou «student» dans le cours + i.e. il a des notes + """ + # QUESTION: ce nombre "5" est une constante universelle dans Moodle ? + est_etudiant = False + for role in user["roles"]: + # print "role : "+str(role['roleid'] ) + if role["roleid"] == 5: + # print "est_etudiant "+str(role['roleid'] ) + est_etudiant = 1 + return est_etudiant + + +def get_etudiants_from_course(moodle_serveur, moodle_token, courses_short_name): + """ + Extrait la liste des étudiants des utilisateurs inscrit dans le cours. + Cette liste contient les informations suivante : + - id moodle + - email + - idnumber (numéro d'identification) s'il existe : celui ci peut servir a stocker le EID ou le nip + """ + courseid = get_moodle_course_id(moodle_serveur, moodle_token, courses_short_name) + param_cours = { + "wstoken": moodle_token, + "moodlewsrestformat": "json", + "wsfunction": "core_enrol_get_enrolled_users", + "options[0][name]": "onlyactive", + "options[0][value]": "1", + "options[1][name]": "userfields", + "options[1][value]": "id,email,idnumber,roles", + "courseid": courseid, + } + r = requests.post(url=moodle_serveur, data=param_cours).json() + etudiants = [user for user in r if has_student_role(user)] + # le role n'est plus une information pertinente : suppression + for etudiant in etudiants: + del etudiant["roles"] + etudiant["email"] = etudiant["email"].encode("ascii").lower() + return etudiants + + +def get_evaluation_list(moodle_serveur, moodle_token, courses_short_name): + """ + Récupère la liste des evaluations du cours Moodle + On recherche les notes d'un seul etudiant pour gagner du temps. + """ + # QUESTION: documenter les valeurs résultats + etudiants = get_etudiants_from_course( + moodle_serveur, moodle_token, courses_short_name + ) + a_userid = etudiants[0]["id"] + courseid = get_moodle_course_id(moodle_serveur, moodle_token, courses_short_name) + param_notes = { + "wstoken": moodle_token, + "moodlewsrestformat": "json", + "wsfunction": "gradereport_user_get_grades_table", + "courseid": courseid, + "userid": a_userid, + } + r = requests.post(url=moodle_serveur, data=param_notes) + notes = r.json() + bareme = {} + liste_evals = [] + for etu_notes in notes["tables"][0]["tabledata"]: + if "grade" in etu_notes: + nom_eval = cleanhtml(etu_notes["itemname"]["content"]) + liste_evals.append(nom_eval) + bareme_min, bareme_max = etu_notes["range"]["content"].split("–") + bareme[nom_eval] = { + "min": float(bareme_min.replace(",", ".")), + "max": float(bareme_max.replace(",", ".")), + } + return liste_evals, bareme + + +def get_grades_from_moodle_course(moodle_serveur, moodle_token, courses_short_name): + """ + Récupère toutes les notes du cours et les remet en forme + dans un dictionnaire indexé par le userid de moodle + {userid: { nom_eval:note, ...}} + """ + courseid = get_moodle_course_id(moodle_serveur, moodle_token, courses_short_name) + param_notes = { + "wstoken": moodle_token, + "moodlewsrestformat": "json", + "wsfunction": "gradereport_user_get_grades_table", + "courseid": courseid, + } + r = requests.post(url=moodle_serveur, data=param_notes) + notes = r.json() + notes_evals = {} + for etudiant in notes["tables"]: + # remise en forme des notes dans un dictionnaire indexe par le nom de la note + tab_notes = {} + + for etu_notes in etudiant["tabledata"]: + if "grade" in etu_notes: + if etu_notes["grade"]["content"] == "-": + etu_notes["grade"]["content"] = "SUPR" + tab_notes[cleanhtml(etu_notes["itemname"]["content"])] = etu_notes[ + "grade" + ]["content"] + notes_evals[etudiant["userid"]] = tab_notes + return notes_evals + + +# QUESTION: j'ai l'impression qu'il y a trop de code en commun entre cette fonction et +# sco_saisie_notes._form_saisie_notes + +# QUESTION: manque vérification de la présence de décisions de jury ?? (qui devrait bloquer l'import amha) + + +def import_eval_notes_from_moodle(context, evaluation_id, group_ids=[], REQUEST=None): + """Récuperation des notes sur moodle""" + moodle_token = context.get_preference("moodle_ws_token", formsemestre_id) + moodle_serveur = context.get_preference("moodle_server_url", formsemestre_id) + # Désactive si l'interface n'est pas configurée: + if not moodle_serveur or not moodle_token: + return "Interface Moodle non paramétrée !" + + authuser = REQUEST.AUTHENTICATED_USER + evals = context.do_evaluation_list({"evaluation_id": evaluation_id}) + if not evals: + raise ScoValueError("invalid evaluation_id") + E = evals[0] + M = context.do_moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] + # M = context.do_moduleimpl_list( args={ 'moduleimpl_id' : E['moduleimpl_id'] } )[0] + formsemestre_id = M["formsemestre_id"] + if not can_edit_notes(context, authuser, E["moduleimpl_id"]): + return ( + context.sco_header(REQUEST) + + "

Modification des notes impossible pour %s

" % authusername + + """

(vérifiez que le semestre n'est pas verrouillé et que vous + avez l'autorisation d'effectuer cette opération)

+

Continuer

+ """ + % E["moduleimpl_id"] + + context.sco_footer(REQUEST) + ) + + if E["description"]: + page_title = 'Saisie des notes de "%s"' % E["description"] + else: + page_title = "Saisie des notes" + + # Informations sur les groupes à afficher: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + context, + group_ids=group_ids, + formsemestre_id=formsemestre_id, + select_all_when_unspecified=True, + etat=None, + REQUEST=REQUEST, + ) + + H = [ + context.sco_header( + REQUEST, + page_title=page_title, + javascripts=sco_groups_view.JAVASCRIPTS, + cssstyles=sco_groups_view.CSSSTYLES, + init_qtip=True, + ), + sco_evaluations.evaluation_describe( + context, evaluation_id=evaluation_id, REQUEST=REQUEST + ), + """Import des notes depuis Moodle""", + ] + H.append( + """
+
+ Nom abrégé du cours sur Moodle + +
+
+ """ + % (evaluation_id) + ) + if "course_short_name" in REQUEST.form: + course_short_name = REQUEST.form["course_short_name"] + courseid = get_moodle_course_id(moodle_serveur, moodle_token, course_short_name) + + if courseid == 0: + H.append( + """ +

" %s " n'est pas un nom abrégé de cours connu sur ce Moodle

+ """ + % course_short_name + ) + else: + list_evaluations, bareme = get_evaluation_list( + moodle_serveur, moodle_token, course_short_name + ) + + msg = "

Remarque : Si l'étudiant n'a pas de note sur Moodle la note dans cette évaluation sera supprimée

" + if len(list_evaluations) > 5: + msg += "

ATTENTION : Le chargement des notes peut prendre beaucoup de temps

" + H.append( + """
+ liste des évaluations du cours %s +
+ """ + % course_short_name + ) + pbplage = False + for ev in range(0, len(list_evaluations)): + # verification du bareme + marque = "" + if ( + bareme[list_evaluations[ev]]["min"] != scu.NOTES_MIN + or bareme[list_evaluations[ev]]["max"] != E["note_max"] + ): + marque = ( + """note entre %.2f et %.2f""" + % ( + bareme[list_evaluations[ev]]["min"], + bareme[list_evaluations[ev]]["max"], + ) + ) + pbplage = True + H.append( + """ + %s %s
+ """ + % (ev, list_evaluations[ev], marque) + ) + if pbplage: + msg += ( + '

ATTENTION : certaines évaluations ne sont pas dans la plage %.2f - %.2f il faudrait modifier cette cette évaluation pour pouvoir les importer !

' + % (scu.NOTES_MIN, E["note_max"]) + ) + H.append( + """ + + +
+ %s +
+ """ + % (course_short_name, evaluation_id, msg) + ) + if "num_eval" in REQUEST.form: + nom_eval = list_evaluations[int(REQUEST.form["num_eval"])] + etudiant_info = get_etudiants_from_course( + moodle_serveur, moodle_token, course_short_name + ) + moodle_notes = get_grades_from_moodle_course( + moodle_serveur, moodle_token, course_short_name + ) + email_id = {} + nip_id = {} + for etu in groups_infos.members: + email = str(etu["email"]).lower() + email_id[email] = etu["etudid"] + nip_id[etu["code_nip"]] = etu["etudid"] + nouvelles_notes = [] + for etu in etudiant_info: + # La présence d'un code nip est prioritaire sur l'adresse mail + if "idnumber" in etu: + nouvelles_notes.append( + (nip_id[etu["idnumber"]], moodle_notes[etu["id"]][nom_eval]) + ) + elif etu["email"] in email_id: + email = str(etu["email"]).lower() + nouvelles_notes.append( + (email_id[email], moodle_notes[etu["id"]][nom_eval]) + ) + updiag = do_moodle_import( + context, + REQUEST, + nouvelles_notes, + "Moodle/%s/%s" % (course_short_name, nom_eval), + ) + # updiag=[0,"en test: merci de patienter"] + if updiag[0]: + H.append(updiag[1]) + H.append( + """

Notes chargées.    + + Revenir au tableau de bord du module +     + Charger d'autres notes dans cette évaluation +

""" + % E + ) + else: + H.append( + """

Notes non chargées !

""" + + updiag[1] + ) + H.append( + """ +

+ Reprendre +

""" + % E + ) + # + H.append("""

Autres opérations

    """) + if can_edit_notes( + context, REQUEST.AUTHENTICATED_USER, E["moduleimpl_id"], allow_ens=False + ): + H.append( + """ +
  • +
    + Mettre toutes les notes manquantes à + + + ABS indique "absent" (zéro), EXC "excusé" (neutralisées), ATT "attente" +
    +
  • +
  • Effacer toutes les notes de cette évaluation + (ceci permet ensuite de supprimer l'évaluation si besoin) +
  • """ + % (evaluation_id, evaluation_id) + ) #' + H.append( + """
  • Revenir au module
  • +
  • Revenir au formulaire de saisie
  • +
""" + % E + ) + H.append(context.sco_footer(REQUEST)) + return "\n".join(H) + + +# QUESTION: Beaucoup de code dupliqué de sco-saisie_notes => maintenance trop difficile à terme +# => refactoring nécessaire +def do_moodle_import(context, REQUEST, notes, comment): + """import moodle""" + authuser = REQUEST.AUTHENTICATED_USER + evaluation_id = REQUEST.form["evaluation_id"] + # comment = "Importée de moodle"#REQUEST.form['comment'] + E = context.do_evaluation_list({"evaluation_id": evaluation_id})[0] + M = context.do_moduleimpl_withmodule_list(moduleimpl_id=E["moduleimpl_id"])[0] + # M = context.do_moduleimpl_withmodule_list( args={ 'moduleimpl_id' : E['moduleimpl_id'] } )[0] + # Check access + # (admin, respformation, and responsable_id) + # if not context.can_edit_notes( authuser, E['moduleimpl_id'] ): + if not can_edit_notes(context, authuser, E["moduleimpl_id"]): + # XXX imaginer un redirect + msg erreur + raise AccessDenied("Modification des notes impossible pour %s" % authuser) + # + diag = [] + try: + # -- check values + L, invalids, withoutnotes, absents, tosuppress = _check_notes( + notes, E, M["module"] + ) + if len(invalids): + diag.append( + "Erreur: Moodle fournit %d notes invalides vérifiez que la note maximale est bien la même sur scodoc et sur Moodle

" + % len(invalids) + ) + if len(invalids) < 25: + etudsnames = [ + context.getEtudInfo(etudid=etudid, filled=True)[0]["nomprenom"] + for etudid in invalids + ] + diag.append("Notes invalides pour: " + ", ".join(etudsnames)) + raise InvalidNoteValue() + else: + nb_changed, nb_suppress, existing_decisions = _notes_add( + context, authuser, evaluation_id, L, comment + ) + # news + cnx = context.GetDBConnexion() + E = context.do_evaluation_list({"evaluation_id": evaluation_id})[0] + M = context.do_moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] + # M = context.do_moduleimpl_list( args={ 'moduleimpl_id':E['moduleimpl_id'] } )[0] + mod = context.do_module_list(args={"module_id": M["module_id"]})[0] + mod["moduleimpl_id"] = M["moduleimpl_id"] + mod["url"] = "Notes/moduleimpl_status?moduleimpl_id=%(moduleimpl_id)s" % mod + sco_news.add( + context, + REQUEST, + typ=NEWS_NOTE, + object=M["moduleimpl_id"], + text='Chargement notes dans %(titre)s' % mod, + url=mod["url"], + ) + + msg = ( + "

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

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

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

""" + # msg += '

' + str(notes) # debug + return 1, msg + + except InvalidNoteValue: + if diag: + msg = ( + '

  • ' + + '
  • '.join(diag) + + "
" + ) + else: + msg = '
  • Une erreur est survenue
' + return 0, msg + "

(pas de notes modifiées)

"