diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index 1b0ea311c..46679b118 100644 --- a/app/scodoc/sco_excel.py +++ b/app/scodoc/sco_excel.py @@ -1,675 +1,654 @@ -# -*- 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 -# -############################################################################## - - -""" Excel file handling -""" -import time, datetime - -# #sco8 #py3 XXX TODO A revoir utiliser d'autres modules -# from pyExcelerator import * - -import app.scodoc.sco_utils as scu -from app.scodoc import notesdb -from app.scodoc.notes_log import log -from app.scodoc.scolog import logdb -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc import sco_preferences -import six - - -# colors, voir exemple format.py -COLOR_CODES = { - "black": 0, - "red": 0x0A, - "mauve": 0x19, - "marron": 0x3C, - "blue": 0x4, - "orange": 0x34, - "lightyellow": 0x2B, -} - - -def sendExcelFile(REQUEST, data, filename): - """publication fichier. - (on ne doit rien avoir émis avant, car ici sont générés les entetes) - """ - filename = ( - scu.unescape_html(scu.suppress_accents(filename)) - .replace("&", "") - .replace(" ", "_") - ) - REQUEST.RESPONSE.setHeader("content-type", scu.XLS_MIMETYPE) - REQUEST.RESPONSE.setHeader( - "content-disposition", 'attachment; filename="%s"' % filename - ) - return data - - -## (stolen from xlrd) -# Convert an Excel number (presumed to represent a date, a datetime or a time) into -# a Python datetime.datetime -# @param xldate The Excel number -# @param datemode 0: 1900-based, 1: 1904-based. -# @return a datetime.datetime object, to the nearest_second. -#
Special case: if 0.0 <= xldate < 1.0, it is assumed to represent a time; -# a datetime.time object will be returned. -#
Note: 1904-01-01 is not regarded as a valid date in the datemode 1 system; its "serial number" -# is zero. - -_XLDAYS_TOO_LARGE = (2958466, 2958466 - 1462) # This is equivalent to 10000-01-01 - - -def xldate_as_datetime(xldate, datemode=0): - if datemode not in (0, 1): - raise ValueError("invalid mode %s" % datemode) - if xldate == 0.00: - return datetime.time(0, 0, 0) - if xldate < 0.00: - raise ValueError("invalid date code %s" % xldate) - xldays = int(xldate) - frac = xldate - xldays - seconds = int(round(frac * 86400.0)) - assert 0 <= seconds <= 86400 - if seconds == 86400: - seconds = 0 - xldays += 1 - if xldays >= _XLDAYS_TOO_LARGE[datemode]: - raise ValueError("date too large %s" % xldate) - - if xldays == 0: - # second = seconds % 60; minutes = seconds // 60 - minutes, second = divmod(seconds, 60) - # minute = minutes % 60; hour = minutes // 60 - hour, minute = divmod(minutes, 60) - return datetime.time(hour, minute, second) - - if xldays < 61 and datemode == 0: - raise ValueError("ambiguous date %s" % xldate) - - return datetime.datetime.fromordinal( - xldays + 693594 + 1462 * datemode - ) + datetime.timedelta(seconds=seconds) - - -# Sous-classes pour ajouter methode savetostr() -# (generation de fichiers en memoire) -# XXX ne marche pas car accès a methodes privees (__xxx) -# -> on utilise version modifiee par nous meme de pyExcelerator -# -# class XlsDocWithSave(CompoundDoc.XlsDoc): -# def savetostr(self, stream): -# #added by Emmanuel: save method, but returns a string -# # 1. Align stream on 0x1000 boundary (and therefore on sector boundary) -# padding = '\x00' * (0x1000 - (len(stream) % 0x1000)) -# self.book_stream_len = len(stream) + len(padding) - -# self.__build_directory() -# self.__build_sat() -# self.__build_header() - -# return self.header+self.packed_MSAT_1st+stream+padding+self.packed_MSAT_2nd+self.packed_SAT+self.dir_stream - -# class WorkbookWithSave(Workbook): -# def savetostr(self): -# doc = XlsDocWithSave() -# return doc.savetostr(self.get_biff_data()) - - -def Excel_MakeStyle( - bold=False, italic=False, color="black", bgcolor=None, halign=None, valign=None -): - style = XFStyle() - font = Font() - if bold: - font.bold = bold - if italic: - font.italic = italic - font.name = "Arial" - colour_index = COLOR_CODES.get(color, None) - if colour_index: - font.colour_index = colour_index - if bgcolor: - style.pattern = Pattern() - style.pattern.pattern = Pattern.SOLID_PATTERN - style.pattern.pattern_fore_colour = COLOR_CODES.get(bgcolor, None) - al = None - if halign: - al = Alignment() - al.horz = { - "left": Alignment.HORZ_LEFT, - "right": Alignment.HORZ_RIGHT, - "center": Alignment.HORZ_CENTER, - }[halign] - if valign: - if not al: - al = Alignment() - al.vert = { - "top": Alignment.VERT_TOP, - "bottom": VERT_BOTTOM, - "center": VERT_CENTER, - }[valign] - if al: - style.alignment = al - style.font = font - return style - - -class ScoExcelSheet(object): - def __init__(self, sheet_name="feuille", default_style=None): - self.sheet_name = sheet_name - self.cells = [] # list of list - self.cells_styles_lico = {} # { (li,co) : style } - self.cells_styles_li = {} # { li : style } - self.cells_styles_co = {} # { co : style } - if not default_style: - default_style = Excel_MakeStyle() - self.default_style = default_style - - def set_style(self, style=None, li=None, co=None): - if li != None and co != None: - self.cells_styles_lico[(li, co)] = style - elif li != None: - self.cells_styles_li[li] = style - elif co != None: - self.cells_styles_co[co] = style - - def append(self, l): - """Append a line of cells""" - self.cells.append(l) - - def get_cell_style(self, li, co): - """Get style for specified cell""" - return ( - self.cells_styles_lico.get((li, co), None) - or self.cells_styles_li.get(li, None) - or self.cells_styles_co.get(co, None) - or self.default_style - ) - - def gen_workbook(self, wb=None): - """Generates and returns a workbook from stored data. - If wb, add a sheet (tab) to the existing workbook (in this case, returns None). - """ - if wb == None: - wb = Workbook() # Création du fichier - sauvegarde = True - else: - sauvegarde = False - ws0 = wb.add_sheet(self.sheet_name.decode(scu.SCO_ENCODING)) - li = 0 - for l in self.cells: - co = 0 - for c in l: - # safety net: allow only str, int and float - # #py3 #sco8 A revoir lors de la ré-écriture de ce module - # XXX if type(c) not in (IntType, FloatType): - # c = str(c).decode(scu.SCO_ENCODING) - - ws0.write(li, co, c, self.get_cell_style(li, co)) - co += 1 - li += 1 - if sauvegarde == True: - return wb.savetostr() - else: - return None - - -def Excel_SimpleTable(titles=[], lines=[[]], SheetName="feuille", titlesStyles=[]): - """Export simple type 'CSV': 1ere ligne en gras, le reste tel quel""" - # XXX devrait maintenant utiliser ScoExcelSheet - wb = Workbook() - ws0 = wb.add_sheet(SheetName.decode(scu.SCO_ENCODING)) - if not titlesStyles: - style = Excel_MakeStyle(bold=True) - titlesStyles = [style] * len(titles) - # ligne de titres - col = 0 - for it in titles: - ws0.write(0, col, it.decode(scu.SCO_ENCODING), titlesStyles[col]) - col += 1 - # suite - default_style = Excel_MakeStyle() - text_style = Excel_MakeStyle() - text_style.num_format_str = "@" - li = 1 - for l in lines: - col = 0 - for it in l: - cell_style = default_style - # safety net: allow only str, int and float - if isinstance(it, LongType): # XXX - it = int(it) # assume all ScoDoc longs fits in int ! - elif type(it) not in (IntType, FloatType): # XXX A REVOIR - it = str(it).decode(scu.SCO_ENCODING) - cell_style = text_style - ws0.write(li, col, it, cell_style) - col += 1 - li += 1 - # - return wb.savetostr() - - -def Excel_feuille_saisie(E, titreannee, description, lines): - """Genere feuille excel pour saisie des notes. - E: evaluation (dict) - lines: liste de tuples - (etudid, nom, prenom, etat, groupe, val, explanation) - """ - SheetName = "Saisie notes" - wb = Workbook() - ws0 = wb.add_sheet(SheetName.decode(scu.SCO_ENCODING)) - # ajuste largeurs colonnes (unite inconnue, empirique) - ws0.col(0).width = 400 # codes - ws0.col(1).width = 6000 # noms - ws0.col(2).width = 4000 # prenoms - ws0.col(3).width = 6000 # groupes - ws0.col(4).width = 3000 # notes - ws0.col(5).width = 13000 # remarques - # styles - style_titres = XFStyle() - font0 = Font() - font0.bold = True - font0.name = "Arial" - font0.bold = True - font0.height = 14 * 0x14 - style_titres.font = font0 - - style_expl = XFStyle() - font_expl = Font() - font_expl.name = "Arial" - font_expl.italic = True - font0.height = 12 * 0x14 - font_expl.colour_index = 0x0A # rouge, voir exemple format.py - style_expl.font = font_expl - - topborders = Borders() - topborders.top = 1 - topleftborders = Borders() - topleftborders.top = 1 - topleftborders.left = 1 - rightborder = Borders() - rightborder.right = 1 - - style_ro = XFStyle() # cells read-only - font_ro = Font() - font_ro.name = "Arial" - font_ro.colour_index = COLOR_CODES["mauve"] - style_ro.font = font_ro - style_ro.borders = rightborder - - style_dem = XFStyle() # cells read-only - font_dem = Font() - font_dem.name = "Arial" - font_dem.colour_index = COLOR_CODES["marron"] - style_dem.font = font_dem - style_dem.borders = topborders - - style = XFStyle() - font1 = Font() - font1.name = "Arial" - font1.height = 12 * 0x14 - style.font = font1 - - style_nom = XFStyle() # style pour nom, prenom, groupe - style_nom.font = font1 - style_nom.borders = topborders - - style_notes = XFStyle() - font2 = Font() - font2.name = "Arial" - font2.bold = True - style_notes.font = font2 - style_notes.num_format_str = "general" - style_notes.pattern = Pattern() # fond jaune - style_notes.pattern.pattern = Pattern.SOLID_PATTERN - style_notes.pattern.pattern_fore_colour = COLOR_CODES["lightyellow"] - style_notes.borders = topborders - - style_comment = XFStyle() - font_comment = Font() - font_comment.name = "Arial" - font_comment.height = 9 * 0x14 - font_comment.colour_index = COLOR_CODES["blue"] - style_comment.font = font_comment - style_comment.borders = topborders - - # ligne de titres - li = 0 - ws0.write( - li, 0, u"Feuille saisie note (à enregistrer au format excel)", style_titres - ) - li += 1 - ws0.write(li, 0, u"Saisir les notes dans la colonne E (cases jaunes)", style_expl) - li += 1 - ws0.write(li, 0, u"Ne pas modifier les cases en mauve !", style_expl) - li += 1 - # Nom du semestre - ws0.write( - li, 0, scu.unescape_html(titreannee).decode(scu.SCO_ENCODING), style_titres - ) - li += 1 - # description evaluation - ws0.write( - li, 0, scu.unescape_html(description).decode(scu.SCO_ENCODING), style_titres - ) - li += 1 - ws0.write( - li, 0, u"Evaluation du %s (coef. %g)" % (E["jour"], E["coefficient"]), style - ) - li += 1 - li += 1 # ligne blanche - # code et titres colonnes - ws0.write(li, 0, u"!%s" % E["evaluation_id"], style_ro) - ws0.write(li, 1, u"Nom", style_titres) - ws0.write(li, 2, u"Prénom", style_titres) - ws0.write(li, 3, u"Groupe", style_titres) - ws0.write(li, 4, u"Note sur %g" % E["note_max"], style_titres) - ws0.write(li, 5, u"Remarque", style_titres) - # etudiants - for line in lines: - li += 1 - st = style_nom - ws0.write(li, 0, ("!" + line[0]).decode(scu.SCO_ENCODING), style_ro) # code - if line[3] != "I": - st = style_dem - if line[3] == "D": # demissionnaire - s = "DEM" - else: - s = line[3] # etat autre - else: - s = line[4] # groupes TD/TP/... - ws0.write(li, 1, line[1].decode(scu.SCO_ENCODING), st) - ws0.write(li, 2, line[2].decode(scu.SCO_ENCODING), st) - ws0.write(li, 3, s.decode(scu.SCO_ENCODING), st) - try: - val = float(line[5]) - except: - val = line[5].decode(scu.SCO_ENCODING) - ws0.write(li, 4, val, style_notes) # note - ws0.write(li, 5, line[6].decode(scu.SCO_ENCODING), style_comment) # comment - # explication en bas - li += 2 - ws0.write(li, 1, u"Code notes", style_titres) - ws0.write(li + 1, 1, u"ABS", style_expl) - ws0.write(li + 1, 2, u"absent (0)", style_expl) - ws0.write(li + 2, 1, u"EXC", style_expl) - ws0.write(li + 2, 2, u"pas prise en compte", style_expl) - ws0.write(li + 3, 1, u"ATT", style_expl) - ws0.write(li + 3, 2, u"en attente", style_expl) - ws0.write(li + 4, 1, u"SUPR", style_expl) - ws0.write(li + 4, 2, u"pour supprimer note déjà entrée", style_expl) - ws0.write(li + 5, 1, u"", style_expl) - ws0.write(li + 5, 2, u"cellule vide -> note non modifiée", style_expl) - return wb.savetostr() - - -def Excel_to_list(data, convert_to_string=str): # we may need 'encoding' argument ? - """returns list of list - convert_to_string is a conversion function applied to all non-string values (ie numbers) - """ - try: - P = parse_xls("", scu.SCO_ENCODING, doc=data) - except: - log("Excel_to_list: failure to import document") - open("/tmp/last_scodoc_import_failure.xls", "w").write(data) - raise ScoValueError( - "Fichier illisible: assurez-vous qu'il s'agit bien d'un document Excel !" - ) - - diag = [] # liste de chaines pour former message d'erreur - # n'utilise que la première feuille - if len(P) < 1: - diag.append("Aucune feuille trouvée dans le classeur !") - return diag, None - if len(P) > 1: - diag.append("Attention: n'utilise que la première feuille du classeur !") - # fill matrix - sheet_name, values = P[0] - sheet_name = sheet_name.encode(scu.SCO_ENCODING, "backslashreplace") - if not values: - diag.append("Aucune valeur trouvée dans le classeur !") - return diag, None - indexes = list(values.keys()) - # search numbers of rows and cols - rows = [x[0] for x in indexes] - cols = [x[1] for x in indexes] - nbcols = max(cols) + 1 - nbrows = max(rows) + 1 - M = [] - for _ in range(nbrows): - M.append([""] * nbcols) - - for row_idx, col_idx in indexes: - v = values[(row_idx, col_idx)] - if isinstance(v, six.text_type): - v = v.encode(scu.SCO_ENCODING, "backslashreplace") - elif convert_to_string: - v = convert_to_string(v) - M[row_idx][col_idx] = v - diag.append('Feuille "%s", %d lignes' % (sheet_name, len(M))) - # diag.append(str(M)) - # - return diag, M - - -# -def Excel_feuille_listeappel( - context, - sem, - groupname, - lines, - partitions=[], # partitions a montrer (colonnes) - with_codes=False, # indique codes etuds - with_paiement=False, # indique si etudiant a paye inscription - server_name=None, -): - "generation feuille appel" - formsemestre_id = sem["formsemestre_id"] - SheetName = "Liste " + groupname - wb = Workbook() - ws0 = wb.add_sheet(SheetName.decode(scu.SCO_ENCODING)) - - font1 = Font() - font1.name = "Arial" - font1.height = 10 * 0x14 - - font1i = Font() - font1i.name = "Arial" - font1i.height = 10 * 0x14 - font1i.italic = True - - style1i = XFStyle() - style1i.font = font1i - - style1b = XFStyle() - style1b.font = font1 - borders = Borders() - borders.left = 1 - borders.top = 1 - borders.bottom = 1 - style1b.borders = borders - - style2 = XFStyle() - font2 = Font() - font2.name = "Arial" - font2.height = 14 * 0x14 - style2.font = font2 - - style2b = XFStyle() - style2b.font = font1i - borders = Borders() - borders.left = 1 - borders.top = 1 - borders.bottom = 1 - borders.right = 1 - style2b.borders = borders - - style2tb = XFStyle() - borders = Borders() - borders.top = 1 - borders.bottom = 1 - style2tb.borders = borders - style2tb.font = Font() - style2tb.font.height = 16 * 0x14 # -> ligne hautes - - style2t3 = XFStyle() - borders = Borders() - borders.top = 1 - borders.bottom = 1 - borders.left = 1 - style2t3.borders = borders - - style2t3bold = XFStyle() - borders = Borders() - borders.top = 1 - borders.bottom = 1 - borders.left = 1 - style2t3bold.borders = borders - fontb = Font() - fontb.bold = True - style2t3bold.font = fontb - - style3 = XFStyle() - font3 = Font() - font3.name = "Arial" - font3.bold = True - font3.height = 14 * 0x14 - style3.font = font3 - - NbWeeks = 4 # nombre de colonnes pour remplir absences - - # ligne 1 - li = 0 - ws0.write( - li, - 1, - ( - "%s %s (%s - %s)" - % ( - sco_preferences.get_preference("DeptName", formsemestre_id), - notesdb.unquote(sem["titre_num"]), - sem["date_debut"], - sem["date_fin"], - ) - ).decode(scu.SCO_ENCODING), - style2, - ) - # ligne 2 - li += 1 - ws0.write(li, 1, u"Discipline :", style2) - # ligne 3 - li += 1 - ws0.write(li, 1, u"Enseignant :", style2) - ws0.write(li, 5, ("Groupe %s" % groupname).decode(scu.SCO_ENCODING), style3) - # Avertissement pour ne pas confondre avec listes notes - ws0.write( - li + 1, 2, u"Ne pas utiliser cette feuille pour saisir les notes !", style1i - ) - # - li += 2 - li += 1 - ws0.write(li, 1, u"Nom", style3) - co = 2 - for partition in partitions: - if partition["partition_name"]: - ws0.write( - li, co, partition["partition_name"].decode(scu.SCO_ENCODING), style3 - ) - co += 1 - if with_codes: - coc = co - ws0.write(li, coc, u"etudid", style3) - ws0.write(li, coc + 1, u"code_nip", style3) - ws0.write(li, coc + 2, u"code_ine", style3) - co += 3 - - for i in range(NbWeeks): - ws0.write(li, co + i, "", style2b) - n = 0 - for t in lines: - n += 1 - li += 1 - ws0.write(li, 0, n, style1b) - nomprenom = ( - t["civilite_str"] - + " " - + t["nom"] - + " " - + scu.strcapitalize(scu.strlower(t["prenom"])) - ) - style_nom = style2t3 - if with_paiement: - paie = t.get("paiementinscription", None) - if paie is None: - nomprenom += " (inscription ?)" - style_nom = style2t3bold - elif not paie: - nomprenom += " (non paiement)" - style_nom = style2t3bold - ws0.write(li, 1, nomprenom.decode(scu.SCO_ENCODING), style_nom) - co = 2 - for partition in partitions: - if partition["partition_name"]: - ws0.write( - li, - co, - t.get(partition["partition_id"], "").decode(scu.SCO_ENCODING), - style2t3, - ) - co += 1 - if with_codes: - ws0.write(li, coc, t["etudid"].decode(scu.SCO_ENCODING), style2t3) - if t["code_nip"]: - code_nip = t["code_nip"].decode(scu.SCO_ENCODING) - else: - code_nip = u"" - ws0.write(li, coc + 1, code_nip, style2t3) - if t["code_ine"]: - code_ine = t["code_ine"].decode(scu.SCO_ENCODING) - else: - code_ine = u"" - ws0.write(li, coc + 2, code_ine, style2t3) - if t["etath"]: - etath = t["etath"].decode(scu.SCO_ENCODING) - else: - etath = u"" - ws0.write(li, co, etath, style2b) # etat - for i in range(1, NbWeeks): - ws0.write(li, co + i, u"", style2b) # cellules vides - ws0.row(li).height = 850 # sans effet ? - # - li += 2 - dt = time.strftime("%d/%m/%Y à %Hh%M") - if server_name: - dt += " sur " + server_name - ws0.write(li, 1, ("Liste éditée le " + dt).decode(scu.SCO_ENCODING), style1i) - # - ws0.col(0).width = 850 - ws0.col(1).width = 9000 - - return wb.savetostr() +# -*- 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 +# +############################################################################## + + +""" Excel file handling +""" +import time, datetime + +# #sco8 #py3 XXX TODO A revoir utiliser d'autres modules +# from pyExcelerator import * + +import app.scodoc.sco_utils as scu +from app.scodoc import notesdb +from app.scodoc.notes_log import log +from app.scodoc.scolog import logdb +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_preferences +import six +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Border, Side, Alignment, Protection +from openpyxl.cell import WriteOnlyCell +from tempfile import NamedTemporaryFile + +# colors, voir exemple format.py +COLOR_CODES = { + "black": 0, + "red": 0x0A, + "mauve": 0x19, + "marron": 0x3C, + "blue": 0x4, + "orange": 0x34, + "lightyellow": 0x2B, +} + + +def send_excel_file(REQUEST, data, filename): + """publication fichier. + (on ne doit rien avoir émis avant, car ici sont générés les entetes) + """ + filename = ( + scu.unescape_html(scu.suppress_accents(filename)) + .replace("&", "") + .replace(" ", "_") + ) + REQUEST.RESPONSE.setHeader("content-type", scu.XLSX_MIMETYPE) + REQUEST.RESPONSE.setHeader( + "content-disposition", 'attachment; filename="%s"' % filename + ) + return data + + +## (stolen from xlrd) +# Convert an Excel number (presumed to represent a date, a datetime or a time) into +# a Python datetime.datetime +# @param xldate The Excel number +# @param datemode 0: 1900-based, 1: 1904-based. +# @return a datetime.datetime object, to the nearest_second. +#
Special case: if 0.0 <= xldate < 1.0, it is assumed to represent a time; +# a datetime.time object will be returned. +#
Note: 1904-01-01 is not regarded as a valid date in the datemode 1 system; its "serial number" +# is zero. + +_XLDAYS_TOO_LARGE = (2958466, 2958466 - 1462) # This is equivalent to 10000-01-01 + + +def xldate_as_datetime(xldate, datemode=0): + if datemode not in (0, 1): + raise ValueError("invalid mode %s" % datemode) + if xldate == 0.00: + return datetime.time(0, 0, 0) + if xldate < 0.00: + raise ValueError("invalid date code %s" % xldate) + xldays = int(xldate) + frac = xldate - xldays + seconds = int(round(frac * 86400.0)) + assert 0 <= seconds <= 86400 + if seconds == 86400: + seconds = 0 + xldays += 1 + if xldays >= _XLDAYS_TOO_LARGE[datemode]: + raise ValueError("date too large %s" % xldate) + + if xldays == 0: + # second = seconds % 60; minutes = seconds // 60 + minutes, second = divmod(seconds, 60) + # minute = minutes % 60; hour = minutes // 60 + hour, minute = divmod(minutes, 60) + return datetime.time(hour, minute, second) + + if xldays < 61 and datemode == 0: + raise ValueError("ambiguous date %s" % xldate) + + return datetime.datetime.fromordinal( + xldays + 693594 + 1462 * datemode + ) + datetime.timedelta(seconds=seconds) + + +# Sous-classes pour ajouter methode savetostr() +# (generation de fichiers en memoire) +# XXX ne marche pas car accès a methodes privees (__xxx) +# -> on utilise version modifiee par nous meme de pyExcelerator +# +# class XlsDocWithSave(CompoundDoc.XlsDoc): +# def savetostr(self, stream): +# #added by Emmanuel: save method, but returns a string +# # 1. Align stream on 0x1000 boundary (and therefore on sector boundary) +# padding = '\x00' * (0x1000 - (len(stream) % 0x1000)) +# self.book_stream_len = len(stream) + len(padding) + +# self.__build_directory() +# self.__build_sat() +# self.__build_header() + +# return self.header+self.packed_MSAT_1st+stream+padding+self.packed_MSAT_2nd+self.packed_SAT+self.dir_stream + +# class WorkbookWithSave(Workbook): +# def savetostr(self): +# doc = XlsDocWithSave() +# return doc.savetostr(self.get_biff_data()) + + +def Excel_MakeStyle( + bold=False, italic=False, color="black", bgcolor=None, halign=None, valign=None +): + style = XFStyle() + font = Font() + if bold: + font.bold = bold + if italic: + font.italic = italic + font.name = "Arial" + colour_index = COLOR_CODES.get(color, None) + if colour_index: + font.colour_index = colour_index + if bgcolor: + style.pattern = Pattern() + style.pattern.pattern = Pattern.SOLID_PATTERN + style.pattern.pattern_fore_colour = COLOR_CODES.get(bgcolor, None) + al = None + if halign: + al = Alignment() + al.horz = { + "left": Alignment.HORZ_LEFT, + "right": Alignment.HORZ_RIGHT, + "center": Alignment.HORZ_CENTER, + }[halign] + if valign: + if not al: + al = Alignment() + al.vert = { + "top": Alignment.VERT_TOP, + "bottom": VERT_BOTTOM, + "center": VERT_CENTER, + }[valign] + if al: + style.alignment = al + style.font = font + return style + + +class ScoExcelSheet(object): + def __init__(self, sheet_name="feuille", default_style=None): + self.sheet_name = sheet_name + self.cells = [] # list of list + self.cells_styles_lico = {} # { (li,co) : style } + self.cells_styles_li = {} # { li : style } + self.cells_styles_co = {} # { co : style } + if not default_style: + default_style = Excel_MakeStyle() + self.default_style = default_style + + def set_style(self, style=None, li=None, co=None): + if li != None and co != None: + self.cells_styles_lico[(li, co)] = style + elif li != None: + self.cells_styles_li[li] = style + elif co != None: + self.cells_styles_co[co] = style + + def append(self, l): + """Append a line of cells""" + self.cells.append(l) + + def get_cell_style(self, li, co): + """Get style for specified cell""" + return ( + self.cells_styles_lico.get((li, co), None) + or self.cells_styles_li.get(li, None) + or self.cells_styles_co.get(co, None) + or self.default_style + ) + + def gen_workbook(self, wb=None): + """Generates and returns a workbook from stored data. + If wb, add a sheet (tab) to the existing workbook (in this case, returns None). + """ + if wb == None: + wb = Workbook() # Création du fichier + sauvegarde = True + else: + sauvegarde = False + ws0 = wb.add_sheet(self.sheet_name) + li = 0 + for l in self.cells: + co = 0 + for c in l: + # safety net: allow only str, int and float + # #py3 #sco8 A revoir lors de la ré-écriture de ce module + # XXX if type(c) not in (IntType, FloatType): + # c = str(c).decode(scu.SCO_ENCODING) + + ws0.write(li, co, c, self.get_cell_style(li, co)) + co += 1 + li += 1 + if sauvegarde == True: + return wb.savetostr() + else: + return None + + +def Excel_SimpleTable(titles=[], lines=[[]], SheetName="feuille", titlesStyles=[]): + """Export simple type 'CSV': 1ere ligne en gras, le reste tel quel""" + # XXX devrait maintenant utiliser ScoExcelSheet + wb = Workbook() + ws0 = wb.add_sheet(SheetName.decode(scu.SCO_ENCODING)) + if not titlesStyles: + style = Excel_MakeStyle(bold=True) + titlesStyles = [style] * len(titles) + # ligne de titres + col = 0 + for it in titles: + ws0.write(0, col, it.decode(scu.SCO_ENCODING), titlesStyles[col]) + col += 1 + # suite + default_style = Excel_MakeStyle() + text_style = Excel_MakeStyle() + text_style.num_format_str = "@" + li = 1 + for l in lines: + col = 0 + for it in l: + cell_style = default_style + # safety net: allow only str, int and float + if isinstance(it, LongType): # XXX + it = int(it) # assume all ScoDoc longs fits in int ! + elif type(it) not in (IntType, FloatType): # XXX A REVOIR + it = str(it).decode(scu.SCO_ENCODING) + cell_style = text_style + ws0.write(li, col, it, cell_style) + col += 1 + li += 1 + # + return wb.savetostr() + + +def Excel_feuille_saisie(E, titreannee, description, lines): + """Genere feuille excel pour saisie des notes. + E: evaluation (dict) + lines: liste de tuples + (etudid, nom, prenom, etat, groupe, val, explanation) + """ + SheetName = "Saisie notes" + wb = Workbook() + ws0 = wb.add_sheet(SheetName.decode(scu.SCO_ENCODING)) + # ajuste largeurs colonnes (unite inconnue, empirique) + ws0.col(0).width = 400 # codes + ws0.col(1).width = 6000 # noms + ws0.col(2).width = 4000 # prenoms + ws0.col(3).width = 6000 # groupes + ws0.col(4).width = 3000 # notes + ws0.col(5).width = 13000 # remarques + # styles + style_titres = XFStyle() + font0 = Font() + font0.bold = True + font0.name = "Arial" + font0.bold = True + font0.height = 14 * 0x14 + style_titres.font = font0 + + style_expl = XFStyle() + font_expl = Font() + font_expl.name = "Arial" + font_expl.italic = True + font0.height = 12 * 0x14 + font_expl.colour_index = 0x0A # rouge, voir exemple format.py + style_expl.font = font_expl + + topborders = Borders() + topborders.top = 1 + topleftborders = Borders() + topleftborders.top = 1 + topleftborders.left = 1 + rightborder = Borders() + rightborder.right = 1 + + style_ro = XFStyle() # cells read-only + font_ro = Font() + font_ro.name = "Arial" + font_ro.colour_index = COLOR_CODES["mauve"] + style_ro.font = font_ro + style_ro.borders = rightborder + + style_dem = XFStyle() # cells read-only + font_dem = Font() + font_dem.name = "Arial" + font_dem.colour_index = COLOR_CODES["marron"] + style_dem.font = font_dem + style_dem.borders = topborders + + style = XFStyle() + font1 = Font() + font1.name = "Arial" + font1.height = 12 * 0x14 + style.font = font1 + + style_nom = XFStyle() # style pour nom, prenom, groupe + style_nom.font = font1 + style_nom.borders = topborders + + style_notes = XFStyle() + font2 = Font() + font2.name = "Arial" + font2.bold = True + style_notes.font = font2 + style_notes.num_format_str = "general" + style_notes.pattern = Pattern() # fond jaune + style_notes.pattern.pattern = Pattern.SOLID_PATTERN + style_notes.pattern.pattern_fore_colour = COLOR_CODES["lightyellow"] + style_notes.borders = topborders + + style_comment = XFStyle() + font_comment = Font() + font_comment.name = "Arial" + font_comment.height = 9 * 0x14 + font_comment.colour_index = COLOR_CODES["blue"] + style_comment.font = font_comment + style_comment.borders = topborders + + # ligne de titres + li = 0 + ws0.write( + li, 0, u"Feuille saisie note (à enregistrer au format excel)", style_titres + ) + li += 1 + ws0.write(li, 0, u"Saisir les notes dans la colonne E (cases jaunes)", style_expl) + li += 1 + ws0.write(li, 0, u"Ne pas modifier les cases en mauve !", style_expl) + li += 1 + # Nom du semestre + ws0.write( + li, 0, scu.unescape_html(titreannee).decode(scu.SCO_ENCODING), style_titres + ) + li += 1 + # description evaluation + ws0.write( + li, 0, scu.unescape_html(description).decode(scu.SCO_ENCODING), style_titres + ) + li += 1 + ws0.write( + li, 0, u"Evaluation du %s (coef. %g)" % (E["jour"], E["coefficient"]), style + ) + li += 1 + li += 1 # ligne blanche + # code et titres colonnes + ws0.write(li, 0, u"!%s" % E["evaluation_id"], style_ro) + ws0.write(li, 1, u"Nom", style_titres) + ws0.write(li, 2, u"Prénom", style_titres) + ws0.write(li, 3, u"Groupe", style_titres) + ws0.write(li, 4, u"Note sur %g" % E["note_max"], style_titres) + ws0.write(li, 5, u"Remarque", style_titres) + # etudiants + for line in lines: + li += 1 + st = style_nom + ws0.write(li, 0, ("!" + line[0]).decode(scu.SCO_ENCODING), style_ro) # code + if line[3] != "I": + st = style_dem + if line[3] == "D": # demissionnaire + s = "DEM" + else: + s = line[3] # etat autre + else: + s = line[4] # groupes TD/TP/... + ws0.write(li, 1, line[1].decode(scu.SCO_ENCODING), st) + ws0.write(li, 2, line[2].decode(scu.SCO_ENCODING), st) + ws0.write(li, 3, s.decode(scu.SCO_ENCODING), st) + try: + val = float(line[5]) + except: + val = line[5].decode(scu.SCO_ENCODING) + ws0.write(li, 4, val, style_notes) # note + ws0.write(li, 5, line[6].decode(scu.SCO_ENCODING), style_comment) # comment + # explication en bas + li += 2 + ws0.write(li, 1, u"Code notes", style_titres) + ws0.write(li + 1, 1, u"ABS", style_expl) + ws0.write(li + 1, 2, u"absent (0)", style_expl) + ws0.write(li + 2, 1, u"EXC", style_expl) + ws0.write(li + 2, 2, u"pas prise en compte", style_expl) + ws0.write(li + 3, 1, u"ATT", style_expl) + ws0.write(li + 3, 2, u"en attente", style_expl) + ws0.write(li + 4, 1, u"SUPR", style_expl) + ws0.write(li + 4, 2, u"pour supprimer note déjà entrée", style_expl) + ws0.write(li + 5, 1, u"", style_expl) + ws0.write(li + 5, 2, u"cellule vide -> note non modifiée", style_expl) + return wb.savetostr() + + +def Excel_to_list(data, convert_to_string=str): # we may need 'encoding' argument ? + """returns list of list + convert_to_string is a conversion function applied to all non-string values (ie numbers) + """ + try: + P = parse_xls("", scu.SCO_ENCODING, doc=data) + except: + log("Excel_to_list: failure to import document") + open("/tmp/last_scodoc_import_failure.xls", "w").write(data) + raise ScoValueError( + "Fichier illisible: assurez-vous qu'il s'agit bien d'un document Excel !" + ) + + diag = [] # liste de chaines pour former message d'erreur + # n'utilise que la première feuille + if len(P) < 1: + diag.append("Aucune feuille trouvée dans le classeur !") + return diag, None + if len(P) > 1: + diag.append("Attention: n'utilise que la première feuille du classeur !") + # fill matrix + sheet_name, values = P[0] + sheet_name = sheet_name.encode(scu.SCO_ENCODING, "backslashreplace") + if not values: + diag.append("Aucune valeur trouvée dans le classeur !") + return diag, None + indexes = list(values.keys()) + # search numbers of rows and cols + rows = [x[0] for x in indexes] + cols = [x[1] for x in indexes] + nbcols = max(cols) + 1 + nbrows = max(rows) + 1 + M = [] + for _ in range(nbrows): + M.append([""] * nbcols) + + for row_idx, col_idx in indexes: + v = values[(row_idx, col_idx)] + if isinstance(v, six.text_type): + v = v.encode(scu.SCO_ENCODING, "backslashreplace") + elif convert_to_string: + v = convert_to_string(v) + M[row_idx][col_idx] = v + diag.append('Feuille "%s", %d lignes' % (sheet_name, len(M))) + # diag.append(str(M)) + # + return diag, M + +# Un style est enregistré comme un dictionnaire qui précise la valeur d'un attributdans la liste suivante: +# font, border, .. (cf https://openpyxl.readthedocs.io/en/stable/styles.html#working-with-styles) + + +def __make_cell(ws, value: any = u"", style=None): + """Contruit/retourne une cellule en spécifiant contenu et style. + + ws -- La feuille où sera intégrée la cellule + value -- le contenu de la cellule (texte) + style -- le style de la cellule + """ + cell = WriteOnlyCell(ws, value) + if "font" in style: + cell.font = style["font"] + if "border" in style: + cell.border = style["border"] + return cell + + +def excel_feuille_listeappel(sem, groupname, lines, partitions=None, with_codes=False, with_paiement=False, + server_name=None): + """generation feuille appel""" + if partitions is None: + partitions = [] + formsemestre_id = sem["formsemestre_id"] + sheet_name = "Liste " + groupname + wb = Workbook(write_only=True) + ws = wb.create_sheet(title=sheet_name) + ws.column_dimensions["A"].width = 3 + ws.column_dimensions["B"].width = 35 + ws.column_dimensions["C"].width = 12 + + font1 = Font(name="Arial", size=11) + font1i = Font(name="Arial", size=10, italic=True) + font1b = Font(name="Arial", size=11, bold=True) + + side_thin = Side(border_style="thin", color="FF000000") + + border_tbl = Border(top=side_thin, bottom=side_thin, left=side_thin) + border_tblr = Border( + top=side_thin, bottom=side_thin, left=side_thin, right=side_thin + ) + + style1i = { + "font": font1i, + } + + style1b = { + "font": font1, + "border": border_tbl, + } + + style2 = { + "font": Font(name="Arial", size=14), + } + + style2b = { + "font": font1i, + "border": border_tblr, + } + + style2t3 = { + "border": border_tblr, + } + + style2t3bold = { + "font": font1b, + "border": border_tblr, + } + + style3 = { + "font": Font(name="Arial", bold=True, size=14), + } + + nb_weeks = 4 # nombre de colonnes pour remplir absences + + # ligne 1 + title = "%s %s (%s - %s)" % ( + sco_preferences.get_preference("DeptName", formsemestre_id), + notesdb.unquote(sem["titre_num"]), + sem["date_debut"], + sem["date_fin"], + ) + + cell_2 = __make_cell(ws, title, style2) + ws.append([None, cell_2]) + + # ligne 2 + cell_2 = __make_cell(ws, u"Discipline :", style2) + ws.append([None, cell_2]) + + # ligne 3 + cell_2 = __make_cell(ws, u"Enseignant :", style2) + cell_6 = __make_cell(ws, ("Groupe %s" % groupname), style3) + ws.append([None, cell_2, None, None, None, None, cell_6]) + + # ligne 4: Avertissement pour ne pas confondre avec listes notes + cell_2 = __make_cell(ws, u"Ne pas utiliser cette feuille pour saisir les notes !", style1i) + ws.append([None, None, cell_2]) + + ws.append([None]) + ws.append([None]) + + # ligne 7: Entête (contruction dans une liste cells) + cells = [None] # passe la première colonne + cell_2 = __make_cell(ws, u"Nom", style3) + cells.append(cell_2) + for partition in partitions: + cells.append(__make_cell(ws, partition["partition_name"], style3)) + if with_codes: + cells.append(__make_cell(ws, u"etudid", style3)) + cells.append(__make_cell(ws, u"code_nip", style3)) + cells.append(__make_cell(ws, u"code_ine", style3)) + for i in range(nb_weeks): + cells.append(__make_cell(ws, "", style2b)) + ws.append(cells) + + n = 0 + # pour chaque étudiant + for t in lines: + n += 1 + nomprenom = ( + t["civilite_str"] + + " " + + t["nom"] + + " " + + scu.strcapitalize(scu.strlower(t["prenom"])) + ) + style_nom = style2t3 + if with_paiement: + paie = t.get("paiementinscription", None) + if paie is None: + nomprenom += " (inscription ?)" + style_nom = style2t3bold + elif not paie: + nomprenom += " (non paiement)" + style_nom = style2t3bold + cell_1 = __make_cell(ws, n, style1b) + cell_2 = __make_cell(ws, nomprenom, style_nom) + cells = [cell_1, cell_2] + + for partition in partitions: + if partition["partition_name"]: + cells.append( + __make_cell(ws, t.get(partition["partition_id"], u""), style2t3) + ) + if with_codes: + cells.append(__make_cell(ws, t["etudid"], style2t3)) + code_nip = t.get("code_nip", u"") + cells.append(__make_cell(ws, code_nip, style2t3)) + code_ine = t.get("code_ine", u"") + cells.append(__make_cell(ws, code_ine, style2t3)) + cells.append(__make_cell(ws, t.get("etath", ""), style2b)) + for i in range(1, nb_weeks): + cells.append(__make_cell(ws, style=style2t3)) + # ws0.row(li).height = 850 # sans effet ? + # (openpyxl: en mode optimisé, les hauteurs de lignes doivent être spécifiées avant toutes les cellules) + ws.append(cells) + + ws.append([None]) + + # bas de page (date, serveur) + dt = time.strftime("%d/%m/%Y à %Hh%M") + if server_name: + dt += " sur " + server_name + cell_2 = __make_cell(ws, ("Liste éditée le " + dt), style1i) + ws.append([None, cell_2]) + + # construction d'un flux (https://openpyxl.readthedocs.io/en/stable/tutorial.html#saving-as-a-stream) + with NamedTemporaryFile() as tmp: + wb.save(tmp.name) + tmp.seek(0) + return tmp.read() diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index 033cb0e41..708038b4d 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -1,1002 +1,996 @@ -# -*- 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 -# -############################################################################## - -"""Affichage étudiants d'un ou plusieurs groupes - sous forme: de liste html (table exportable), de trombinoscope (exportable en pdf) -""" - -# Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code) -import datetime -import cgi -import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error -import time -import collections -import operator - -from flask import url_for, g - -import app.scodoc.sco_utils as scu -from app.scodoc import html_sco_header -from app.scodoc import sco_abs -from app.scodoc import sco_excel -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_parcours_dut -from app.scodoc import sco_portal_apogee -from app.scodoc import sco_preferences -from app.scodoc import sco_etud -from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.sco_permissions import Permission -from six.moves import range - -JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ - "js/etud_info.js", - "js/groups_view.js", -] - -CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS - - -def groups_view( - context, - group_ids=[], - format="html", - REQUEST=None, - # Options pour listes: - with_codes=0, - etat=None, - with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) - with_archives=0, # ajoute colonne avec noms fichiers archivés - with_annotations=0, - formsemestre_id=None, # utilise si aucun groupe selectionné -): - """Affichage des étudiants des groupes indiqués - group_ids: liste de group_id - format: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf - """ - # Informations sur les groupes à afficher: - groups_infos = DisplayedGroupsInfos( - context, - group_ids, - formsemestre_id=formsemestre_id, - etat=etat, - REQUEST=REQUEST, - select_all_when_unspecified=True, - ) - # Formats spéciaux: download direct - if format != "html": - return groups_table( - context=context, - groups_infos=groups_infos, - format=format, - REQUEST=REQUEST, - with_codes=with_codes, - etat=etat, - with_paiement=with_paiement, - with_archives=with_archives, - with_annotations=with_annotations, - ) - - H = [ - html_sco_header.sco_header( - javascripts=JAVASCRIPTS, - cssstyles=CSSSTYLES, - init_qtip=True, - ) - ] - # Menu choix groupe - H.append("""
""") - H.append(form_groups_choice(context, groups_infos, submit_on_change=True)) - # Note: le formulaire est soumis a chaque modif des groupes - # on pourrait faire comme pour le form de saisie des notes. Il faudrait pour cela: - # - charger tous les etudiants au debut, quels que soient les groupes selectionnés - # - ajouter du JS pour modifier les liens (arguments group_ids) quand le menu change - - # Tabs - # H.extend( ("""toto""",) ) - H.extend( - ( - """ -
- -
-
- """, - groups_table( - context=context, - groups_infos=groups_infos, - format=format, - REQUEST=REQUEST, - with_codes=with_codes, - etat=etat, - with_paiement=with_paiement, - with_archives=with_archives, - with_annotations=with_annotations, - ), - "
", - """
""", - tab_photos_html(context, groups_infos, etat=etat, REQUEST=REQUEST), - #'

hello

', - "
", - '
', - tab_absences_html(context, groups_infos, etat=etat, REQUEST=REQUEST), - "
", - ) - ) - - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def form_groups_choice( - context, groups_infos, with_selectall_butt=False, submit_on_change=False -): - """form pour selection groupes - group_ids est la liste des groupes actuellement sélectionnés - et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. - (utilisé pour retrouver le semestre et proposer la liste des autres groupes) - - Si submit_on_change, ajoute une classe "submit_on_change" qui est utilisee en JS - """ - default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) - - H = [ - """
- - - Groupes: - """ - % (groups_infos.formsemestre_id, default_group_id) - ] - - H.append( - menu_groups_choice(context, groups_infos, submit_on_change=submit_on_change) - ) - - if with_selectall_butt: - H.append( - """""" - ) - H.append("
") - - return "\n".join(H) - - -def menu_groups_choice(context, groups_infos, submit_on_change=False): - """menu pour selection groupes - group_ids est la liste des groupes actuellement sélectionnés - et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. - (utilisé pour retrouver le semestre et proposer la liste des autres groupes) - """ - default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) - - if submit_on_change: - klass = "submit_on_change" - else: - klass = "" - H = [ - """ ") - return "\n".join(H) - - -def menu_group_choice(context, group_id=None, formsemestre_id=None): - """Un bête menu pour choisir un seul groupe - group_id est le groupe actuellement sélectionné. - Si aucun groupe selectionné, utilise formsemestre_id pour lister les groupes. - """ - if group_id: - group = sco_groups.get_group(context, group_id) - formsemestre_id = group["formsemestre_id"] - elif not formsemestre_id: - raise ValueError("missing formsemestre_id") - H = [ - """ - - - """ - ) - return "\n".join(H) - - -class DisplayedGroupsInfos(object): - """Container with attributes describing groups to display in the page - .groups_query_args : 'group_ids=xxx&group_ids=yyy' - .base_url : url de la requete, avec les groupes, sans les autres paramètres - .formsemestre_id : semestre "principal" (en fait celui du 1er groupe de la liste) - .members - .groups_titles - """ - - def __init__( - self, - context, - group_ids=[], # groupes specifies dans l'URL - formsemestre_id=None, - etat=None, - select_all_when_unspecified=False, - moduleimpl_id=None, # used to find formsemestre when unspecified - REQUEST=None, - ): - # log('DisplayedGroupsInfos %s' % group_ids) - if isinstance(group_ids, str): - if group_ids: - group_ids = [group_ids] # cas ou un seul parametre, pas de liste - else: - group_ids = [] - if not formsemestre_id and moduleimpl_id: - mods = sco_moduleimpl.do_moduleimpl_list( - context, moduleimpl_id=moduleimpl_id - ) - if len(mods) != 1: - raise ValueError("invalid moduleimpl_id") - formsemestre_id = mods[0]["formsemestre_id"] - - if not group_ids: # appel sans groupe (eg page accueil) - if not formsemestre_id: - raise Exception("missing parameter formsemestre_id or group_ids") - if select_all_when_unspecified: - group_ids = [sco_groups.get_default_group(formsemestre_id)] - else: - # selectionne le premier groupe trouvé, s'il y en a un - partition = sco_groups.get_partitions_list( - context, formsemestre_id, with_default=True - )[0] - groups = sco_groups.get_partition_groups(context, partition) - if groups: - group_ids = [groups[0]["group_id"]] - else: - group_ids = [sco_groups.get_default_group(formsemestre_id)] - - gq = [] - for group_id in group_ids: - gq.append("group_ids=" + group_id) - self.groups_query_args = "&".join(gq) - self.base_url = REQUEST.URL0 + "?" + self.groups_query_args - self.group_ids = group_ids - self.groups = [] - groups_titles = [] - self.members = [] - self.tous_les_etuds_du_sem = ( - False # affiche tous les etuds du semestre ? (si un seul semestre) - ) - self.sems = collections.OrderedDict() # formsemestre_id : sem - self.formsemestre = None - self.formsemestre_id = formsemestre_id - self.nbdem = 0 # nombre d'étudiants démissionnaires en tout - sem = None - selected_partitions = set() - for group_id in group_ids: - group_members, group, group_tit, sem, nbdem = sco_groups.get_group_infos( - context, group_id, etat=etat - ) - self.groups.append(group) - self.nbdem += nbdem - self.sems[sem["formsemestre_id"]] = sem - if not self.formsemestre_id: - self.formsemestre_id = sem["formsemestre_id"] - self.formsemestre = sem - self.members.extend(group_members) - groups_titles.append(group_tit) - if group["group_name"] == None: - self.tous_les_etuds_du_sem = True - else: - # liste les partitions explicitement sélectionnés (= des groupes de group_ids) - selected_partitions.add((group["numero"], group["partition_id"])) - - self.selected_partitions = [ - x[1] for x in sorted(list(selected_partitions)) - ] # -> [ partition_id ] - - if not self.formsemestre: # aucun groupe selectionne - self.formsemestre = sco_formsemestre.get_formsemestre( - context, formsemestre_id - ) - - self.sortuniq() - - if len(self.sems) > 1: - self.tous_les_etuds_du_sem = False # plusieurs semestres - if self.tous_les_etuds_du_sem: - if sem and sem["semestre_id"] >= 0: - self.groups_titles = "S%d" % sem["semestre_id"] - else: - self.groups_titles = "tous" - self.groups_filename = self.groups_titles - else: - self.groups_titles = ", ".join(groups_titles) - self.groups_filename = "_".join(groups_titles).replace(" ", "_") - # Sanitize filename: - self.groups_filename = scu.make_filename(self.groups_filename) - - # colonnes pour affichages nom des groupes: - # gère le cas où les étudiants appartiennent à des semestres différents - self.partitions = [] # les partitions, sans celle par defaut - for formsemestre_id in self.sems: - for partition in sco_groups.get_partitions_list(context, formsemestre_id): - if partition["partition_name"]: - self.partitions.append(partition) - - def sortuniq(self): - "Trie les étudiants (de plusieurs groupes) et elimine les doublons" - if (len(self.group_ids) <= 1) or len(self.members) <= 1: - return # on suppose que les etudiants d'un groupe sont deja triés - self.members.sort( - key=operator.itemgetter("nom_disp", "prenom") - ) # tri selon nom_usuel ou nom - to_remove = [] - T = self.members - for i in range(len(T) - 1, 0, -1): - if T[i - 1]["etudid"] == T[i]["etudid"]: - to_remove.append(i) - for i in to_remove: - del T[i] - - def get_form_elem(self): - """html hidden input with groups""" - H = [] - for group_id in self.group_ids: - H.append('' % group_id) - return "\n".join(H) - - -# Ancien ZScolar.group_list renommé ici en group_table -def groups_table( - context=None, - REQUEST=None, - groups_infos=None, # instance of DisplayedGroupsInfos - with_codes=0, - etat=None, - format="html", - with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) - with_archives=0, # ajoute colonne avec noms fichiers archivés - with_annotations=0, -): - """liste etudiants inscrits dans ce semestre - format: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf - Si with_codes, ajoute 4 colonnes avec les codes etudid, NIP, INE et etape - """ - from app.scodoc import sco_report - - # log( - # "enter groups_table %s: %s" - # % (groups_infos.members[0]["nom"], groups_infos.members[0].get("etape", "-")) - # ) - with_codes = int(with_codes) - with_paiement = int(with_paiement) - with_archives = int(with_archives) - with_annotations = int(with_annotations) - - base_url_np = groups_infos.base_url + "&with_codes=%s" % with_codes - base_url = ( - base_url_np - + "&with_paiement=%s&with_archives=%s&with_annotations=%s" - % (with_paiement, with_archives, with_annotations) - ) - # - columns_ids = ["civilite_str", "nom_disp", "prenom"] # colonnes a inclure - titles = { - "civilite_str": "Civ.", - "nom_disp": "Nom", - "prenom": "Prénom", - "email": "Mail", - "emailperso": "Personnel", - "etat": "Etat", - "etudid": "etudid", - "code_nip": "code_nip", - "code_ine": "code_ine", - "datefinalisationinscription_str": "Finalisation inscr.", - "paiementinscription_str": "Paiement", - "etudarchive": "Fichiers", - "annotations_str": "Annotations", - "etape": "Etape", - "semestre_groupe": "Semestre-Groupe", # pour Moodle - } - - # ajoute colonnes pour groupes - columns_ids.extend([p["partition_id"] for p in groups_infos.partitions]) - titles.update( - dict( - [(p["partition_id"], p["partition_name"]) for p in groups_infos.partitions] - ) - ) - - if format != "html": # ne mentionne l'état que en Excel (style en html) - columns_ids.append("etat") - columns_ids.append("email") - columns_ids.append("emailperso") - - if format == "moodlecsv": - columns_ids = ["email", "semestre_groupe"] - - if with_codes: - columns_ids += ["etape", "etudid", "code_nip", "code_ine"] - if with_paiement: - columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"] - if with_paiement or with_codes: - sco_portal_apogee.check_paiement_etuds(context, groups_infos.members) - if with_archives: - from app.scodoc import sco_archives_etud - - sco_archives_etud.add_archives_info_to_etud_list(context, groups_infos.members) - columns_ids += ["etudarchive"] - if with_annotations: - sco_etud.add_annotations_to_etud_list(context, groups_infos.members) - columns_ids += ["annotations_str"] - - if groups_infos.formsemestre["semestre_id"] >= 0: - moodle_sem_name = "S%d" % groups_infos.formsemestre["semestre_id"] - else: - moodle_sem_name = "A" # pas de semestre spécifié, que faire ? - moodle_groupenames = set() - # ajoute liens - for etud in groups_infos.members: - if etud["email"]: - etud["_email_target"] = "mailto:" + etud["email"] - else: - etud["_email_target"] = "" - if etud["emailperso"]: - etud["_emailperso_target"] = "mailto:" + etud["emailperso"] - else: - etud["_emailperso_target"] = "" - fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ) - etud["_nom_disp_target"] = fiche_url - etud["_prenom_target"] = fiche_url - - etud["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) - - if etud["etat"] == "D": - etud["_css_row_class"] = "etuddem" - # et groupes: - for partition_id in etud["partitions"]: - etud[partition_id] = etud["partitions"][partition_id]["group_name"] - # Ajoute colonne pour moodle: semestre_groupe, de la forme S1-NomgroupeXXX - moodle_groupename = [] - if groups_infos.selected_partitions: - # il y a des groupes selectionnes, utilise leurs partitions - for partition_id in groups_infos.selected_partitions: - if partition_id in etud["partitions"]: - moodle_groupename.append( - etud["partitions"][partition_id]["group_name"] - ) - else: - # pas de groupes sélectionnés: prend le premier s'il y en a un - if etud["partitions"]: - for p in etud["partitions"].items(): # partitions is an OrderedDict - break - moodle_groupename = [p[1]["group_name"]] - else: - moodle_groupename = ["tous"] - moodle_groupenames |= set(moodle_groupename) - etud["semestre_groupe"] = moodle_sem_name + "-" + "+".join(moodle_groupename) - - if groups_infos.nbdem > 1: - s = "s" - else: - s = "" - - if format == "moodlecsv": - # de la forme S1-[FI][FA]-groupe.csv - if not moodle_groupenames: - moodle_groupenames = {"tous"} - filename = ( - moodle_sem_name - + "-" - + groups_infos.formsemestre["modalite"] - + "-" - + "+".join(sorted(moodle_groupenames)) - ) - else: - filename = "etudiants_%s" % groups_infos.groups_filename - - prefs = sco_preferences.SemPreferences(groups_infos.formsemestre_id) - tab = GenTable( - rows=groups_infos.members, - columns_ids=columns_ids, - titles=titles, - caption="soit %d étudiants inscrits et %d démissionaire%s." - % (len(groups_infos.members) - groups_infos.nbdem, groups_infos.nbdem, s), - base_url=base_url, - filename=filename, - pdf_link=False, # pas d'export pdf - html_sortable=True, - html_class="table_leftalign table_listegroupe", - xml_outer_tag="group_list", - xml_row_tag="etud", - text_fields_separator=prefs["moodle_csv_separator"], - text_with_titles=prefs["moodle_csv_with_headerline"], - preferences=prefs, - ) - # - if format == "html": - amail_inst = [ - x["email"] for x in groups_infos.members if x["email"] and x["etat"] != "D" - ] - amail_perso = [ - x["emailperso"] - for x in groups_infos.members - if x["emailperso"] and x["etat"] != "D" - ] - - if len(groups_infos.members): - if groups_infos.tous_les_etuds_du_sem: - htitle = "Les %d étudiants inscrits" % len(groups_infos.members) - else: - htitle = "Groupe %s (%d étudiants)" % ( - groups_infos.groups_titles, - len(groups_infos.members), - ) - else: - htitle = "Aucun étudiant !" - H = [ - '
' '

', - htitle, - "", - ] - if groups_infos.members: - Of = [] - options = { - "with_paiement": "Paiement inscription", - "with_archives": "Fichiers archivés", - "with_annotations": "Annotations", - "with_codes": "Codes", - } - for option in options: - if locals().get(option, False): - selected = "selected" - else: - selected = "" - Of.append( - """""" - % (option, selected, options[option]) - ) - - H.extend( - [ - """ - - """, - ] - ) - H.append("

") - if groups_infos.members: - H.extend( - [ - tab.html(), - "") - - return "".join(H) + "
" - - elif ( - format == "pdf" - or format == "xml" - or format == "json" - or format == "xls" - or format == "moodlecsv" - ): - if format == "moodlecsv": - format = "csv" - return tab.make_page(context, format=format, REQUEST=REQUEST) - - elif format == "xlsappel": - xls = sco_excel.Excel_feuille_listeappel( - context, - groups_infos.formsemestre, # le 1er semestre, serait à modifier si plusieurs - groups_infos.groups_titles, - groups_infos.members, - partitions=groups_infos.partitions, - with_codes=with_codes, - with_paiement=with_paiement, - server_name=REQUEST.BASE0, - ) - filename = "liste_%s" % groups_infos.groups_filename + ".xls" - return sco_excel.sendExcelFile(REQUEST, xls, filename) - elif format == "allxls": - # feuille Excel avec toutes les infos etudiants - if not groups_infos.members: - return "" - keys = [ - "etudid", - "code_nip", - "etat", - "civilite_str", - "nom", - "nom_usuel", - "prenom", - "inscriptionstr", - ] - if with_paiement: - keys.append("paiementinscription") - keys += [ - "email", - "emailperso", - "domicile", - "villedomicile", - "codepostaldomicile", - "paysdomicile", - "telephone", - "telephonemobile", - "fax", - "date_naissance", - "lieu_naissance", - "bac", - "specialite", - "annee_bac", - "nomlycee", - "villelycee", - "codepostallycee", - "codelycee", - "type_admission", - "boursier_prec", - "debouche", - "parcours", - "codeparcours", - ] - titles = keys[:] - other_partitions = sco_groups.get_group_other_partitions( - context, groups_infos.groups[0] - ) - keys += [p["partition_id"] for p in other_partitions] - titles += [p["partition_name"] for p in other_partitions] - # remplis infos lycee si on a que le code lycée - # et ajoute infos inscription - for m in groups_infos.members: - etud = sco_etud.get_etud_info(m["etudid"], filled=True)[0] - m.update(etud) - sco_etud.etud_add_lycee_infos(etud) - # et ajoute le parcours - Se = sco_parcours_dut.SituationEtudParcours( - context, etud, groups_infos.formsemestre_id - ) - m["parcours"] = Se.get_parcours_descr() - m["codeparcours"], _ = sco_report.get_codeparcoursetud(context, etud) - - def dicttakestr(d, keys): - r = [] - for k in keys: - r.append(str(d.get(k, ""))) - return r - - L = [dicttakestr(m, keys) for m in groups_infos.members] - title = "etudiants_%s" % groups_infos.groups_filename - xls = sco_excel.Excel_SimpleTable(titles=titles, lines=L, SheetName=title) - filename = title + ".xls" - return sco_excel.sendExcelFile(REQUEST, xls, filename) - else: - raise ValueError("unsupported format") - - -def tab_absences_html(context, groups_infos, etat=None, REQUEST=None): - """contenu du tab "absences et feuilles diverses" """ - authuser = REQUEST.AUTHENTICATED_USER - H = ['
'] - if not groups_infos.members: - return "".join(H) + "

Aucun étudiant !

" - H.extend( - [ - "

Absences

", - '", - "

Feuilles

", - '", - ] - ) - - H.append('

Opérations diverses

") - return "".join(H) - - -def tab_photos_html(context, groups_infos, etat=None, REQUEST=None): - """contenu du tab "photos" """ - from app.scodoc import sco_trombino - - if not groups_infos.members: - return '

Aucun étudiant !

' - - return sco_trombino.trombino_html(context, groups_infos, REQUEST=REQUEST) - - -def form_choix_jour_saisie_hebdo( - context, groups_infos, moduleimpl_id=None, REQUEST=None -): - """Formulaire choix jour semaine pour saisie.""" - authuser = REQUEST.AUTHENTICATED_USER - if not authuser.has_permission(Permission.ScoAbsChange): - return "" - sem = groups_infos.formsemestre - first_monday = sco_abs.ddmmyyyy(sem["date_debut"]).prev_monday() - today_idx = datetime.date.today().weekday() - - FA = [] # formulaire avec menu saisi absences - FA.append( - '
' - ) - FA.append('' % sem) - FA.append(groups_infos.get_form_elem()) - if moduleimpl_id: - FA.append( - '' % moduleimpl_id - ) - FA.append('') - - FA.append( - """""" - ) - FA.append("""") - FA.append("
") - return "\n".join(FA) - - -# Ajout Le Havre -# Formulaire saisie absences semaine -def form_choix_saisie_semaine(context, groups_infos, REQUEST=None): - authuser = REQUEST.AUTHENTICATED_USER - if not authuser.has_permission(Permission.ScoAbsChange): - return "" - # construit l'URL "destination" - # (a laquelle on revient apres saisie absences) - query_args = cgi.parse_qs(REQUEST.QUERY_STRING) - moduleimpl_id = query_args.get("moduleimpl_id", [""])[0] - if "head_message" in query_args: - del query_args["head_message"] - destination = "%s?%s" % ( - REQUEST.URL, - six.moves.urllib.parse.urlencode(query_args, True), - ) - destination = destination.replace( - "%", "%%" - ) # car ici utilisee dans un format string ! - - DateJour = time.strftime("%d/%m/%Y") - datelundi = sco_abs.ddmmyyyy(DateJour).prev_monday() - FA = [] # formulaire avec menu saisie hebdo des absences - FA.append('
') - FA.append('' % datelundi) - FA.append('' % moduleimpl_id) - FA.append('' % destination) - FA.append(groups_infos.get_form_elem()) - FA.append('') - FA.append("
") - return "\n".join(FA) - - -def export_groups_as_moodle_csv(context, formsemestre_id=None, REQUEST=None): - """Export all students/groups, in a CSV format suitable for Moodle - Each (student,group) will be listed on a separate line - jo@univ.fr,S3-A - jo@univ.fr,S3-B1 - if jo belongs to group A in a partition, and B1 in another one. - Caution: if groups in different partitions share the same name, there will be - duplicates... should we prefix the group names with the partition's name ? - """ - if not formsemestre_id: - raise ScoValueError("missing parameter: formsemestre_id") - _, partitions_etud_groups = sco_groups.get_formsemestre_groups( - context, formsemestre_id, with_default=True - ) - sem = sco_formsemestre.get_formsemestre(context, formsemestre_id) - moodle_sem_name = sem["session_id"] - - columns_ids = ("email", "semestre_groupe") - T = [] - for partition_id in partitions_etud_groups: - partition = sco_groups.get_partition(context, partition_id) - members = partitions_etud_groups[partition_id] - for etudid in members: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - group_name = members[etudid]["group_name"] - elts = [moodle_sem_name] - if partition["partition_name"]: - elts.append(partition["partition_name"]) - if group_name: - elts.append(group_name) - T.append({"email": etud["email"], "semestre_groupe": "-".join(elts)}) - # Make table - prefs = sco_preferences.SemPreferences(formsemestre_id) - tab = GenTable( - rows=T, - columns_ids=("email", "semestre_groupe"), - filename=moodle_sem_name + "-moodle", - titles={x: x for x in columns_ids}, - text_fields_separator=prefs["moodle_csv_separator"], - text_with_titles=prefs["moodle_csv_with_headerline"], - preferences=prefs, - ) - return tab.make_page(context, format="csv", REQUEST=REQUEST) +# -*- 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 +# +############################################################################## + +"""Affichage étudiants d'un ou plusieurs groupes + sous forme: de liste html (table exportable), de trombinoscope (exportable en pdf) +""" + +# Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code) +import datetime +import cgi +import six.moves.urllib.request, six.moves.urllib.parse, six.moves.urllib.error +import time +import collections +import operator + +from flask import url_for, g + +import app.scodoc.sco_utils as scu +from app.scodoc import html_sco_header +from app.scodoc import sco_abs +from app.scodoc import sco_excel +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_parcours_dut +from app.scodoc import sco_portal_apogee +from app.scodoc import sco_preferences +from app.scodoc import sco_etud +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_permissions import Permission +from six.moves import range + +JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [ + "js/etud_info.js", + "js/groups_view.js", +] + +CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS + + +def groups_view( + context, + group_ids=[], + format="html", + REQUEST=None, + # Options pour listes: + with_codes=0, + etat=None, + with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) + with_archives=0, # ajoute colonne avec noms fichiers archivés + with_annotations=0, + formsemestre_id=None, # utilise si aucun groupe selectionné +): + """Affichage des étudiants des groupes indiqués + group_ids: liste de group_id + format: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf + """ + # Informations sur les groupes à afficher: + groups_infos = DisplayedGroupsInfos( + context, + group_ids, + formsemestre_id=formsemestre_id, + etat=etat, + REQUEST=REQUEST, + select_all_when_unspecified=True, + ) + # Formats spéciaux: download direct + if format != "html": + return groups_table( + context=context, + groups_infos=groups_infos, + format=format, + REQUEST=REQUEST, + with_codes=with_codes, + etat=etat, + with_paiement=with_paiement, + with_archives=with_archives, + with_annotations=with_annotations, + ) + + H = [ + html_sco_header.sco_header( + javascripts=JAVASCRIPTS, + cssstyles=CSSSTYLES, + init_qtip=True, + ) + ] + # Menu choix groupe + H.append("""
""") + H.append(form_groups_choice(context, groups_infos, submit_on_change=True)) + # Note: le formulaire est soumis a chaque modif des groupes + # on pourrait faire comme pour le form de saisie des notes. Il faudrait pour cela: + # - charger tous les etudiants au debut, quels que soient les groupes selectionnés + # - ajouter du JS pour modifier les liens (arguments group_ids) quand le menu change + + # Tabs + # H.extend( ("""toto""",) ) + H.extend( + ( + """ +
+ +
+
+ """, + groups_table( + context=context, + groups_infos=groups_infos, + format=format, + REQUEST=REQUEST, + with_codes=with_codes, + etat=etat, + with_paiement=with_paiement, + with_archives=with_archives, + with_annotations=with_annotations, + ), + "
", + """
""", + tab_photos_html(context, groups_infos, etat=etat, REQUEST=REQUEST), + #'

hello

', + "
", + '
', + tab_absences_html(context, groups_infos, etat=etat, REQUEST=REQUEST), + "
", + ) + ) + + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def form_groups_choice( + context, groups_infos, with_selectall_butt=False, submit_on_change=False +): + """form pour selection groupes + group_ids est la liste des groupes actuellement sélectionnés + et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. + (utilisé pour retrouver le semestre et proposer la liste des autres groupes) + + Si submit_on_change, ajoute une classe "submit_on_change" qui est utilisee en JS + """ + default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) + + H = [ + """
+ + + Groupes: + """ + % (groups_infos.formsemestre_id, default_group_id) + ] + + H.append( + menu_groups_choice(context, groups_infos, submit_on_change=submit_on_change) + ) + + if with_selectall_butt: + H.append( + """""" + ) + H.append("
") + + return "\n".join(H) + + +def menu_groups_choice(context, groups_infos, submit_on_change=False): + """menu pour selection groupes + group_ids est la liste des groupes actuellement sélectionnés + et doit comporter au moins un élément, sauf si formsemestre_id est spécifié. + (utilisé pour retrouver le semestre et proposer la liste des autres groupes) + """ + default_group_id = sco_groups.get_default_group(groups_infos.formsemestre_id) + + if submit_on_change: + klass = "submit_on_change" + else: + klass = "" + H = [ + """ ") + return "\n".join(H) + + +def menu_group_choice(context, group_id=None, formsemestre_id=None): + """Un bête menu pour choisir un seul groupe + group_id est le groupe actuellement sélectionné. + Si aucun groupe selectionné, utilise formsemestre_id pour lister les groupes. + """ + if group_id: + group = sco_groups.get_group(context, group_id) + formsemestre_id = group["formsemestre_id"] + elif not formsemestre_id: + raise ValueError("missing formsemestre_id") + H = [ + """ + + + """ + ) + return "\n".join(H) + + +class DisplayedGroupsInfos(object): + """Container with attributes describing groups to display in the page + .groups_query_args : 'group_ids=xxx&group_ids=yyy' + .base_url : url de la requete, avec les groupes, sans les autres paramètres + .formsemestre_id : semestre "principal" (en fait celui du 1er groupe de la liste) + .members + .groups_titles + """ + + def __init__( + self, + context, + group_ids=[], # groupes specifies dans l'URL + formsemestre_id=None, + etat=None, + select_all_when_unspecified=False, + moduleimpl_id=None, # used to find formsemestre when unspecified + REQUEST=None, + ): + # log('DisplayedGroupsInfos %s' % group_ids) + if isinstance(group_ids, str): + if group_ids: + group_ids = [group_ids] # cas ou un seul parametre, pas de liste + else: + group_ids = [] + if not formsemestre_id and moduleimpl_id: + mods = sco_moduleimpl.do_moduleimpl_list( + context, moduleimpl_id=moduleimpl_id + ) + if len(mods) != 1: + raise ValueError("invalid moduleimpl_id") + formsemestre_id = mods[0]["formsemestre_id"] + + if not group_ids: # appel sans groupe (eg page accueil) + if not formsemestre_id: + raise Exception("missing parameter formsemestre_id or group_ids") + if select_all_when_unspecified: + group_ids = [sco_groups.get_default_group(formsemestre_id)] + else: + # selectionne le premier groupe trouvé, s'il y en a un + partition = sco_groups.get_partitions_list( + context, formsemestre_id, with_default=True + )[0] + groups = sco_groups.get_partition_groups(context, partition) + if groups: + group_ids = [groups[0]["group_id"]] + else: + group_ids = [sco_groups.get_default_group(formsemestre_id)] + + gq = [] + for group_id in group_ids: + gq.append("group_ids=" + group_id) + self.groups_query_args = "&".join(gq) + self.base_url = REQUEST.URL0 + "?" + self.groups_query_args + self.group_ids = group_ids + self.groups = [] + groups_titles = [] + self.members = [] + self.tous_les_etuds_du_sem = ( + False # affiche tous les etuds du semestre ? (si un seul semestre) + ) + self.sems = collections.OrderedDict() # formsemestre_id : sem + self.formsemestre = None + self.formsemestre_id = formsemestre_id + self.nbdem = 0 # nombre d'étudiants démissionnaires en tout + sem = None + selected_partitions = set() + for group_id in group_ids: + group_members, group, group_tit, sem, nbdem = sco_groups.get_group_infos( + context, group_id, etat=etat + ) + self.groups.append(group) + self.nbdem += nbdem + self.sems[sem["formsemestre_id"]] = sem + if not self.formsemestre_id: + self.formsemestre_id = sem["formsemestre_id"] + self.formsemestre = sem + self.members.extend(group_members) + groups_titles.append(group_tit) + if group["group_name"] == None: + self.tous_les_etuds_du_sem = True + else: + # liste les partitions explicitement sélectionnés (= des groupes de group_ids) + selected_partitions.add((group["numero"], group["partition_id"])) + + self.selected_partitions = [ + x[1] for x in sorted(list(selected_partitions)) + ] # -> [ partition_id ] + + if not self.formsemestre: # aucun groupe selectionne + self.formsemestre = sco_formsemestre.get_formsemestre( + context, formsemestre_id + ) + + self.sortuniq() + + if len(self.sems) > 1: + self.tous_les_etuds_du_sem = False # plusieurs semestres + if self.tous_les_etuds_du_sem: + if sem and sem["semestre_id"] >= 0: + self.groups_titles = "S%d" % sem["semestre_id"] + else: + self.groups_titles = "tous" + self.groups_filename = self.groups_titles + else: + self.groups_titles = ", ".join(groups_titles) + self.groups_filename = "_".join(groups_titles).replace(" ", "_") + # Sanitize filename: + self.groups_filename = scu.make_filename(self.groups_filename) + + # colonnes pour affichages nom des groupes: + # gère le cas où les étudiants appartiennent à des semestres différents + self.partitions = [] # les partitions, sans celle par defaut + for formsemestre_id in self.sems: + for partition in sco_groups.get_partitions_list(context, formsemestre_id): + if partition["partition_name"]: + self.partitions.append(partition) + + def sortuniq(self): + "Trie les étudiants (de plusieurs groupes) et elimine les doublons" + if (len(self.group_ids) <= 1) or len(self.members) <= 1: + return # on suppose que les etudiants d'un groupe sont deja triés + self.members.sort( + key=operator.itemgetter("nom_disp", "prenom") + ) # tri selon nom_usuel ou nom + to_remove = [] + T = self.members + for i in range(len(T) - 1, 0, -1): + if T[i - 1]["etudid"] == T[i]["etudid"]: + to_remove.append(i) + for i in to_remove: + del T[i] + + def get_form_elem(self): + """html hidden input with groups""" + H = [] + for group_id in self.group_ids: + H.append('' % group_id) + return "\n".join(H) + + +# Ancien ZScolar.group_list renommé ici en group_table +def groups_table( + context=None, + REQUEST=None, + groups_infos=None, # instance of DisplayedGroupsInfos + with_codes=0, + etat=None, + format="html", + with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) + with_archives=0, # ajoute colonne avec noms fichiers archivés + with_annotations=0, +): + """liste etudiants inscrits dans ce semestre + format: csv, json, xml, xls, allxls, xlsappel, moodlecsv, pdf + Si with_codes, ajoute 4 colonnes avec les codes etudid, NIP, INE et etape + """ + from app.scodoc import sco_report + + # log( + # "enter groups_table %s: %s" + # % (groups_infos.members[0]["nom"], groups_infos.members[0].get("etape", "-")) + # ) + with_codes = int(with_codes) + with_paiement = int(with_paiement) + with_archives = int(with_archives) + with_annotations = int(with_annotations) + + base_url_np = groups_infos.base_url + "&with_codes=%s" % with_codes + base_url = ( + base_url_np + + "&with_paiement=%s&with_archives=%s&with_annotations=%s" + % (with_paiement, with_archives, with_annotations) + ) + # + columns_ids = ["civilite_str", "nom_disp", "prenom"] # colonnes a inclure + titles = { + "civilite_str": "Civ.", + "nom_disp": "Nom", + "prenom": "Prénom", + "email": "Mail", + "emailperso": "Personnel", + "etat": "Etat", + "etudid": "etudid", + "code_nip": "code_nip", + "code_ine": "code_ine", + "datefinalisationinscription_str": "Finalisation inscr.", + "paiementinscription_str": "Paiement", + "etudarchive": "Fichiers", + "annotations_str": "Annotations", + "etape": "Etape", + "semestre_groupe": "Semestre-Groupe", # pour Moodle + } + + # ajoute colonnes pour groupes + columns_ids.extend([p["partition_id"] for p in groups_infos.partitions]) + titles.update( + dict( + [(p["partition_id"], p["partition_name"]) for p in groups_infos.partitions] + ) + ) + + if format != "html": # ne mentionne l'état que en Excel (style en html) + columns_ids.append("etat") + columns_ids.append("email") + columns_ids.append("emailperso") + + if format == "moodlecsv": + columns_ids = ["email", "semestre_groupe"] + + if with_codes: + columns_ids += ["etape", "etudid", "code_nip", "code_ine"] + if with_paiement: + columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"] + if with_paiement or with_codes: + sco_portal_apogee.check_paiement_etuds(context, groups_infos.members) + if with_archives: + from app.scodoc import sco_archives_etud + + sco_archives_etud.add_archives_info_to_etud_list(context, groups_infos.members) + columns_ids += ["etudarchive"] + if with_annotations: + sco_etud.add_annotations_to_etud_list(context, groups_infos.members) + columns_ids += ["annotations_str"] + + if groups_infos.formsemestre["semestre_id"] >= 0: + moodle_sem_name = "S%d" % groups_infos.formsemestre["semestre_id"] + else: + moodle_sem_name = "A" # pas de semestre spécifié, que faire ? + moodle_groupenames = set() + # ajoute liens + for etud in groups_infos.members: + if etud["email"]: + etud["_email_target"] = "mailto:" + etud["email"] + else: + etud["_email_target"] = "" + if etud["emailperso"]: + etud["_emailperso_target"] = "mailto:" + etud["emailperso"] + else: + etud["_emailperso_target"] = "" + fiche_url = url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + ) + etud["_nom_disp_target"] = fiche_url + etud["_prenom_target"] = fiche_url + + etud["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + + if etud["etat"] == "D": + etud["_css_row_class"] = "etuddem" + # et groupes: + for partition_id in etud["partitions"]: + etud[partition_id] = etud["partitions"][partition_id]["group_name"] + # Ajoute colonne pour moodle: semestre_groupe, de la forme S1-NomgroupeXXX + moodle_groupename = [] + if groups_infos.selected_partitions: + # il y a des groupes selectionnes, utilise leurs partitions + for partition_id in groups_infos.selected_partitions: + if partition_id in etud["partitions"]: + moodle_groupename.append( + etud["partitions"][partition_id]["group_name"] + ) + else: + # pas de groupes sélectionnés: prend le premier s'il y en a un + if etud["partitions"]: + for p in etud["partitions"].items(): # partitions is an OrderedDict + break + moodle_groupename = [p[1]["group_name"]] + else: + moodle_groupename = ["tous"] + moodle_groupenames |= set(moodle_groupename) + etud["semestre_groupe"] = moodle_sem_name + "-" + "+".join(moodle_groupename) + + if groups_infos.nbdem > 1: + s = "s" + else: + s = "" + + if format == "moodlecsv": + # de la forme S1-[FI][FA]-groupe.csv + if not moodle_groupenames: + moodle_groupenames = {"tous"} + filename = ( + moodle_sem_name + + "-" + + groups_infos.formsemestre["modalite"] + + "-" + + "+".join(sorted(moodle_groupenames)) + ) + else: + filename = "etudiants_%s" % groups_infos.groups_filename + + prefs = sco_preferences.SemPreferences(groups_infos.formsemestre_id) + tab = GenTable( + rows=groups_infos.members, + columns_ids=columns_ids, + titles=titles, + caption="soit %d étudiants inscrits et %d démissionaire%s." + % (len(groups_infos.members) - groups_infos.nbdem, groups_infos.nbdem, s), + base_url=base_url, + filename=filename, + pdf_link=False, # pas d'export pdf + html_sortable=True, + html_class="table_leftalign table_listegroupe", + xml_outer_tag="group_list", + xml_row_tag="etud", + text_fields_separator=prefs["moodle_csv_separator"], + text_with_titles=prefs["moodle_csv_with_headerline"], + preferences=prefs, + ) + # + if format == "html": + amail_inst = [ + x["email"] for x in groups_infos.members if x["email"] and x["etat"] != "D" + ] + amail_perso = [ + x["emailperso"] + for x in groups_infos.members + if x["emailperso"] and x["etat"] != "D" + ] + + if len(groups_infos.members): + if groups_infos.tous_les_etuds_du_sem: + htitle = "Les %d étudiants inscrits" % len(groups_infos.members) + else: + htitle = "Groupe %s (%d étudiants)" % ( + groups_infos.groups_titles, + len(groups_infos.members), + ) + else: + htitle = "Aucun étudiant !" + H = [ + '
' '

', + htitle, + "", + ] + if groups_infos.members: + Of = [] + options = { + "with_paiement": "Paiement inscription", + "with_archives": "Fichiers archivés", + "with_annotations": "Annotations", + "with_codes": "Codes", + } + for option in options: + if locals().get(option, False): + selected = "selected" + else: + selected = "" + Of.append( + """""" + % (option, selected, options[option]) + ) + + H.extend( + [ + """ + + """, + ] + ) + H.append("

") + if groups_infos.members: + H.extend( + [ + tab.html(), + "") + + return "".join(H) + "
" + + elif ( + format == "pdf" + or format == "xml" + or format == "json" + or format == "xls" + or format == "moodlecsv" + ): + if format == "moodlecsv": + format = "csv" + return tab.make_page(context, format=format, REQUEST=REQUEST) + + elif format == "xlsappel": + xls = sco_excel.excel_feuille_listeappel(groups_infos.formsemestre, groups_infos.groups_titles, + groups_infos.members, partitions=groups_infos.partitions, + with_codes=with_codes, with_paiement=with_paiement, + server_name=REQUEST.BASE0) + filename = "liste_%s" % groups_infos.groups_filename + ".xlsx" + return sco_excel.send_excel_file(REQUEST, xls, filename) + elif format == "allxls": + # feuille Excel avec toutes les infos etudiants + if not groups_infos.members: + return "" + keys = [ + "etudid", + "code_nip", + "etat", + "civilite_str", + "nom", + "nom_usuel", + "prenom", + "inscriptionstr", + ] + if with_paiement: + keys.append("paiementinscription") + keys += [ + "email", + "emailperso", + "domicile", + "villedomicile", + "codepostaldomicile", + "paysdomicile", + "telephone", + "telephonemobile", + "fax", + "date_naissance", + "lieu_naissance", + "bac", + "specialite", + "annee_bac", + "nomlycee", + "villelycee", + "codepostallycee", + "codelycee", + "type_admission", + "boursier_prec", + "debouche", + "parcours", + "codeparcours", + ] + titles = keys[:] + other_partitions = sco_groups.get_group_other_partitions( + context, groups_infos.groups[0] + ) + keys += [p["partition_id"] for p in other_partitions] + titles += [p["partition_name"] for p in other_partitions] + # remplis infos lycee si on a que le code lycée + # et ajoute infos inscription + for m in groups_infos.members: + etud = sco_etud.get_etud_info(m["etudid"], filled=True)[0] + m.update(etud) + sco_etud.etud_add_lycee_infos(etud) + # et ajoute le parcours + Se = sco_parcours_dut.SituationEtudParcours( + context, etud, groups_infos.formsemestre_id + ) + m["parcours"] = Se.get_parcours_descr() + m["codeparcours"], _ = sco_report.get_codeparcoursetud(context, etud) + + def dicttakestr(d, keys): + r = [] + for k in keys: + r.append(str(d.get(k, ""))) + return r + + L = [dicttakestr(m, keys) for m in groups_infos.members] + title = "etudiants_%s" % groups_infos.groups_filename + xls = sco_excel.Excel_SimpleTable(titles=titles, lines=L, SheetName=title) + filename = title + ".xls" + return sco_excel.send_excel_file(REQUEST, xls, filename) + else: + raise ValueError("unsupported format") + + +def tab_absences_html(context, groups_infos, etat=None, REQUEST=None): + """contenu du tab "absences et feuilles diverses" """ + authuser = REQUEST.AUTHENTICATED_USER + H = ['
'] + if not groups_infos.members: + return "".join(H) + "

Aucun étudiant !

" + H.extend( + [ + "

Absences

", + '", + "

Feuilles

", + '", + ] + ) + + H.append('

Opérations diverses

") + return "".join(H) + + +def tab_photos_html(context, groups_infos, etat=None, REQUEST=None): + """contenu du tab "photos" """ + from app.scodoc import sco_trombino + + if not groups_infos.members: + return '

Aucun étudiant !

' + + return sco_trombino.trombino_html(context, groups_infos, REQUEST=REQUEST) + + +def form_choix_jour_saisie_hebdo( + context, groups_infos, moduleimpl_id=None, REQUEST=None +): + """Formulaire choix jour semaine pour saisie.""" + authuser = REQUEST.AUTHENTICATED_USER + if not authuser.has_permission(Permission.ScoAbsChange): + return "" + sem = groups_infos.formsemestre + first_monday = sco_abs.ddmmyyyy(sem["date_debut"]).prev_monday() + today_idx = datetime.date.today().weekday() + + FA = [] # formulaire avec menu saisi absences + FA.append( + '
' + ) + FA.append('' % sem) + FA.append(groups_infos.get_form_elem()) + if moduleimpl_id: + FA.append( + '' % moduleimpl_id + ) + FA.append('') + + FA.append( + """""" + ) + FA.append("""") + FA.append("
") + return "\n".join(FA) + + +# Ajout Le Havre +# Formulaire saisie absences semaine +def form_choix_saisie_semaine(context, groups_infos, REQUEST=None): + authuser = REQUEST.AUTHENTICATED_USER + if not authuser.has_permission(Permission.ScoAbsChange): + return "" + # construit l'URL "destination" + # (a laquelle on revient apres saisie absences) + query_args = cgi.parse_qs(REQUEST.QUERY_STRING) + moduleimpl_id = query_args.get("moduleimpl_id", [""])[0] + if "head_message" in query_args: + del query_args["head_message"] + destination = "%s?%s" % ( + REQUEST.URL, + six.moves.urllib.parse.urlencode(query_args, True), + ) + destination = destination.replace( + "%", "%%" + ) # car ici utilisee dans un format string ! + + DateJour = time.strftime("%d/%m/%Y") + datelundi = sco_abs.ddmmyyyy(DateJour).prev_monday() + FA = [] # formulaire avec menu saisie hebdo des absences + FA.append('
') + FA.append('' % datelundi) + FA.append('' % moduleimpl_id) + FA.append('' % destination) + FA.append(groups_infos.get_form_elem()) + FA.append('') + FA.append("
") + return "\n".join(FA) + + +def export_groups_as_moodle_csv(context, formsemestre_id=None, REQUEST=None): + """Export all students/groups, in a CSV format suitable for Moodle + Each (student,group) will be listed on a separate line + jo@univ.fr,S3-A + jo@univ.fr,S3-B1 + if jo belongs to group A in a partition, and B1 in another one. + Caution: if groups in different partitions share the same name, there will be + duplicates... should we prefix the group names with the partition's name ? + """ + if not formsemestre_id: + raise ScoValueError("missing parameter: formsemestre_id") + _, partitions_etud_groups = sco_groups.get_formsemestre_groups( + context, formsemestre_id, with_default=True + ) + sem = sco_formsemestre.get_formsemestre(context, formsemestre_id) + moodle_sem_name = sem["session_id"] + + columns_ids = ("email", "semestre_groupe") + T = [] + for partition_id in partitions_etud_groups: + partition = sco_groups.get_partition(context, partition_id) + members = partitions_etud_groups[partition_id] + for etudid in members: + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + group_name = members[etudid]["group_name"] + elts = [moodle_sem_name] + if partition["partition_name"]: + elts.append(partition["partition_name"]) + if group_name: + elts.append(group_name) + T.append({"email": etud["email"], "semestre_groupe": "-".join(elts)}) + # Make table + prefs = sco_preferences.SemPreferences(formsemestre_id) + tab = GenTable( + rows=T, + columns_ids=("email", "semestre_groupe"), + filename=moodle_sem_name + "-moodle", + titles={x: x for x in columns_ids}, + text_fields_separator=prefs["moodle_csv_separator"], + text_with_titles=prefs["moodle_csv_with_headerline"], + preferences=prefs, + ) + return tab.make_page(context, format="csv", REQUEST=REQUEST) diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index c9983d636..2f6c828ad 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -301,6 +301,7 @@ CSV_FIELDSEP = ";" CSV_LINESEP = "\n" CSV_MIMETYPE = "text/comma-separated-values" XLS_MIMETYPE = "application/vnd.ms-excel" +XLSX_MIMETYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" PDF_MIMETYPE = "application/pdf" XML_MIMETYPE = "text/xml" JSON_MIMETYPE = "application/json" diff --git a/requirements-3.7.txt b/requirements-3.7.txt index 275cd7afc..bd98cf721 100755 --- a/requirements-3.7.txt +++ b/requirements-3.7.txt @@ -1,63 +1,64 @@ -alembic==1.6.5 -attrs==21.2.0 -Babel==2.9.1 -blinker==1.4 -certifi==2021.5.30 -cffi==1.14.6 -chardet==4.0.0 -charset-normalizer==2.0.3 -click==8.0.1 -cracklib==2.9.3 -cryptography==3.4.7 -dnspython==2.1.0 -dominate==2.6.0 -email-validator==1.1.3 -Flask==2.0.1 -Flask-Babel==2.0.0 -Flask-Bootstrap==3.3.7.1 -Flask-Caching==1.10.1 -Flask-Login==0.5.0 -Flask-Mail==0.9.1 -Flask-Migrate==3.0.1 -Flask-Moment==1.0.2 -Flask-SQLAlchemy==2.5.1 -Flask-WTF==0.15.1 -greenlet==1.1.0 -icalendar==4.0.7 -idna==3.2 -importlib-metadata==4.6.1 -iniconfig==1.1.1 -itsdangerous==2.0.1 -Jinja2==3.0.1 -jwt==1.2.0 -Mako==1.1.4 -MarkupSafe==2.0.1 -packaging==21.0 -Pillow==8.3.1 -pkg-resources==0.0.0 -pluggy==0.13.1 -psycopg2==2.9.1 -py==1.10.0 -pycparser==2.20 -pydot==1.4.2 -pyOpenSSL==20.0.1 -pyparsing==2.4.7 -PyRSS2Gen==1.1 -pytest==6.2.4 -python-dateutil==2.8.2 -python-dotenv==0.18.0 -python-editor==1.0.4 -pytz==2021.1 -redis==3.5.3 -reportlab==3.5.68 -requests==2.26.0 -rq==1.9.0 -six==1.16.0 -SQLAlchemy==1.4.22 -toml==0.10.2 -typing-extensions==3.10.0.0 -urllib3==1.26.6 -visitor==0.1.3 -Werkzeug==2.0.1 -WTForms==2.3.3 -zipp==3.5.0 +openpyxl +alembic==1.6.5 +attrs==21.2.0 +Babel==2.9.1 +blinker==1.4 +certifi==2021.5.30 +cffi==1.14.6 +chardet==4.0.0 +charset-normalizer==2.0.3 +click==8.0.1 +cracklib==2.9.3 +cryptography==3.4.7 +dnspython==2.1.0 +dominate==2.6.0 +email-validator==1.1.3 +Flask==2.0.1 +Flask-Babel==2.0.0 +Flask-Bootstrap==3.3.7.1 +Flask-Caching==1.10.1 +Flask-Login==0.5.0 +Flask-Mail==0.9.1 +Flask-Migrate==3.0.1 +Flask-Moment==1.0.2 +Flask-SQLAlchemy==2.5.1 +Flask-WTF==0.15.1 +greenlet==1.1.0 +icalendar==4.0.7 +idna==3.2 +importlib-metadata==4.6.1 +iniconfig==1.1.1 +itsdangerous==2.0.1 +Jinja2==3.0.1 +jwt==1.2.0 +Mako==1.1.4 +MarkupSafe==2.0.1 +packaging==21.0 +Pillow==8.3.1 +pkg-resources==0.0.0 +pluggy==0.13.1 +psycopg2==2.9.1 +py==1.10.0 +pycparser==2.20 +pydot==1.4.2 +pyOpenSSL==20.0.1 +pyparsing==2.4.7 +PyRSS2Gen==1.1 +pytest==6.2.4 +python-dateutil==2.8.2 +python-dotenv==0.18.0 +python-editor==1.0.4 +pytz==2021.1 +redis==3.5.3 +reportlab==3.5.68 +requests==2.26.0 +rq==1.9.0 +six==1.16.0 +SQLAlchemy==1.4.22 +toml==0.10.2 +typing-extensions==3.10.0.0 +urllib3==1.26.6 +visitor==0.1.3 +Werkzeug==2.0.1 +WTForms==2.3.3 +zipp==3.5.0