diff --git a/README.md b/README.md
index 753fc3dc..a757f17e 100644
--- a/README.md
+++ b/README.md
@@ -106,13 +106,15 @@ Ou avec couverture (`pip install pytest-cov`)
#### Utilisation des tests unitaires pour initialiser la base de dev
On peut aussi utiliser les tests unitaires pour mettre la base
-de données de développement dans un état connu, par exemple pour éviter de recréer à la main étudianst et semestres quand on développe.
+de données de développement dans un état connu, par exemple pour éviter de
+recréer à la main étudiants et semestres quand on développe.
Il suffit de positionner une variable d'environnement indiquant la BD utilisée par les tests:
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
-puis de les lancer normalement, par exemple:
+(si elle n'existe pas, voir plus loin pour la créer) puis de les lancer
+normalement, par exemple:
pytest tests/unit/test_sco_basic.py
@@ -133,7 +135,8 @@ On utilise SQLAlchemy avec Alembic et Flask-Migrate.
Ne pas oublier de commiter les migrations (`git add migrations` ...).
-Mémo pour développeurs: séquence re-création d'une base:
+Mémo pour développeurs: séquence re-création d'une base (vérifiez votre `.env`
+ou variables d'environnement pour interroger la bonne base !).
dropdb SCODOC_DEV
tools/create_database.sh SCODOC_DEV # créé base SQL
@@ -148,7 +151,25 @@ Si la base utilisée pour les dev n'est plus en phase avec les scripts de
migration, utiliser les commandes `flask db history`et `flask db stamp`pour se
positionner à la bonne étape.
-# Paquet debian 11
+### Profiling
+
+Sur une machine de DEV, lancer
+
+ flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
+
+le fichier `.prof` sera alors écrit dans `/opt/scoidoc-data` (on peut aussi utiliser `/tmp`).
+
+Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien:
+
+ pip install snakeviz
+
+puis
+
+ snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
+
+
+
+# Paquet Debian 11
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
important est `postinst`qui se charge de configurer le système (install ou
diff --git a/app/__init__.py b/app/__init__.py
index a3f04839..65707c26 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -1,6 +1,7 @@
# -*- coding: UTF-8 -*
# pylint: disable=invalid-name
+import datetime
import os
import socket
import sys
@@ -24,7 +25,12 @@ from flask_moment import Moment
from flask_caching import Cache
import sqlalchemy
-from app.scodoc.sco_exceptions import ScoGenError, ScoValueError, APIInvalidParams
+from app.scodoc.sco_exceptions import (
+ AccessDenied,
+ ScoGenError,
+ ScoValueError,
+ APIInvalidParams,
+)
from config import DevConfig
import sco_version
@@ -50,10 +56,21 @@ def handle_sco_value_error(exc):
return render_template("sco_value_error.html", exc=exc), 404
+def handle_access_denied(exc):
+ return render_template("error_access_denied.html", exc=exc), 403
+
+
def internal_server_error(e):
"""Bugs scodoc, erreurs 500"""
# note that we set the 500 status explicitly
- return render_template("error_500.html", SCOVERSION=sco_version.SCOVERSION), 500
+ return (
+ render_template(
+ "error_500.html",
+ SCOVERSION=sco_version.SCOVERSION,
+ date=datetime.datetime.now().isoformat(),
+ ),
+ 500,
+ )
def handle_invalid_usage(error):
@@ -93,6 +110,10 @@ class LogRequestFormatter(logging.Formatter):
record.url = None
record.remote_addr = None
record.sco_user = current_user
+ if has_request_context():
+ record.sco_admin_mail = current_app.config["SCODOC_ADMIN_MAIL"]
+ else:
+ record.sco_admin_mail = "(pas de requête)"
return super().format(record)
@@ -121,6 +142,10 @@ class LogExceptionFormatter(logging.Formatter):
record.http_params = None
record.sco_user = current_user
+ if has_request_context():
+ record.sco_admin_mail = current_app.config["SCODOC_ADMIN_MAIL"]
+ else:
+ record.sco_admin_mail = "(pas de requête)"
return super().format(record)
@@ -165,6 +190,7 @@ def create_app(config_class=DevConfig):
app.register_error_handler(ScoGenError, handle_sco_value_error)
app.register_error_handler(ScoValueError, handle_sco_value_error)
+ app.register_error_handler(AccessDenied, handle_access_denied)
app.register_error_handler(500, internal_server_error)
app.register_error_handler(503, postgresql_server_error)
app.register_error_handler(APIInvalidParams, handle_invalid_usage)
@@ -197,12 +223,14 @@ def create_app(config_class=DevConfig):
"[%(asctime)s] %(sco_user)s@%(remote_addr)s requested %(url)s\n"
"%(levelname)s: %(message)s"
)
+ # les champs additionnels sont définis dans LogRequestFormatter
scodoc_exc_formatter = LogExceptionFormatter(
"[%(asctime)s] %(sco_user)s@%(remote_addr)s requested %(url)s\n"
"%(levelname)s: %(message)s\n"
"Referrer: %(http_referrer)s\n"
"Method: %(http_method)s\n"
"Params: %(http_params)s\n"
+ "Admin mail: %(sco_admin_mail)s\n"
)
if not app.testing:
if not app.debug:
@@ -259,15 +287,19 @@ def create_app(config_class=DevConfig):
)
# ---- INITIALISATION SPECIFIQUES A SCODOC
from app.scodoc import sco_bulletins_generator
- from app.scodoc.sco_bulletins_example import BulletinGeneratorExample
+
from app.scodoc.sco_bulletins_legacy import BulletinGeneratorLegacy
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc.sco_bulletins_ucac import BulletinGeneratorUCAC
- sco_bulletins_generator.register_bulletin_class(BulletinGeneratorExample)
- sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy)
+ # l'ordre est important, le premeir sera le "défaut" pour les nouveaux départements.
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorStandard)
+ sco_bulletins_generator.register_bulletin_class(BulletinGeneratorLegacy)
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorUCAC)
+ if app.testing or app.debug:
+ from app.scodoc.sco_bulletins_example import BulletinGeneratorExample
+
+ sco_bulletins_generator.register_bulletin_class(BulletinGeneratorExample)
return app
diff --git a/app/auth/models.py b/app/auth/models.py
index 3a994e95..ed20d5ee 100644
--- a/app/auth/models.py
+++ b/app/auth/models.py
@@ -422,7 +422,7 @@ class UserRole(db.Model):
def get_super_admin():
- """L'utilisateur admin (où le premier, s'il y en a plusieurs).
+ """L'utilisateur admin (ou le premier, s'il y en a plusieurs).
Utilisé par les tests unitaires et le script de migration.
"""
admin_role = Role.query.filter_by(name="SuperAdmin").first()
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 89468ab2..93b781b7 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -273,6 +273,7 @@ class NotesModuleImplInscription(db.Model):
"""Inscription à un module (etudiants,moduleimpl)"""
__tablename__ = "notes_moduleimpl_inscription"
+ __table_args__ = (db.UniqueConstraint("moduleimpl_id", "etudid"),)
id = db.Column(db.Integer, primary_key=True)
moduleimpl_inscription_id = db.synonym("id")
diff --git a/app/scodoc/html_sidebar.py b/app/scodoc/html_sidebar.py
index 399d267e..658bbbea 100644
--- a/app/scodoc/html_sidebar.py
+++ b/app/scodoc/html_sidebar.py
@@ -40,7 +40,7 @@ from app.scodoc.sco_permissions import Permission
def sidebar_common():
"partie commune à toutes les sidebar"
H = [
- f"""ScoDoc 9
+ f"""ScoDoc 9
toutes notes
-
+ if not nt.get_etud_etat(etudid):
+ raise ScoValueError("Etudiant non inscrit à ce semestre")
I = scu.DictDefault(defaultvalue="")
I["etudid"] = etudid
I["formsemestre_id"] = formsemestre_id
@@ -774,8 +775,8 @@ def formsemestre_bulletinetud(
except:
sco_etud.log_unknown_etud()
raise ScoValueError("étudiant inconnu")
-
- sem = sco_formsemestre.get_formsemestre(formsemestre_id)
+ # API, donc erreurs admises en ScoValueError
+ sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True)
bulletin = do_formsemestre_bulletinetud(
formsemestre_id,
diff --git a/app/scodoc/sco_compute_moy.py b/app/scodoc/sco_compute_moy.py
index ae821068..94d1f1f3 100644
--- a/app/scodoc/sco_compute_moy.py
+++ b/app/scodoc/sco_compute_moy.py
@@ -348,7 +348,7 @@ def do_moduleimpl_moyennes(nt, mod):
if etudid in eval_rattr["notes"]:
note = eval_rattr["notes"][etudid]["value"]
if note != None and note != NOTES_NEUTRALISE and note != NOTES_ATTENTE:
- if isinstance(R[etudid], float):
+ if not isinstance(R[etudid], float):
R[etudid] = note
else:
note_sur_20 = note * 20.0 / eval_rattr["note_max"]
diff --git a/app/scodoc/sco_debouche.py b/app/scodoc/sco_debouche.py
index b32b39de..4c0b8916 100644
--- a/app/scodoc/sco_debouche.py
+++ b/app/scodoc/sco_debouche.py
@@ -48,9 +48,19 @@ import sco_version
def report_debouche_date(start_year=None, format="html"):
- """Rapport (table) pour les débouchés des étudiants sortis à partir de l'année indiquée."""
+ """Rapport (table) pour les débouchés des étudiants sortis
+ à partir de l'année indiquée.
+ """
if not start_year:
- return report_debouche_ask_date()
+ return report_debouche_ask_date("Année de début de la recherche")
+ else:
+ try:
+ start_year = int(start_year)
+ except ValueError:
+ return report_debouche_ask_date(
+ "Année invalide. Année de début de la recherche"
+ )
+
if format == "xls":
keep_numeric = True # pas de conversion des notes en strings
else:
@@ -96,8 +106,9 @@ def get_etudids_with_debouche(start_year):
FROM notes_formsemestre_inscription i, notes_formsemestre s, itemsuivi it
WHERE i.etudid = it.etudid
AND i.formsemestre_id = s.id AND s.date_fin >= %(start_date)s
+ AND s.dept_id = %(dept_id)s
""",
- {"start_date": start_date},
+ {"start_date": start_date, "dept_id": g.scodoc_dept_id},
)
return [x["etudid"] for x in r]
@@ -193,15 +204,16 @@ def table_debouche_etudids(etudids, keep_numeric=True):
return tab
-def report_debouche_ask_date():
+def report_debouche_ask_date(msg: str) -> str:
"""Formulaire demande date départ"""
- return (
- html_sco_header.sco_header()
- + """"""
- + html_sco_header.sco_footer()
- )
+ return f"""{html_sco_header.sco_header()}
+ Table des débouchés des étudiants
+
+ {html_sco_header.sco_footer()}
+ """
# ----------------------------------------------------------------------------
diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py
index f948ec31..a89f2184 100644
--- a/app/scodoc/sco_dept.py
+++ b/app/scodoc/sco_dept.py
@@ -48,6 +48,7 @@ from app.scodoc import sco_users
def index_html(showcodes=0, showsemtable=0):
"Page accueil département (liste des semestres)"
+ showcodes = int(showcodes)
showsemtable = int(showsemtable)
H = []
@@ -78,7 +79,7 @@ def index_html(showcodes=0, showsemtable=0):
# Responsable de formation:
sco_formsemestre.sem_set_responsable_name(sem)
- if showcodes == "1":
+ if showcodes:
sem["tmpcode"] = "%s | " % sem["formsemestre_id"]
else:
sem["tmpcode"] = ""
@@ -126,7 +127,7 @@ def index_html(showcodes=0, showsemtable=0):
"""
% sco_preferences.get_preference("DeptName")
)
- H.append(_sem_table_gt(sems).html())
+ H.append(_sem_table_gt(sems, showcodes=showcodes).html())
H.append("")
if not showsemtable:
H.append(
diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index a5509a28..dc1b4f84 100644
--- a/app/scodoc/sco_edit_ue.py
+++ b/app/scodoc/sco_edit_ue.py
@@ -845,6 +845,7 @@ def ue_sharing_code(ue_code=None, ue_id=None, hide_ue_id=None):
"""
from app.scodoc import sco_formations
+ ue_code = str(ue_code)
if ue_id:
ue = do_ue_list(args={"ue_id": ue_id})[0]
if not ue_code:
diff --git a/app/scodoc/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py
index 651c66d3..de1e7ec9 100644
--- a/app/scodoc/sco_etape_apogee_view.py
+++ b/app/scodoc/sco_etape_apogee_view.py
@@ -640,7 +640,7 @@ def view_apo_csv_delete(etape_apo="", semset_id="", dialog_confirmed=False):
if not semset_id:
raise ValueError("invalid null semset_id")
semset = sco_semset.SemSet(semset_id=semset_id)
- dest_url = "apo_semset_maq_status?semset_id=" + semset_id
+ dest_url = f"apo_semset_maq_status?semset_id={semset_id}"
if not dialog_confirmed:
return scu.confirm_dialog(
"""
Confirmer la suppression du fichier étape %s?
diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py
index 8e35b408..53d68f96 100644
--- a/app/scodoc/sco_etud.py
+++ b/app/scodoc/sco_etud.py
@@ -655,7 +655,7 @@ def log_unknown_etud():
def get_etud_info(etudid=False, code_nip=False, filled=False) -> list:
- """infos sur un etudiant (API). If not foud, returns empty list.
+ """infos sur un etudiant (API). If not found, returns empty list.
On peut specifier etudid ou code_nip
ou bien cherche dans les argumenst de la requête courante:
etudid, code_nip, code_ine (dans cet ordre).
@@ -671,6 +671,19 @@ def get_etud_info(etudid=False, code_nip=False, filled=False) -> list:
return etud
+# Optim par cache local, utilité non prouvée mais
+# on s'oriente vers un cahce persistent dans Redis ou bien l'utilisation de NT
+# def get_etud_info_filled_by_etudid(etudid, cnx=None) -> dict:
+# """Infos sur un étudiant, avec cache local à la requête"""
+# if etudid in g.stored_etud_info:
+# return g.stored_etud_info[etudid]
+# cnx = cnx or ndb.GetDBConnexion()
+# etud = etudident_list(cnx, args={"etudid": etudid})
+# fill_etuds_info(etud)
+# g.stored_etud_info[etudid] = etud[0]
+# return etud[0]
+
+
def create_etud(cnx, args={}):
"""Creation d'un étudiant. génère aussi évenement et "news".
diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py
index e8c02fe9..f3fc0ba2 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -35,7 +35,7 @@ from enum import Enum
from tempfile import NamedTemporaryFile
import openpyxl.utils.datetime
-from openpyxl.styles.numbers import FORMAT_NUMBER_00, FORMAT_GENERAL
+from openpyxl.styles.numbers import FORMAT_NUMBER_00, FORMAT_GENERAL, FORMAT_DATE_DDMMYY
from openpyxl.comments import Comment
from openpyxl import Workbook, load_workbook
from openpyxl.cell import WriteOnlyCell
@@ -65,10 +65,16 @@ class COLORS(Enum):
def xldate_as_datetime(xldate, datemode=0):
- """Conversion d'une date Excel en date
+ """Conversion d'une date Excel en datetime python
+ Deux formats de chaîne acceptés:
+ * JJ/MM/YYYY (chaîne naïve)
+ * Date ISO (valeur de type date lue dans la feuille)
Peut lever une ValueError
"""
- return openpyxl.utils.datetime.from_ISO8601(xldate)
+ try:
+ return datetime.datetime.strptime(xldate, "%d/%m/%Y")
+ except:
+ return openpyxl.utils.datetime.from_ISO8601(xldate)
def adjust_sheetname(sheet_name):
@@ -283,10 +289,6 @@ class ScoExcelSheet:
style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié
"""
cell = WriteOnlyCell(self.ws, value or "")
- if not (isinstance(value, int) or isinstance(value, float)):
- cell.data_type = "s"
- # if style is not None and "fill" in style:
- # toto()
if style is None:
style = self.default_style
if "font" in style:
@@ -308,6 +310,14 @@ class ScoExcelSheet:
lines = comment.splitlines()
cell.comment.width = 7 * max([len(line) for line in lines])
cell.comment.height = 20 * len(lines)
+ # test datatype at the end so that datetime format may be overwritten
+ if isinstance(value, datetime.date):
+ cell.data_type = "d"
+ cell.number_format = FORMAT_DATE_DDMMYY
+ elif isinstance(value, int) or isinstance(value, float):
+ cell.data_type = "n"
+ else:
+ cell.data_type = "s"
return cell
def make_row(self, values: list, style=None, comments=None):
@@ -568,10 +578,9 @@ def excel_bytes_to_list(bytes_content):
return _excel_to_list(filelike)
except:
raise ScoValueError(
+ """Le fichier xlsx attendu n'est pas lisible !
+ Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ..)
"""
- scolars_import_excel_file: un contenu xlsx semble corrompu!
- peut-être avez vous fourni un fichier au mauvais format (txt, xls, ..)
- """
)
@@ -580,10 +589,9 @@ def excel_file_to_list(filename):
return _excel_to_list(filename)
except:
raise ScoValueError(
- """scolars_import_excel_file: un contenu xlsx
- semble corrompu !
- Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
- """
+ """Le fichier xlsx attendu n'est pas lisible !
+ Peut-être avez-vous fourni un fichier au mauvais format (txt, xls, ...)
+ """
)
diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py
index ec487b09..1ae86e65 100644
--- a/app/scodoc/sco_exceptions.py
+++ b/app/scodoc/sco_exceptions.py
@@ -45,10 +45,6 @@ class InvalidEtudId(NoteProcessError):
pass
-class AccessDenied(ScoException):
- pass
-
-
class InvalidNoteValue(ScoException):
pass
@@ -92,6 +88,10 @@ class ScoGenError(ScoException):
ScoException.__init__(self, msg)
+class AccessDenied(ScoGenError):
+ pass
+
+
class ScoInvalidDateError(ScoValueError):
pass
diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py
index 40355120..e65a9205 100644
--- a/app/scodoc/sco_formsemestre.py
+++ b/app/scodoc/sco_formsemestre.py
@@ -93,16 +93,21 @@ _formsemestreEditor = ndb.EditableTable(
)
-def get_formsemestre(formsemestre_id):
+def get_formsemestre(formsemestre_id, raise_soft_exc=False):
"list ONE formsemestre"
+ if formsemestre_id in g.stored_get_formsemestre:
+ return g.stored_get_formsemestre[formsemestre_id]
if not isinstance(formsemestre_id, int):
raise ValueError("formsemestre_id must be an integer !")
- try:
- sem = do_formsemestre_list(args={"formsemestre_id": formsemestre_id})[0]
- return sem
- except:
+ sems = do_formsemestre_list(args={"formsemestre_id": formsemestre_id})
+ if not sems:
log("get_formsemestre: invalid formsemestre_id (%s)" % formsemestre_id)
- raise
+ if raise_soft_exc:
+ raise ScoValueError(f"semestre {formsemestre_id} inconnu !")
+ else:
+ raise ValueError(f"semestre {formsemestre_id} inconnu !")
+ g.stored_get_formsemestre[formsemestre_id] = sems[0]
+ return sems[0]
def do_formsemestre_list(*a, **kw):
diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
index e85fd4a3..5aa948e4 100644
--- a/app/scodoc/sco_formsemestre_status.py
+++ b/app/scodoc/sco_formsemestre_status.py
@@ -337,7 +337,7 @@ def formsemestre_status_menubar(sem):
submenu.append(
{
"title": "%s" % partition["partition_name"],
- "endpoint": "scolar.affectGroups",
+ "endpoint": "scolar.affect_groups",
"args": {"partition_id": partition["partition_id"]},
"enabled": enabled,
}
@@ -505,15 +505,29 @@ def formsemestre_page_title():
fill_formsemestre(sem)
- H = [
- """
""",
- """
"""
- % sem,
- formsemestre_status_menubar(sem),
- """
""",
- ]
- return "\n".join(H)
+ h = f"""
+
+ {formsemestre_status_menubar(sem)}
+
+ """
+
+ return h
def fill_formsemestre(sem):
@@ -843,7 +857,7 @@ def _make_listes_sem(sem, with_absences=True):
H.append('
Aucun groupe dans cette partition')
if sco_groups.sco_permissions_check.can_change_groups(formsemestre_id):
H.append(
- f""" (créer)"""
@@ -967,7 +981,7 @@ def formsemestre_status(formsemestre_id=None):
"""Tableau de bord semestre HTML"""
# porté du DTML
cnx = ndb.GetDBConnexion()
- sem = sco_formsemestre.get_formsemestre(formsemestre_id)
+ sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True)
Mlist = sco_moduleimpl.do_moduleimpl_withmodule_list(
formsemestre_id=formsemestre_id
)
diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py
index 6512f1fe..0a776813 100644
--- a/app/scodoc/sco_groups.py
+++ b/app/scodoc/sco_groups.py
@@ -492,6 +492,8 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
"""
from app.scodoc import sco_formsemestre
+ cnx = ndb.GetDBConnexion()
+
t0 = time.time()
partition = get_partition(partition_id)
formsemestre_id = partition["formsemestre_id"]
@@ -500,6 +502,7 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
nt = sco_cache.NotesTableCache.get(formsemestre_id) # > inscrdict
etuds_set = set(nt.inscrdict)
# Build XML:
+ t1 = time.time()
doc = Element("ajax-response")
x_response = Element("response", type="object", id="MyUpdater")
doc.append(x_response)
@@ -513,7 +516,8 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
)
x_response.append(x_group)
for e in get_group_members(group["group_id"]):
- etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=1)[0]
+ etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0]
+ # etud = sco_etud.get_etud_info_filled_by_etudid(e["etudid"], cnx)
x_group.append(
Element(
"etud",
@@ -540,6 +544,7 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
doc.append(x_group)
for etudid in etuds_set:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
+ # etud = sco_etud.get_etud_info_filled_by_etudid(etudid, cnx)
x_group.append(
Element(
"etud",
@@ -550,7 +555,8 @@ def XMLgetGroupsInPartition(partition_id): # was XMLgetGroupesTD
origin=comp_origin(etud, sem),
)
)
- log("XMLgetGroupsInPartition: %s seconds" % (time.time() - t0))
+ t2 = time.time()
+ log(f"XMLgetGroupsInPartition: {t2-t0} seconds ({t1-t0}+{t2-t1})")
# XML response:
data = sco_xml.XML_HEADER + ElementTree.tostring(doc).decode(scu.SCO_ENCODING)
response = make_response(data)
@@ -911,7 +917,7 @@ def editPartitionForm(formsemestre_id=None):
H.append(", ".join(lg))
H.append(
f"""
répartir |
@@ -1173,7 +1179,7 @@ def group_set_name(group_id, group_name, redirect=1):
if redirect:
return flask.redirect(
url_for(
- "scolar.affectGroups",
+ "scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=group["partition_id"],
)
@@ -1216,7 +1222,7 @@ def group_rename(group_id):
elif tf[0] == -1:
return flask.redirect(
url_for(
- "scolar.affectGroups",
+ "scolar.affect_groups",
scodoc_dept=g.scodoc_dept,
partition_id=group["partition_id"],
)
@@ -1236,7 +1242,7 @@ def groups_auto_repartition(partition_id=None):
formsemestre_id = partition["formsemestre_id"]
# renvoie sur page édition groupes
dest_url = url_for(
- "scolar.affectGroups", scodoc_dept=g.scodoc_dept, partition_id=partition_id
+ "scolar.affect_groups", scodoc_dept=g.scodoc_dept, partition_id=partition_id
)
if not sco_permissions_check.can_change_groups(formsemestre_id):
raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !")
diff --git a/app/scodoc/sco_groups_edit.py b/app/scodoc/sco_groups_edit.py
index 75eb12b4..bc419690 100644
--- a/app/scodoc/sco_groups_edit.py
+++ b/app/scodoc/sco_groups_edit.py
@@ -27,70 +27,33 @@
"""Formulaires gestion des groupes
"""
+from flask import render_template
from app.scodoc import html_sco_header
from app.scodoc import sco_groups
from app.scodoc.sco_exceptions import AccessDenied
-def affectGroups(partition_id):
+def affect_groups(partition_id):
"""Formulaire affectation des etudiants aux groupes de la partition.
Permet aussi la creation et la suppression de groupes.
"""
- # Ported from DTML and adapted to new group management (nov 2009)
+ # réécrit pour 9.0.47 avec un template
partition = sco_groups.get_partition(partition_id)
formsemestre_id = partition["formsemestre_id"]
if not sco_groups.sco_permissions_check.can_change_groups(formsemestre_id):
raise AccessDenied("vous n'avez pas la permission d'effectuer cette opération")
-
- H = [
- html_sco_header.sco_header(
+ return render_template(
+ "scolar/affect_groups.html",
+ sco_header=html_sco_header.sco_header(
page_title="Affectation aux groupes",
javascripts=["js/groupmgr.js"],
cssstyles=["css/groups.css"],
),
- """
""",
- """
Faites glisser les étudiants d'un groupe à l'autre. Les modifications ne sont enregistrées que lorsque vous cliquez sur le bouton "Enregistrer ces groupes". Vous pouvez créer de nouveaux groupes. Pour supprimer un groupe, utiliser le lien "suppr." en haut à droite de sa boite. Vous pouvez aussi répartir automatiquement les groupes.
-
"""
- % partition,
- """
""",
- """
""",
- """
""",
- """
-
-
-
-
-
-
-
-""",
- html_sco_header.sco_footer(),
- ]
- return "\n".join(H)
+ sco_footer=html_sco_header.sco_footer(),
+ partition=partition,
+ partitions_list=sco_groups.get_partitions_list(
+ formsemestre_id, with_default=False
+ ),
+ formsemestre_id=formsemestre_id,
+ )
diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py
index ebab7ec6..91444729 100644
--- a/app/scodoc/sco_groups_view.py
+++ b/app/scodoc/sco_groups_view.py
@@ -489,7 +489,7 @@ def groups_table(
columns_ids += ["etape", "etudid", "code_nip", "code_ine"]
if with_paiement:
columns_ids += ["datefinalisationinscription_str", "paiementinscription_str"]
- if with_paiement or with_codes:
+ if with_paiement: # or with_codes:
sco_portal_apogee.check_paiement_etuds(groups_infos.members)
if with_archives:
from app.scodoc import sco_archives_etud
diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py
index 1c366268..0bd9d447 100644
--- a/app/scodoc/sco_import_etuds.py
+++ b/app/scodoc/sco_import_etuds.py
@@ -25,16 +25,16 @@
#
##############################################################################
-""" Importation des etudiants à partir de fichiers CSV
+""" Importation des étudiants à partir de fichiers CSV
"""
import collections
+import io
import os
import re
import time
from datetime import date
-import flask
from flask import g, url_for
import app.scodoc.sco_utils as scu
@@ -252,7 +252,7 @@ def students_import_excel(
def scolars_import_excel_file(
- datafile,
+ datafile: io.BytesIO,
formsemestre_id=None,
check_homonyms=True,
require_ine=False,
@@ -414,16 +414,14 @@ def scolars_import_excel_file(
if NbHomonyms:
NbImportedHomonyms += 1
# Insert in DB tables
- formsemestre_to_invalidate.add(
- _import_one_student(
- cnx,
- formsemestre_id,
- values,
- GroupIdInferers,
- annee_courante,
- created_etudids,
- linenum,
- )
+ formsemestre_id_etud = _import_one_student(
+ cnx,
+ formsemestre_id,
+ values,
+ GroupIdInferers,
+ annee_courante,
+ created_etudids,
+ linenum,
)
# Verification proportion d'homonymes: si > 10%, abandonne
@@ -522,7 +520,7 @@ def _import_one_student(
annee_courante,
created_etudids,
linenum,
-):
+) -> int:
"""
Import d'un étudiant et inscription dans le semestre.
Return: id du semestre dans lequel il a été inscrit.
@@ -550,6 +548,12 @@ def _import_one_student(
else:
args["formsemestre_id"] = values["codesemestre"]
formsemestre_id = values["codesemestre"]
+ try:
+ formsemestre_id = int(formsemestre_id)
+ except ValueError as exc:
+ raise ScoValueError(
+ f"valeur invalide dans la colonne codesemestre, ligne {linenum+1}"
+ ) from exc
# recupere liste des groupes:
if formsemestre_id not in GroupIdInferers:
GroupIdInferers[formsemestre_id] = sco_groups.GroupIdInferer(formsemestre_id)
@@ -566,7 +570,7 @@ def _import_one_student(
)
do_formsemestre_inscription_with_modules(
- args["formsemestre_id"],
+ int(args["formsemestre_id"]),
etudid,
group_ids,
etat="I",
diff --git a/app/scodoc/sco_import_users.py b/app/scodoc/sco_import_users.py
index 89ac9989..f5056b59 100644
--- a/app/scodoc/sco_import_users.py
+++ b/app/scodoc/sco_import_users.py
@@ -109,8 +109,11 @@ def import_excel_file(datafile):
if not exceldata:
raise ScoValueError("Ficher excel vide ou invalide")
_, data = sco_excel.excel_bytes_to_list(exceldata)
- if not data: # probably a bug
- raise ScoException("import_excel_file: empty file !")
+ if not data:
+ raise ScoValueError(
+ """Le fichier xlsx attendu semble vide !
+ """
+ )
# 1- --- check title line
fs = [scu.stripquotes(s).lower() for s in data[0]]
log("excel: fs='%s'\ndata=%s" % (str(fs), str(data)))
@@ -179,11 +182,13 @@ def import_users(users):
line = line + 1
user_ok, msg = sco_users.check_modif_user(
0,
+ ignore_optionals=False,
user_name=u["user_name"],
nom=u["nom"],
prenom=u["prenom"],
email=u["email"],
roles=u["roles"].split(","),
+ dept=u["dept"],
)
if not user_ok:
append_msg("identifiant '%s' %s" % (u["user_name"], msg))
@@ -193,39 +198,12 @@ def import_users(users):
u["passwd"] = generate_password()
#
# check identifiant
- if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]*$", u["user_name"]):
- user_ok = False
- append_msg(
- "identifiant '%s' invalide (pas d'accents ni de caractères spéciaux)"
- % u["user_name"]
- )
- if len(u["user_name"]) > 64:
- user_ok = False
- append_msg(
- "identifiant '%s' trop long (64 caractères)" % u["user_name"]
- )
- if len(u["nom"]) > 64:
- user_ok = False
- append_msg("nom '%s' trop long (64 caractères)" % u["nom"])
- if len(u["prenom"]) > 64:
- user_ok = False
- append_msg("prenom '%s' trop long (64 caractères)" % u["prenom"])
- if len(u["email"]) > 120:
- user_ok = False
- append_msg("email '%s' trop long (120 caractères)" % u["email"])
- # check that tha same user_name has not already been described in this import
if u["user_name"] in created.keys():
user_ok = False
append_msg(
"l'utilisateur '%s' a déjà été décrit ligne %s"
% (u["user_name"], created[u["user_name"]]["line"])
)
- # check département
- if u["dept"] != "":
- dept = Departement.query.filter_by(acronym=u["dept"]).first()
- if dept is None:
- user_ok = False
- append_msg("département '%s' inexistant" % u["dept"])
# check roles / ignore whitespaces around roles / build roles_string
# roles_string (expected by User) appears as column 'roles' in excel file
roles_list = []
diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py
index 4fb8c57a..cc917284 100644
--- a/app/scodoc/sco_inscr_passage.py
+++ b/app/scodoc/sco_inscr_passage.py
@@ -390,7 +390,7 @@ def formsemestre_inscr_passage(
): # il y a au moins une vraie partition
H.append(
f"""Répartir les groupes de {partition["partition_name"]}
"""
diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py
index c2ac6691..a9ea6ce8 100644
--- a/app/scodoc/sco_liste_notes.py
+++ b/app/scodoc/sco_liste_notes.py
@@ -315,7 +315,7 @@ def _make_table_notes(
rows.append(
{
- "code": code,
+ "code": str(code), # INE, NIP ou etudid
"_code_td_attrs": 'style="padding-left: 1em; padding-right: 2em;"',
"etudid": etudid,
"nom": etud["nom"].upper(),
@@ -374,9 +374,11 @@ def _make_table_notes(
columns_ids.append(e["evaluation_id"])
#
if anonymous_listing:
- rows.sort(key=lambda x: x["code"])
+ rows.sort(key=lambda x: x["code"] or "")
else:
- rows.sort(key=lambda x: (x["nom"], x["prenom"])) # sort by nom, prenom
+ rows.sort(
+ key=lambda x: (x["nom"] or "", x["prenom"] or "")
+ ) # sort by nom, prenom
# Si module, ajoute moyenne du module:
if len(evals) > 1:
diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py
index 9fa261a9..93d420d0 100644
--- a/app/scodoc/sco_moduleimpl_inscriptions.py
+++ b/app/scodoc/sco_moduleimpl_inscriptions.py
@@ -527,15 +527,15 @@ def do_etud_desinscrit_ue(etudid, formsemestre_id, ue_id):
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"""DELETE FROM notes_moduleimpl_inscription
- WHERE moduleimpl_inscription_id IN (
- SELECT i.moduleimpl_inscription_id FROM
+ WHERE id IN (
+ SELECT i.id FROM
notes_moduleimpl mi, notes_modules mod,
notes_formsemestre sem, notes_moduleimpl_inscription i
- WHERE sem.formsemestre_id = %(formsemestre_id)s
- AND mi.formsemestre_id = sem.formsemestre_id
- AND mod.module_id = mi.module_id
+ WHERE sem.id = %(formsemestre_id)s
+ AND mi.formsemestre_id = sem.id
+ AND mod.id = mi.module_id
AND mod.ue_id = %(ue_id)s
- AND i.moduleimpl_id = mi.moduleimpl_id
+ AND i.moduleimpl_id = mi.id
AND i.etudid = %(etudid)s
)
""",
diff --git a/app/scodoc/sco_parcours_dut.py b/app/scodoc/sco_parcours_dut.py
index be4f983f..cb10bf4b 100644
--- a/app/scodoc/sco_parcours_dut.py
+++ b/app/scodoc/sco_parcours_dut.py
@@ -916,7 +916,7 @@ def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite)
and ue_status["moy"] >= nt.parcours.NOTES_BARRE_VALID_UE
):
code_ue = ADM
- elif isinstance(ue_status["moy"], float):
+ elif not isinstance(ue_status["moy"], float):
# aucune note (pas de moyenne) dans l'UE: ne la valide pas
code_ue = None
elif valid_semestre:
diff --git a/app/scodoc/sco_pdf.py b/app/scodoc/sco_pdf.py
index 2a97e716..c504ae17 100755
--- a/app/scodoc/sco_pdf.py
+++ b/app/scodoc/sco_pdf.py
@@ -66,7 +66,7 @@ from app.scodoc.sco_utils import (
LOGOS_IMAGES_ALLOWED_TYPES,
)
from app import log
-from app.scodoc.sco_exceptions import ScoGenError
+from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
import sco_version
PAGE_HEIGHT = defaultPageSize[1]
@@ -121,6 +121,7 @@ def makeParas(txt, style, suppress_empty=False):
"""Returns a list of Paragraph instances from a text
with one or more ...
"""
+ result = []
try:
paras = _splitPara(txt)
if suppress_empty:
@@ -133,21 +134,30 @@ def makeParas(txt, style, suppress_empty=False):
if m.group(1): # non empty paragraph
r.append(para)
paras = r
- return [Paragraph(SU(s), style) for s in paras]
+ result = [Paragraph(SU(s), style) for s in paras]
+ except OSError as e:
+ msg = str(e)
+ # If a file is missing, try to display the invalid name
+ m = re.match(r".*\sfilename=\'(.*?)\'.*", msg, re.DOTALL)
+ if m:
+ filename = os.path.split(m.group(1))[1]
+ if filename.startswith("logo_"):
+ filename = filename[len("logo_") :]
+ raise ScoValueError(
+ f"Erreur dans le format PDF paramétré: fichier logo {filename} non trouvé"
+ ) from e
+ else:
+ raise e
except Exception as e:
- detail = " " + str(e)
log(traceback.format_exc())
log("Invalid pdf para format: %s" % txt)
- return [
+ result = [
Paragraph(
- SU(
- 'Erreur: format invalide{}'.format(
- detail
- )
- ),
+ SU('Erreur: format invalide'),
style,
)
]
+ return result
def bold_paras(L, tag="b", close=None):
diff --git a/app/scodoc/sco_placement.py b/app/scodoc/sco_placement.py
index ac39c2bb..c2ef9d93 100644
--- a/app/scodoc/sco_placement.py
+++ b/app/scodoc/sco_placement.py
@@ -88,10 +88,9 @@ def _get_group_info(evaluation_id):
groups_tree[partition][group_name] = group_id
if partition != TOUS:
has_groups = True
- nb_groups = len(groups_tree)
else:
has_groups = False
- nb_groups = 1
+ nb_groups = sum([len(groups_tree[p]) for p in groups_tree])
return groups_tree, has_groups, nb_groups
diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py
index 59a1d6d9..c4c60d81 100644
--- a/app/scodoc/sco_saisie_notes.py
+++ b/app/scodoc/sco_saisie_notes.py
@@ -827,7 +827,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
]
)
- filename = "notes_%s_%s.xlsx" % (evalname, gr_title_filename)
+ filename = "notes_%s_%s" % (evalname, gr_title_filename)
xls = sco_excel.excel_feuille_saisie(E, sem["titreannee"], description, lines=L)
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE)
# return sco_excel.send_excel_file(xls, filename)
diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py
index 649358a6..c91ccf6d 100644
--- a/app/scodoc/sco_synchro_etuds.py
+++ b/app/scodoc/sco_synchro_etuds.py
@@ -271,7 +271,7 @@ def formsemestre_synchro_etuds(
if partitions: # il y a au moins une vraie partition
H.append(
f"""Répartir les groupes de {partitions[0]["partition_name"]}
diff --git a/app/scodoc/sco_users.py b/app/scodoc/sco_users.py
index b766908c..1dd104b6 100644
--- a/app/scodoc/sco_users.py
+++ b/app/scodoc/sco_users.py
@@ -29,13 +29,14 @@
"""
# Anciennement ZScoUsers.py, fonctions de gestion des données réécrite avec flask/SQLAlchemy
+import re
from flask import url_for, g, request
from flask_login import current_user
import cracklib # pylint: disable=import-error
-from app import db
+from app import db, Departement
from app.auth.models import Permission
from app.auth.models import User
@@ -171,21 +172,25 @@ def list_users(
if not can_modify:
d["date_modif_passwd"] = "(non visible)"
+ columns_ids = [
+ "user_name",
+ "nom_fmt",
+ "prenom_fmt",
+ "email",
+ "dept",
+ "roles_string",
+ "date_expiration",
+ "date_modif_passwd",
+ "passwd_temp",
+ "status_txt",
+ ]
+ # Seul l'admin peut voir les dates de dernière connexion
+ if current_user.is_administrator():
+ columns_ids.append("last_seen")
title = "Utilisateurs définis dans ScoDoc"
tab = GenTable(
rows=r,
- columns_ids=(
- "user_name",
- "nom_fmt",
- "prenom_fmt",
- "email",
- "dept",
- "roles_string",
- "date_expiration",
- "date_modif_passwd",
- "passwd_temp",
- "status_txt",
- ),
+ columns_ids=columns_ids,
titles={
"user_name": "Login",
"nom_fmt": "Nom",
@@ -195,6 +200,7 @@ def list_users(
"roles_string": "Rôles",
"date_expiration": "Expiration",
"date_modif_passwd": "Modif. mot de passe",
+ "last_seen": "Dernière cnx.",
"passwd_temp": "Temp.",
"status_txt": "Etat",
},
@@ -206,7 +212,7 @@ def list_users(
html_class="table_leftalign list_users",
html_with_td_classes=True,
html_sortable=True,
- base_url="%s?all=%s" % (request.base_url, all),
+ base_url="%s?all_depts=%s" % (request.base_url, 1 if all_depts else 0),
pdf_link=False, # table is too wide to fit in a paper page => disable pdf
preferences=sco_preferences.SemPreferences(),
)
@@ -379,7 +385,16 @@ def user_info_page(user_name=None):
return "\n".join(H) + F
-def check_modif_user(edit, user_name="", nom="", prenom="", email="", roles=[]):
+def check_modif_user(
+ edit,
+ ignore_optionals=False,
+ user_name="",
+ nom="",
+ prenom="",
+ email="",
+ dept="",
+ roles=[],
+):
"""Vérifie que cet utilisateur peut être créé (edit=0) ou modifié (edit=1)
Cherche homonymes.
returns (ok, msg)
@@ -387,17 +402,44 @@ def check_modif_user(edit, user_name="", nom="", prenom="", email="", roles=[]):
(si ok est faux, l'utilisateur peut quand même forcer la creation)
- msg: message warning a presenter l'utilisateur
"""
- if not user_name or not nom or not prenom:
- return False, "champ requis vide"
- if not email:
- return False, "vous devriez indiquer le mail de l'utilisateur créé !"
+ MSG_OPT = """Attention: %s (vous pouvez forcer l'opération en cochant "Ignorer les avertissements" en bas de page)"""
# ce login existe ?
user = _user_list(user_name)
if edit and not user: # safety net, le user_name ne devrait pas changer
return False, "identifiant %s inexistant" % user_name
if not edit and user:
return False, "identifiant %s déjà utilisé" % user_name
-
+ if not user_name or not nom or not prenom:
+ return False, "champ requis vide"
+ if not re.match(r"^[a-zA-Z0-9@\\\-_\\\.]*$", user_name):
+ return (
+ False,
+ "identifiant '%s' invalide (pas d'accents ni de caractères spéciaux)"
+ % user_name,
+ )
+ if ignore_optionals and len(user_name) > 64:
+ return False, "identifiant '%s' trop long (64 caractères)" % user_name
+ if ignore_optionals and len(nom) > 64:
+ return False, "nom '%s' trop long (64 caractères)" % nom + MSG_OPT
+ if ignore_optionals and len(prenom) > 64:
+ return False, "prenom '%s' trop long (64 caractères)" % prenom + MSG_OPT
+ # check that tha same user_name has not already been described in this import
+ if not email:
+ return False, "vous devriez indiquer le mail de l'utilisateur créé !"
+ if len(email) > 120:
+ return False, "email '%s' trop long (120 caractères)" % email
+ if not re.fullmatch(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", email):
+ return False, "l'adresse mail semble incorrecte"
+ # check département
+ if (
+ ignore_optionals
+ and dept != ""
+ and Departement.query.filter_by(acronym=dept).first() is None
+ ):
+ return False, "département '%s' inexistant" % u["dept"] + MSG_OPT
+ if ignore_optionals and not roles:
+ return False, "aucun rôle sélectionné, êtes vous sûr ?" + MSG_OPT
+ # ok
# Des noms/prénoms semblables existent ?
nom = nom.lower().strip()
prenom = prenom.lower().strip()
@@ -417,12 +459,10 @@ def check_modif_user(edit, user_name="", nom="", prenom="", email="", roles=[]):
"%s %s (pseudo=%s)" % (x.prenom, x.nom, x.user_name)
for x in similar_users
]
- ),
+ )
+ + MSG_OPT,
)
# Roles ?
- if not roles:
- return False, "aucun rôle sélectionné, êtes vous sûr ?"
- # ok
return True, ""
diff --git a/app/static/js/groupmgr.js b/app/static/js/groupmgr.js
index 7d16adc9..de0e512d 100644
--- a/app/static/js/groupmgr.js
+++ b/app/static/js/groupmgr.js
@@ -402,7 +402,7 @@ function GotoAnother() {
if (groups_unsaved) {
alert("Enregistrez ou annulez vos changement avant !");
} else
- document.location = SCO_URL + '/affectGroups?partition_id=' + document.formGroup.other_partition_id.value;
+ document.location = SCO_URL + '/affect_groups?partition_id=' + document.formGroup.other_partition_id.value;
}
diff --git a/app/templates/error_500.html b/app/templates/error_500.html
index 23963376..d986a00d 100644
--- a/app/templates/error_500.html
+++ b/app/templates/error_500.html
@@ -4,8 +4,9 @@
{% block title %}Une erreur est survenue !{% endblock %}
{% block body %}
Une erreur est survenue !
-Oops... ScoDoc version {{SCOVERSION}} a
+
Oups... ScoDoc version {{SCOVERSION}} a
un problème, désolé.
+{{date}}
Si le problème persiste, contacter l'administrateur de votre site,
ou écrire la liste "notes" notes@listes.univ-paris13.fr en
diff --git a/app/templates/error_access_denied.html b/app/templates/error_access_denied.html
new file mode 100644
index 00000000..5a765b76
--- /dev/null
+++ b/app/templates/error_access_denied.html
@@ -0,0 +1,19 @@
+{% extends 'base.html' %}
+{% import 'bootstrap/wtf.html' as wtf %}
+
+{% block app_content %}
+
+
Accès non autorisé
+
+{{ exc | safe }}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/scodoc/forms/placement.html b/app/templates/scodoc/forms/placement.html
index 5d2c3c20..7631b743 100644
--- a/app/templates/scodoc/forms/placement.html
+++ b/app/templates/scodoc/forms/placement.html
@@ -3,7 +3,7 @@
{% macro render_field(field) %}
{{ field.label }} |
- {{ field()|safe }}
+ | {{ field(**kwargs)|safe }}
{% if field.errors %}
{% for error in field.errors %}
@@ -27,7 +27,7 @@
{{ render_field(form.nb_rangs) }}
{{ render_field(form.etiquetage) }}
{% if form.has_groups %}
- {{ render_field(form.groups) }}
+ {{ render_field(form.groups, size=form.nb_groups) }}
|