# -*- mode: python -*- # -*- coding: utf-8 -*- ############################################################################## # # Gestion scolarite IUT # # Copyright (c) 1999 - 2022 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@gmail.com # ############################################################################## """Evaluations """ import datetime import operator import time import flask from flask import url_for from flask import g from flask_login import current_user from flask import request from app import log import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType import app.scodoc.notesdb as ndb from app.scodoc.sco_exceptions import AccessDenied, ScoValueError import sco_version from app.scodoc.gen_tables import GenTable from app.scodoc import html_sco_header from app.scodoc import sco_evaluation_db from app.scodoc import sco_abs from app.scodoc import sco_cache from app.scodoc import sco_edit_module from app.scodoc import sco_edit_ue from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_groups from app.scodoc import sco_moduleimpl from app.scodoc import sco_news from app.scodoc import sco_permissions_check from app.scodoc import sco_preferences from app.scodoc import sco_users # -------------------------------------------------------------------- # # MISC AUXILIARY FUNCTIONS # # -------------------------------------------------------------------- def notes_moyenne_median_mini_maxi(notes): "calcule moyenne et mediane d'une liste de valeurs (floats)" notes = [ x for x in notes if (x != None) and (x != scu.NOTES_NEUTRALISE) and (x != scu.NOTES_ATTENTE) ] n = len(notes) if not n: return None, None, None, None moy = sum(notes) / n median = ListMedian(notes) mini = min(notes) maxi = max(notes) return moy, median, mini, maxi def ListMedian(L): """Median of a list L""" n = len(L) if not n: raise ValueError("empty list") L.sort() if n % 2: return L[n // 2] else: return (L[n // 2] + L[n // 2 - 1]) / 2 # -------------------------------------------------------------------- def do_evaluation_etat(evaluation_id, partition_id=None, select_first_partition=False): """donne infos sur l'état de l'évaluation { nb_inscrits, nb_notes, nb_abs, nb_neutre, nb_att, moyenne, mediane, mini, maxi, date_last_modif, gr_complets, gr_incomplets, evalcomplete } evalcomplete est vrai si l'eval est complete (tous les inscrits à ce module ont des notes) evalattente est vrai s'il ne manque que des notes en attente """ nb_inscrits = len( sco_groups.do_evaluation_listeetuds_groups(evaluation_id, getallstudents=True) ) etuds_notes_dict = sco_evaluation_db.do_evaluation_get_all_notes( evaluation_id ) # { etudid : note } # ---- Liste des groupes complets et incomplets E = sco_evaluation_db.do_evaluation_list(args={"evaluation_id": evaluation_id})[0] M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] is_malus = Mod["module_type"] == ModuleType.MALUS # True si module de malus formsemestre_id = M["formsemestre_id"] # Si partition_id is None, prend 'all' ou bien la premiere: if partition_id is None: if select_first_partition: partitions = sco_groups.get_partitions_list(formsemestre_id) partition = partitions[0] else: partition = sco_groups.get_default_partition(formsemestre_id) partition_id = partition["partition_id"] # Il faut considerer les inscriptions au semestre # (pour avoir l'etat et le groupe) et aussi les inscriptions # au module (pour gerer les modules optionnels correctement) insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( formsemestre_id ) insmod = sco_moduleimpl.do_moduleimpl_inscription_list( moduleimpl_id=E["moduleimpl_id"] ) insmodset = set([x["etudid"] for x in insmod]) # retire de insem ceux qui ne sont pas inscrits au module ins = [i for i in insem if i["etudid"] in insmodset] # Nombre de notes valides d'étudiants inscrits au module # (car il peut y avoir des notes d'étudiants désinscrits depuis l'évaluation) etudids_avec_note = insmodset.intersection(etuds_notes_dict) nb_notes = len(etudids_avec_note) # toutes saisies, y compris chez des non-inscrits: nb_notes_total = len(etuds_notes_dict) notes = [etuds_notes_dict[etudid]["value"] for etudid in etudids_avec_note] nb_abs = len([x for x in notes if x is None]) nb_neutre = len([x for x in notes if x == scu.NOTES_NEUTRALISE]) nb_att = len([x for x in notes if x == scu.NOTES_ATTENTE]) moy_num, median_num, mini_num, maxi_num = notes_moyenne_median_mini_maxi(notes) if moy_num is None: median, moy = "", "" median_num, moy_num = None, None mini, maxi = "", "" mini_num, maxi_num = None, None else: median = scu.fmt_note(median_num) moy = scu.fmt_note(moy_num) mini = scu.fmt_note(mini_num) maxi = scu.fmt_note(maxi_num) # cherche date derniere modif note if len(etuds_notes_dict): t = [x["date"] for x in etuds_notes_dict.values()] last_modif = max(t) else: last_modif = None # On considere une note "manquante" lorsqu'elle n'existe pas # ou qu'elle est en attente (ATT) GrNbMissing = scu.DictDefault() # group_id : nb notes manquantes GrNotes = scu.DictDefault(defaultvalue=[]) # group_id: liste notes valides TotalNbMissing = 0 TotalNbAtt = 0 groups = {} # group_id : group etud_groups = sco_groups.get_etud_groups_in_partition(partition_id) for i in ins: group = etud_groups.get(i["etudid"], None) if group and not group["group_id"] in groups: groups[group["group_id"]] = group # isMissing = False if i["etudid"] in etuds_notes_dict: val = etuds_notes_dict[i["etudid"]]["value"] if val == scu.NOTES_ATTENTE: isMissing = True TotalNbAtt += 1 if group: GrNotes[group["group_id"]].append(val) else: if group: _ = GrNotes[group["group_id"]] # create group isMissing = True if isMissing: TotalNbMissing += 1 if group: GrNbMissing[group["group_id"]] += 1 gr_incomplets = [x for x in GrNbMissing.keys()] gr_incomplets.sort() if ( (TotalNbMissing > 0) and (E["evaluation_type"] != scu.EVALUATION_RATTRAPAGE) and (E["evaluation_type"] != scu.EVALUATION_SESSION2) and not is_malus ): complete = False else: complete = True if ( TotalNbMissing > 0 and ((TotalNbMissing == TotalNbAtt) or E["publish_incomplete"]) and not is_malus ): evalattente = True else: evalattente = False # mais ne met pas en attente les evals immediates sans aucune notes: if E["publish_incomplete"] and nb_notes == 0: evalattente = False # Calcul moyenne dans chaque groupe de TD gr_moyennes = [] # group : {moy,median, nb_notes} for group_id in GrNotes.keys(): notes = GrNotes[group_id] gr_moy, gr_median, gr_mini, gr_maxi = notes_moyenne_median_mini_maxi(notes) gr_moyennes.append( { "group_id": group_id, "group_name": groups[group_id]["group_name"], "gr_moy_num": gr_moy, "gr_moy": scu.fmt_note(gr_moy), "gr_median_num": gr_median, "gr_median": scu.fmt_note(gr_median), "gr_mini": scu.fmt_note(gr_mini), "gr_maxi": scu.fmt_note(gr_maxi), "gr_mini_num": gr_mini, "gr_maxi_num": gr_maxi, "gr_nb_notes": len(notes), "gr_nb_att": len([x for x in notes if x == scu.NOTES_ATTENTE]), } ) gr_moyennes.sort(key=operator.itemgetter("group_name")) # retourne mapping return { "evaluation_id": evaluation_id, "nb_inscrits": nb_inscrits, "nb_notes": nb_notes, # nb notes etudiants inscrits "nb_notes_total": nb_notes_total, # nb de notes (incluant desinscrits) "nb_abs": nb_abs, "nb_neutre": nb_neutre, "nb_att": nb_att, "moy": moy, "moy_num": moy_num, "median": median, "mini": mini, "mini_num": mini_num, "maxi": maxi, "maxi_num": maxi_num, "median_num": median_num, "last_modif": last_modif, "gr_incomplets": gr_incomplets, "gr_moyennes": gr_moyennes, "groups": groups, "evalcomplete": complete, "evalattente": evalattente, "is_malus": is_malus, } def do_evaluation_list_in_sem(formsemestre_id, with_etat=True): """Liste les evaluations de tous les modules de ce semestre. Donne pour chaque eval son état (voir do_evaluation_etat) { evaluation_id,nb_inscrits, nb_notes, nb_abs, nb_neutre, moy, median, last_modif ... } Exemple: [ { 'coefficient': 1.0, 'description': 'QCM et cas pratiques', 'etat': {'evalattente': False, 'evalcomplete': True, 'evaluation_id': 'GEAEVAL82883', 'gr_incomplets': [], 'gr_moyennes': [{'gr_median': '12.00', 'gr_median_num' : 12., 'gr_moy': '11.88', 'gr_moy_num' : 11.88, 'gr_nb_att': 0, 'gr_nb_notes': 166, 'group_id': 'GEAG266762', 'group_name': None}], 'groups': {'GEAG266762': {'etudid': 'GEAEID80603', 'group_id': 'GEAG266762', 'group_name': None, 'partition_id': 'GEAP266761'} }, 'last_modif': datetime.datetime(2015, 12, 3, 15, 15, 16), 'median': '12.00', 'moy': '11.84', 'nb_abs': 2, 'nb_att': 0, 'nb_inscrits': 166, 'nb_neutre': 0, 'nb_notes': 168, 'nb_notes_total': 169 }, 'evaluation_id': 'GEAEVAL82883', 'evaluation_type': 0, 'heure_debut': datetime.time(8, 0), 'heure_fin': datetime.time(9, 30), 'jour': datetime.date(2015, 11, 3), // vide => 1/1/1 'moduleimpl_id': 'GEAMIP80490', 'note_max': 20.0, 'numero': 0, 'publish_incomplete': 0, 'visibulletin': 1} ] """ req = """SELECT E.id AS evaluation_id, E.* FROM notes_evaluation E, notes_moduleimpl MI WHERE MI.formsemestre_id = %(formsemestre_id)s and MI.id = E.moduleimpl_id ORDER BY MI.id, numero desc, jour desc, heure_debut DESC """ cnx = ndb.GetDBConnexion() cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor.execute(req, {"formsemestre_id": formsemestre_id}) res = cursor.dictfetchall() # etat de chaque evaluation: for r in res: r["jour"] = r["jour"] or datetime.date(1900, 1, 1) # pour les comparaisons if with_etat: r["etat"] = do_evaluation_etat(r["evaluation_id"]) return res def _eval_etat(evals): """evals: list of mappings (etats) -> nb_eval_completes, nb_evals_en_cours, nb_evals_vides, date derniere modif Une eval est "complete" ssi tous les etudiants *inscrits* ont une note. """ nb_evals_completes, nb_evals_en_cours, nb_evals_vides = 0, 0, 0 dates = [] for e in evals: if e["etat"]["evalcomplete"]: nb_evals_completes += 1 elif e["etat"]["nb_notes"] == 0: nb_evals_vides += 1 else: nb_evals_en_cours += 1 last_modif = e["etat"]["last_modif"] if last_modif is not None: dates.append(e["etat"]["last_modif"]) if len(dates): dates = scu.sort_dates(dates) last_modif = dates[-1] # date de derniere modif d'une note dans un module else: last_modif = "" return { "nb_evals_completes": nb_evals_completes, "nb_evals_en_cours": nb_evals_en_cours, "nb_evals_vides": nb_evals_vides, "last_modif": last_modif, } def do_evaluation_etat_in_sem(formsemestre_id): """-> nb_eval_completes, nb_evals_en_cours, nb_evals_vides, date derniere modif, attente""" nt = sco_cache.NotesTableCache.get( formsemestre_id ) # > liste evaluations et moduleimpl en attente evals = nt.get_sem_evaluation_etat_list() etat = _eval_etat(evals) # Ajoute information sur notes en attente etat["attente"] = len(nt.get_moduleimpls_attente()) > 0 return etat def do_evaluation_etat_in_mod(nt, moduleimpl_id): """""" evals = nt.get_mod_evaluation_etat_list(moduleimpl_id) etat = _eval_etat(evals) etat["attente"] = moduleimpl_id in [ m["moduleimpl_id"] for m in nt.get_moduleimpls_attente() ] # > liste moduleimpl en attente return etat def formsemestre_evaluations_cal(formsemestre_id): """Page avec calendrier de toutes les evaluations de ce semestre""" sem = sco_formsemestre.get_formsemestre(formsemestre_id) nt = sco_cache.NotesTableCache.get(formsemestre_id) # > liste evaluations evals = nt.get_sem_evaluation_etat_list() nb_evals = len(evals) color_incomplete = "#FF6060" color_complete = "#A0FFA0" color_futur = "#70E0FF" today = time.strftime("%Y-%m-%d") year = int(sem["annee_debut"]) if sem["mois_debut_ord"] < 8: year -= 1 # calendrier septembre a septembre events = {} # (day, halfday) : event for e in evals: etat = e["etat"] if not e["jour"]: continue day = e["jour"].strftime("%Y-%m-%d") mod = sco_moduleimpl.moduleimpl_withmodule_list( moduleimpl_id=e["moduleimpl_id"] )[0] txt = mod["module"]["code"] or mod["module"]["abbrev"] or "eval" if e["heure_debut"]: debut = e["heure_debut"].strftime("%Hh%M") else: debut = "?" if e["heure_fin"]: fin = e["heure_fin"].strftime("%Hh%M") else: fin = "?" description = "%s, de %s à %s" % (mod["module"]["titre"], debut, fin) if etat["evalcomplete"]: color = color_complete else: color = color_incomplete if day > today: color = color_futur href = "moduleimpl_status?moduleimpl_id=%s" % e["moduleimpl_id"] # if e['heure_debut'].hour < 12: # halfday = True # else: # halfday = False if not day in events: # events[(day,halfday)] = [day, txt, color, href, halfday, description, mod] events[day] = [day, txt, color, href, description, mod] else: e = events[day] if e[-1]["moduleimpl_id"] != mod["moduleimpl_id"]: # plusieurs evals de modules differents a la meme date e[1] += ", " + txt e[4] += ", " + description if not etat["evalcomplete"]: e[2] = color_incomplete if day > today: e[2] = color_futur CalHTML = sco_abs.YearTable( year, events=list(events.values()), halfday=False, pad_width=None ) H = [ html_sco_header.html_sem_header( "Evaluations du semestre", sem, cssstyles=["css/calabs.css"], ), '
', CalHTML, "
", "

soit %s évaluations planifiées;" % nb_evals, """

""" % (color_incomplete, color_complete, color_futur), """

voir les délais de correction

""" % (formsemestre_id,), html_sco_header.sco_footer(), ] return "\n".join(H) def evaluation_date_first_completion(evaluation_id): """Première date à laquelle l'évaluation a été complète ou None si actuellement incomplète """ etat = do_evaluation_etat(evaluation_id) if not etat["evalcomplete"]: return None # XXX inachevé ou à revoir ? # Il faut considerer les inscriptions au semestre # (pour avoir l'etat et le groupe) et aussi les inscriptions # au module (pour gerer les modules optionnels correctement) # E = do_evaluation_list(args={"evaluation_id": evaluation_id})[0] # M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0] # formsemestre_id = M["formsemestre_id"] # insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( formsemestre_id) # insmod = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=E["moduleimpl_id"]) # insmodset = set([x["etudid"] for x in insmod]) # retire de insem ceux qui ne sont pas inscrits au module # ins = [i for i in insem if i["etudid"] in insmodset] notes = list( sco_evaluation_db.do_evaluation_get_all_notes( evaluation_id, filter_suppressed=False ).values() ) notes_log = list( sco_evaluation_db.do_evaluation_get_all_notes( evaluation_id, filter_suppressed=False, table="notes_notes_log" ).values() ) date_premiere_note = {} # etudid : date for note in notes + notes_log: etudid = note["etudid"] if etudid in date_premiere_note: date_premiere_note[etudid] = min(note["date"], date_premiere_note[etudid]) else: date_premiere_note[etudid] = note["date"] if not date_premiere_note: return None # complete mais aucun etudiant non démissionnaires # complet au moment du max (date la plus tardive) des premieres dates de saisie return max(date_premiere_note.values()) def formsemestre_evaluations_delai_correction(formsemestre_id, format="html"): """Experimental: un tableau indiquant pour chaque évaluation le nombre de jours avant la publication des notes. N'indique pas les évaluations de ratrapage ni celles des modules de bonus/malus. """ sem = sco_formsemestre.get_formsemestre(formsemestre_id) nt = sco_cache.NotesTableCache.get(formsemestre_id) # > liste evaluations evals = nt.get_sem_evaluation_etat_list() T = [] for e in evals: M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=e["moduleimpl_id"])[0] Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] if (e["evaluation_type"] != scu.EVALUATION_NORMALE) or ( Mod["module_type"] == ModuleType.MALUS ): continue e["date_first_complete"] = evaluation_date_first_completion(e["evaluation_id"]) if e["date_first_complete"]: e["delai_correction"] = (e["date_first_complete"].date() - e["jour"]).days else: e["delai_correction"] = None e["module_code"] = Mod["code"] e["_module_code_target"] = url_for( "notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=M["moduleimpl_id"], ) e["module_titre"] = Mod["titre"] e["responsable_id"] = M["responsable_id"] e["responsable_nomplogin"] = sco_users.user_info(M["responsable_id"])[ "nomplogin" ] e["_jour_target"] = url_for( "notes.evaluation_listenotes", scodoc_dept=g.scodoc_dept, evaluation_id=e["evaluation_id"], ) T.append(e) columns_ids = ( "module_code", "module_titre", "responsable_nomplogin", "jour", "date_first_complete", "delai_correction", "description", ) titles = { "module_code": "Code", "module_titre": "Module", "responsable_nomplogin": "Responsable", "jour": "Date", "date_first_complete": "Fin saisie", "delai_correction": "Délai", "description": "Description", } tab = GenTable( titles=titles, columns_ids=columns_ids, rows=T, html_class="table_leftalign table_coldate", html_sortable=True, html_title="

Correction des évaluations du semestre

", caption="Correction des évaluations du semestre", preferences=sco_preferences.SemPreferences(formsemestre_id), base_url="%s?formsemestre_id=%s" % (request.base_url, formsemestre_id), origin="Généré par %s le " % sco_version.SCONAME + scu.timedate_human_repr() + "", filename=scu.make_filename("evaluations_delais_" + sem["titreannee"]), ) return tab.make_page(format=format) # -------------- VIEWS def evaluation_describe(evaluation_id="", edit_in_place=True): """HTML description of evaluation, for page headers edit_in_place: allow in-place editing when permitted (not implemented) """ from app.scodoc import sco_saisie_notes E = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id})[0] moduleimpl_id = E["moduleimpl_id"] M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0] formsemestre_id = M["formsemestre_id"] u = sco_users.user_info(M["responsable_id"]) resp = u["prenomnom"] nomcomplet = u["nomcomplet"] can_edit = sco_permissions_check.can_edit_notes( current_user, moduleimpl_id, allow_ens=False ) link = ( 'voir toutes les notes du module' % moduleimpl_id ) mod_descr = ( '%s %s (resp. %s) %s' % (moduleimpl_id, Mod["code"], Mod["titre"], nomcomplet, resp, link) ) etit = E["description"] or "" if etit: etit = ' "' + etit + '"' if Mod["module_type"] == ModuleType.MALUS: etit += ' (points de malus)' H = [ 'Evaluation%s

Module : %s

' % (etit, mod_descr) ] if Mod["module_type"] == ModuleType.MALUS: # Indique l'UE ue = sco_edit_ue.ue_list(args={"ue_id": Mod["ue_id"]})[0] H.append("

UE : %(acronyme)s

" % ue) # store min/max values used by JS client-side checks: H.append( '-20.20.' ) else: # date et absences (pas pour evals de malus) if E["jour"]: jour = E["jour"] H.append("

Réalisée le %s " % (jour)) if E["heure_debut"] != E["heure_fin"]: H.append("de %s à %s " % (E["heure_debut"], E["heure_fin"])) group_id = sco_groups.get_default_group(formsemestre_id) H.append( f"""(absences ce jour)""" ) else: jour = "pas de date" H.append("

Réalisée le %s " % (jour)) H.append( '

Coefficient dans le module: %s, notes sur %g ' % (E["coefficient"], E["note_max"]) ) H.append('0.') if can_edit: H.append( f""" modifier l'évaluation saisie des notes """ ) H.append("

") return '
' + "\n".join(H) + "
"