Refactoring et uniformisation tables jury/recap.

This commit is contained in:
Emmanuel Viennet 2023-02-12 01:13:43 +01:00
parent fa911907ad
commit 4db6ee368a
18 changed files with 380 additions and 413 deletions

View File

@ -13,6 +13,7 @@
import datetime
from functools import cached_property
from flask_login import current_user
import flask_sqlalchemy
from flask import flash, g
from sqlalchemy import and_, or_
@ -20,6 +21,7 @@ from sqlalchemy.sql import text
import app.scodoc.sco_utils as scu
from app import db, log
from app.auth.models import User
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import (
ApcAnneeParcours,
@ -535,10 +537,32 @@ class FormSemestre(db.Model):
else:
return ", ".join([u.get_nomcomplet() for u in self.responsables])
def est_responsable(self, user):
def est_responsable(self, user: User):
"True si l'user est l'un des responsables du semestre"
return user.id in [u.id for u in self.responsables]
def est_chef_or_diretud(self, user: User = None):
"Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
user = user or current_user
return user.has_permission(Permission.ScoImplement) or self.est_responsable(
user
)
def can_edit_jury(self, user: User = None):
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
dans ce semestre: vérifie permission et verrouillage.
"""
user = user or current_user
return self.etat and self.est_chef_or_diretud(user)
def can_edit_pv(self, user: User = None):
"Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
user = user or current_user
# Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr
return self.est_chef_or_diretud(user) or user.has_permission(
Permission.ScoEtudChangeAdr
)
def annee_scolaire(self) -> int:
"""L'année de début de l'année scolaire.
Par exemple, 2022 si le semestre va de septembre 2022 à février 2023."""

View File

@ -251,7 +251,7 @@ def sco_header(
#gtrcontent {{
margin-left: {params["margin_left"]};
height: 100%%;
margin-bottom: 10px;
margin-bottom: 16px;
}}
</style>
"""

View File

@ -47,7 +47,7 @@
qui est une description (humaine, format libre) de l'archive.
"""
import chardet
from typing import Union
import datetime
import glob
import json
@ -56,10 +56,11 @@ import os
import re
import shutil
import time
from typing import Union
import chardet
import flask
from flask import g, request
from flask import flash, g, request, url_for
from flask_login import current_user
import app.scodoc.sco_utils as scu
@ -70,9 +71,7 @@ from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Departement, FormSemestre
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_exceptions import (
AccessDenied,
)
from app.scodoc.sco_exceptions import AccessDenied, ScoPermissionDenied
from app.scodoc import html_sco_header
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_formsemestre
@ -314,7 +313,7 @@ def do_formsemestre_archive(
"""
from app.scodoc.sco_recapcomplet import (
gen_formsemestre_recapcomplet_excel,
gen_formsemestre_recapcomplet_html,
gen_formsemestre_recapcomplet_html_table,
gen_formsemestre_recapcomplet_json,
)
@ -338,7 +337,7 @@ def do_formsemestre_archive(
if data:
PVArchive.store(archive_id, "Tableau_moyennes" + scu.XLSX_SUFFIX, data)
# Tableau recap notes en HTML (pour tous les etudiants, n'utilise pas les groupes)
table_html = gen_formsemestre_recapcomplet_html(
table_html, _ = gen_formsemestre_recapcomplet_html_table(
formsemestre, res, include_evaluations=True
)
if table_html:
@ -416,8 +415,15 @@ def formsemestre_archive(formsemestre_id, group_ids=[]):
"""Make and store new archive for this formsemestre.
(all students or only selected groups)
"""
if not sco_permissions_check.can_edit_pv(formsemestre_id):
raise AccessDenied("opération non autorisée pour %s" % str(current_user))
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_pv():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
if not group_ids:
@ -579,26 +585,38 @@ def formsemestre_list_archives(formsemestre_id):
def formsemestre_get_archived_file(formsemestre_id, archive_name, filename):
"""Send file to client."""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
sem_archive_id = formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
sem_archive_id = formsemestre.id
return PVArchive.get_archived_file(sem_archive_id, archive_name, filename)
def formsemestre_delete_archive(formsemestre_id, archive_name, dialog_confirmed=False):
"""Delete an archive"""
if not sco_permissions_check.can_edit_pv(formsemestre_id):
raise AccessDenied("opération non autorisée pour %s" % str(current_user))
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_pv():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
sem_archive_id = formsemestre_id
archive_id = PVArchive.get_id_from_name(sem_archive_id, archive_name)
dest_url = "formsemestre_list_archives?formsemestre_id=%s" % (formsemestre_id)
dest_url = url_for(
"notes.formsemestre_list_archives",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
if not dialog_confirmed:
return scu.confirm_dialog(
"""<h2>Confirmer la suppression de l'archive du %s ?</h2>
<p>La suppression sera définitive.</p>"""
% PVArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M"),
f"""<h2>Confirmer la suppression de l'archive du {
PVArchive.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M")
} ?</h2>
<p>La suppression sera définitive.</p>
""",
dest_url="",
cancel_url=dest_url,
parameters={
@ -608,4 +626,5 @@ def formsemestre_delete_archive(formsemestre_id, archive_name, dialog_confirmed=
)
PVArchive.delete_archive(archive_id)
return flask.redirect(dest_url + "&head_message=Archive%20supprimée")
flash("Archive supprimée")
return flask.redirect(dest_url)

View File

@ -1208,7 +1208,7 @@ def make_menu_autres_operations(
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": sco_permissions_check.can_validate_sem(formsemestre.id),
"enabled": formsemestre.can_edit_jury(),
},
{
"title": "Enregistrer note d'une UE externe",
@ -1217,7 +1217,7 @@ def make_menu_autres_operations(
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": sco_permissions_check.can_validate_sem(formsemestre.id)
"enabled": formsemestre.can_edit_jury()
and not formsemestre.formation.is_apc(),
},
{
@ -1227,7 +1227,7 @@ def make_menu_autres_operations(
"formsemestre_id": formsemestre.id,
"etudid": etud.id,
},
"enabled": sco_permissions_check.can_validate_sem(formsemestre.id),
"enabled": formsemestre.can_edit_jury(),
},
{
"title": "Éditer PV jury",

View File

@ -27,22 +27,21 @@
"""Exception handling
"""
from flask_login import current_user
# --- Exceptions
MSGPERMDENIED = "l'utilisateur %s n'a pas le droit d'effectuer cette operation"
class ScoException(Exception):
pass
"super classe de toutes les exceptions ScoDoc."
class InvalidNoteValue(ScoException):
pass
"Valeur note invalide. Usage interne saisie note."
class ScoValueError(ScoException):
"Exception avec page d'erreur utilisateur, et qui stoque dest_url"
# mal nommée: super classe de toutes les exceptions avec page
# d'erreur gentille.
def __init__(self, msg, dest_url=None):
super().__init__(msg)
self.dest_url = dest_url
@ -53,7 +52,9 @@ class ScoPermissionDenied(ScoValueError):
def __init__(self, msg=None, dest_url=None):
if msg is None:
msg = "Opération non autorisée !"
msg = f"""Opération non autorisée pour {
current_user.get_nomcomplet() if current_user else "?"
}. Pas la permission, ou objet verrouillé."""
super().__init__(msg, dest_url=dest_url)

View File

@ -431,17 +431,18 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
},
{
"title": "Saisie des décisions du jury",
"endpoint": "notes.formsemestre_saisie_jury",
"endpoint": "notes.formsemestre_recapcomplet",
"args": {
"formsemestre_id": formsemestre_id,
"mode_jury": 1,
},
"enabled": sco_permissions_check.can_validate_sem(formsemestre_id),
"enabled": formsemestre.can_edit_jury(),
},
{
"title": "Éditer les PV et archiver les résultats",
"endpoint": "notes.formsemestre_archive",
"args": {"formsemestre_id": formsemestre_id},
"enabled": sco_permissions_check.can_edit_pv(formsemestre_id),
"enabled": formsemestre.can_edit_pv(),
},
{
"title": "Documents archivés",

View File

@ -72,9 +72,9 @@ def formsemestre_validation_etud_form(
etudid=None, # one of etudid or etud_index is required
etud_index=None,
check=0, # opt: si true, propose juste une relecture du parcours
desturl=None,
dest_url=None,
sortcol=None,
readonly=True,
read_only=True,
):
"""Formulaire de validation des décisions de jury"""
formsemestre: FormSemestre = FormSemestre.query.filter_by(
@ -111,7 +111,7 @@ def formsemestre_validation_etud_form(
etud_index_prev = etud_index - 1
if etud_index_prev < 0:
etud_index_prev = None
if readonly:
if read_only:
check = True
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
@ -216,13 +216,13 @@ def formsemestre_validation_etud_form(
H.append(
formsemestre_recap_parcours_table(
Se, etudid, with_links=(check and not readonly)
Se, etudid, with_links=(check and not read_only)
)
)
if check:
if not desturl:
desturl = url_tableau
H.append(f'<ul><li><a href="{desturl}">Continuer</a></li></ul>')
if not dest_url:
dest_url = url_tableau
H.append(f'<ul><li><a href="{dest_url}">Continuer</a></li></ul>')
return "\n".join(H + footer)
@ -342,8 +342,8 @@ def formsemestre_validation_etud_form(
<input type="hidden" name="formsemestre_id" value="%s"/>"""
% (etudid, formsemestre_id)
)
if desturl:
H.append('<input type="hidden" name="desturl" value="%s"/>' % desturl)
if dest_url:
H.append('<input type="hidden" name="desturl" value="%s"/>' % dest_url)
if sortcol:
H.append('<input type="hidden" name="sortcol" value="%s"/>' % sortcol)

View File

@ -55,10 +55,6 @@ _SCO_PERMISSIONS = (
),
# 27 à 39 ... réservé pour "entreprises"
(1 << 40, "ScoEtudChangePhoto", "Modifier la photo d'un étudiant"),
# Api scodoc9
# XXX à revoir
# (1 << 42, "APIEditAllNotes", "API: Modifier toutes les notes"),
# (1 << 43, "APIAbsChange", "API: Saisir des absences"),
)

View File

@ -101,30 +101,7 @@ def can_edit_suivi():
return current_user.has_permission(Permission.ScoEtudChangeAdr)
def can_validate_sem(formsemestre_id):
"Vrai si utilisateur peut saisir decision de jury dans ce semestre"
from app.scodoc import sco_formsemestre
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
if not sem["etat"]:
return False # semestre verrouillé
return is_chef_or_diretud(sem)
def can_edit_pv(formsemestre_id):
"Vrai si utilisateur peut editer un PV de jury de ce semestre"
from app.scodoc import sco_formsemestre
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
if is_chef_or_diretud(sem):
return True
# Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr
# (ceci nous évite d'ajouter une permission Zope aux installations existantes)
return current_user.has_permission(Permission.ScoEtudChangeAdr)
def is_chef_or_diretud(sem):
def is_chef_or_diretud(sem): # remplacé par formsemestre.est_chef_or_diretud
"Vrai si utilisateur est admin, chef dept ou responsable du semestre"
if (
current_user.has_permission(Permission.ScoImplement)

View File

@ -1243,7 +1243,7 @@ class BasePreferences(object):
{
"initvalue": 0,
"title": "Afficher toutes les évaluations sur les bulletins",
"explanation": "y compris incomplètes ou futures (déconseillé, risque de publier des notes non définitives)",
"explanation": "y compris incomplètes ou futures (déconseillé, risque de publier des notes non définitives; n'affecte pas le calcul des moyennes)",
"input_type": "boolcheckbox",
"category": "bul",
"labels": ["non", "oui"],

View File

@ -516,18 +516,18 @@ def pvjury_table(
def formsemestre_pvjury(formsemestre_id, format="html", publish=True):
"""Page récapitulant les décisions de jury"""
# Bretelle provisoire pour BUT 9.3.0
# XXX TODO
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
is_apc = formsemestre.formation.is_apc()
if format == "html" and is_apc and formsemestre.semestre_id % 2 == 0:
from app.tables import jury_recap
return jury_recap.formsemestre_saisie_jury_but(
formsemestre, read_only=True, mode="recap"
if format == "html" and is_apc:
return redirect(
url_for(
"notes.formsemestre_recapcomplet",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
mode_jury=1,
)
)
# /XXX
footer = html_sco_header.sco_footer()
dpv = dict_pvjury(formsemestre_id, with_prev=True)

View File

@ -51,7 +51,6 @@ from app.scodoc import sco_evaluations
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_status
from app.scodoc import sco_permissions_check
from app.scodoc import sco_preferences
from app.tables.recap import TableRecap
from app.tables.jury_recap import TableJury
@ -95,17 +94,26 @@ def formsemestre_recapcomplet(
mode_jury = int(mode_jury)
xml_with_decisions = int(xml_with_decisions)
force_publishing = int(force_publishing)
data = _do_formsemestre_recapcomplet(
formsemestre_id,
format=tabformat,
mode_jury=mode_jury,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
selected_etudid=selected_etudid,
filename = scu.sanitize_filename(
f"""recap-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}"""
)
if is_file:
return data
return _formsemestre_recapcomplet_to_file(
formsemestre,
tabformat=tabformat,
filename=filename,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
)
table_html, table = _formsemestre_recapcomplet_to_html(
formsemestre,
filename=filename,
mode_jury=mode_jury,
tabformat=tabformat,
selected_etudid=selected_etudid,
)
H = [
html_sco_header.sco_header(
page_title=f"{formsemestre.sem_modalite()}: "
@ -131,64 +139,90 @@ def formsemestre_recapcomplet(
H.append(
'<select name="tabformat" onchange="document.f.submit()" class="noprint">'
)
for (format, label) in (
for (fmt, label) in (
("html", "Tableau"),
("evals", "Avec toutes les évaluations"),
("xlsx", "Excel (non formaté)"),
("xlsall", "Excel avec évaluations"),
("xml", "Bulletins XML (obsolète)"),
("json", "Bulletins JSON"),
):
if format == tabformat:
if fmt == tabformat:
selected = " selected"
else:
selected = ""
H.append(f'<option value="{format}"{selected}>{label}</option>')
H.append("</select>")
H.append(f'<option value="{fmt}"{selected}>{label}</option>')
H.append(
f"""&nbsp;(cliquer sur un nom pour afficher son bulletin ou <a class="stdlink"
f"""
</select>&nbsp;(cliquer sur un nom pour afficher son bulletin ou
<a class="stdlink"
href="{url_for('notes.formsemestre_bulletins_pdf',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
}">ici avoir le classeur papier</a>)
</form>
"""
)
H.append(data)
H.append(table_html) # La table
if len(formsemestre.inscriptions) > 0:
H.append("</form>")
H.append("""<div class="links_under_recap"><ul>""")
H.append(
f"""<p><a class="stdlink" href="{url_for('notes.formsemestre_pvjury',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
}">Voir les décisions du jury</a></p>"""
f"""<li><a class="stdlink" href="{url_for('notes.formsemestre_recapcomplet',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, mode_jury=1)
}">Décisions du jury</a>
</li>
"""
)
if sco_permissions_check.can_validate_sem(formsemestre_id):
H.append("<p>")
if formsemestre.can_edit_jury():
if mode_jury:
H.append(
f"""<p><a class="stdlink" href="{url_for('notes.formsemestre_validation_auto',
f"""<li><a class="stdlink" href="{url_for('notes.formsemestre_validation_auto',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
}">Calcul automatique des décisions du jury</a>
</p><a class="stdlink" href="{url_for('notes.formsemestre_jury_but_erase',
</li>
<li><a class="stdlink" href="{url_for('notes.formsemestre_jury_but_erase',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, only_one_sem=1)
}">Effacer <em>toutes</em> les décisions de jury du semestre</a>
<p>
</p>
}">Effacer <em>toutes</em> les décisions de jury (BUT) du semestre</a>
</li>
"""
)
else:
H.append(
f"""<a class="stdlink" href="{url_for('notes.formsemestre_recapcomplet',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, mode_jury=1)
}">Saisie des décisions du jury</a>"""
)
H.append("</p>")
H.append("</ul></div>")
if sco_preferences.get_preference("use_ue_coefs", formsemestre_id):
H.append(
"""
<p class="infop">utilise les coefficients d'UE pour calculer la moyenne générale.</p>
"""
)
if mode_jury and table and sum(table.freq_codes_annuels.values()) > 0:
H.append(
f"""
<div class="jury_stats">
<div>Nb d'étudiants avec décision annuelle:
{sum(table.freq_codes_annuels.values())} / {len(table)}
</div>
<div><b>Codes annuels octroyés:</b></div>
<table class="jury_stats_codes">
"""
)
for code in sorted(table.freq_codes_annuels.keys()):
H.append(
f"""<tr>
<td>{code}</td>
<td style="text-align:right">{table.freq_codes_annuels[code]}</td>
<td style="text-align:right">{
(100*table.freq_codes_annuels[code] / len(table)):2.1f}%
</td>
</tr>"""
)
H.append(
"""
</table>
</div>
"""
)
H.append(html_sco_header.sco_footer())
# HTML or binary data ?
if len(H) > 1:
@ -199,62 +233,69 @@ def formsemestre_recapcomplet(
return H
def _do_formsemestre_recapcomplet(
formsemestre_id=None,
format="html", # html, xml, xls, xlsall, json
xml_nodate=False, # format XML sans dates (sert pour debug cache: comparaison de XML)
def _formsemestre_recapcomplet_to_html(
formsemestre: FormSemestre,
tabformat="html", # "html" or "evals"
filename: str = "",
mode_jury=False, # saisie décisions jury
selected_etudid=None,
) -> tuple[str, TableRecap]:
"""Le tableau recap en html"""
if tabformat not in ("html", "evals"):
raise ScoValueError("invalid table format")
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
table_html, table = gen_formsemestre_recapcomplet_html_table(
formsemestre,
res,
include_evaluations=(tabformat == "evals"),
mode_jury=mode_jury,
filename=filename,
selected_etudid=selected_etudid,
)
return table_html, table
def _formsemestre_recapcomplet_to_file(
formsemestre: FormSemestre,
tabformat: str = "json", # xml, xls, xlsall, json
filename: str = "",
xml_nodate=False, # format XML sans dates (sert pour debug cache: comparaison de XML)
xml_with_decisions=False,
force_publishing=True,
selected_etudid=None,
):
"""Calcule et renvoie le tableau récapitulatif."""
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
filename = scu.sanitize_filename(
f"""recap-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}"""
)
if format == "html" or format == "evals":
if tabformat.startswith("xls"):
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
data = gen_formsemestre_recapcomplet_html(
formsemestre,
res,
include_evaluations=(format == "evals"),
mode_jury=mode_jury,
filename=filename,
selected_etudid=selected_etudid,
)
return data
elif format.startswith("xls"):
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
include_evaluations = format in {"xlsall", "csv "} # csv not supported anymore
if format != "csv":
format = "xlsx"
include_evaluations = tabformat in {
"xlsall",
"csv ",
} # csv not supported anymore
if tabformat != "csv":
tabformat = "xlsx"
data, filename = gen_formsemestre_recapcomplet_excel(
res,
include_evaluations=include_evaluations,
filename=filename,
)
return scu.send_file(data, filename=filename, mime=scu.get_mime_suffix(format))
elif format == "xml":
elif tabformat == "xml":
data = gen_formsemestre_recapcomplet_xml(
formsemestre_id,
formsemestre.id,
xml_nodate,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
)
return scu.send_file(data, filename=filename, suffix=scu.XML_SUFFIX)
elif format == "json":
elif tabformat == "json":
data = gen_formsemestre_recapcomplet_json(
formsemestre_id,
formsemestre.id,
xml_nodate=xml_nodate,
xml_with_decisions=xml_with_decisions,
force_publishing=force_publishing,
)
return scu.sendJSON(data, filename=filename)
raise ScoValueError(f"Format demandé invalide: {format}")
raise ScoValueError(f"Format demandé invalide: {tabformat}")
def gen_formsemestre_recapcomplet_xml(
@ -368,22 +409,26 @@ def formsemestres_bulletins(annee_scolaire):
return scu.sendJSON(js_list)
def gen_formsemestre_recapcomplet_html(
def gen_formsemestre_recapcomplet_html_table(
formsemestre: FormSemestre,
res: NotesTableCompat,
include_evaluations=False,
mode_jury=False,
filename="",
selected_etudid=None,
):
) -> tuple[str, TableRecap]:
"""Construit table recap pour le BUT
Cache le résultat pour le semestre (sauf en mode jury).
Note: on cache le HTML et non l'objet Table.
Si mode_jury, cache colonnes modules et affiche un lien vers la saisie de la décision de jury
Si mode_jury, occultera colonnes modules (en js)
et affiche un lien vers la saisie de la décision de jury
Return: data, filename
data est une chaine, le <div>...</div> incluant le tableau.
Return: html (str), table (None sauf en mode jury ou si pas cachée)
html est une chaine, le <div>...</div> incluant le tableau.
"""
table = None
table_html = None
if not (mode_jury or selected_etudid):
if include_evaluations:
@ -392,7 +437,7 @@ def gen_formsemestre_recapcomplet_html(
table_html = sco_cache.TableRecapCache.get(formsemestre.id)
# en mode jury ne cache pas la table html
if mode_jury or (table_html is None):
table_html = _gen_formsemestre_recapcomplet_html(
table = _gen_formsemestre_recapcomplet_table(
formsemestre,
res,
include_evaluations,
@ -400,48 +445,37 @@ def gen_formsemestre_recapcomplet_html(
filename,
selected_etudid=selected_etudid,
)
table_html = table.html()
if not mode_jury:
if include_evaluations:
sco_cache.TableRecapWithEvalsCache.set(formsemestre.id, table_html)
else:
sco_cache.TableRecapCache.set(formsemestre.id, table_html)
return table_html
return table_html, table
def _gen_formsemestre_recapcomplet_html(
def _gen_formsemestre_recapcomplet_table(
formsemestre: FormSemestre,
res: ResultatsSemestre,
include_evaluations=False,
mode_jury=False,
filename: str = "",
selected_etudid=None,
) -> str:
"""Génère le html"""
) -> TableRecap:
"""Construit la table récap."""
table_class = TableJury if mode_jury else TableRecap
table = table_class(
res,
convert_values=True,
include_evaluations=include_evaluations,
mode_jury=mode_jury,
read_only=not formsemestre.can_edit_jury(),
)
table.data["filename"] = filename
table.select_row(selected_etudid)
return f"""
<div class="table_recap">
{
'<div class="message">aucun étudiant !</div>'
if table.is_empty()
else table.html(
extra_classes=[
'table_recap',
'apc' if formsemestre.formation.is_apc() else 'classic',
'jury' if mode_jury else ''
])
}
</div>
"""
return table
def gen_formsemestre_recapcomplet_excel(

View File

@ -1,6 +1,7 @@
/*
* DataTables style for ScoDoc gen_tables
* generated using https://datatables.net/manual/styling/theme-creator
* and customized by hand
*/
/*
@ -374,9 +375,11 @@ table.dataTable td {
float: left;
}
.dataTables_wrapper .dataTables_filter {
float: right;
text-align: right;
.dataTables_wrapper div.dataTables_filter {
float: left;
text-align: left;
margin-left: 64px;
margin-top: 4px;
}
.dataTables_wrapper .dataTables_filter input {

View File

@ -35,7 +35,7 @@ h3 {
}
div#gtrcontent {
margin-bottom: 4ex;
margin-bottom: 16px;
}
.gtrcontent {
@ -4015,8 +4015,14 @@ div.table_recap {
background: linear-gradient(to bottom, rgb(51, 255, 0) 0%, lightgray 100%);
}
/* Non supproté par les navigateurs (en Fev. 2023)
.table_recap button:has(span a.clearreaload) {
}
*/
div.table_recap table.table_recap {
width: auto;
margin-left: 0px;
/* font-family: Consolas, monaco, monospace; */
}
@ -4344,6 +4350,9 @@ div.table_jury_but_links {
margin-bottom: 16px;
}
div.links_under_recap ul li {
padding-bottom: 8px;
}
/* ------------- Tableau stats jury BUT -------- */
table.jury_stats_codes {

View File

@ -8,11 +8,6 @@ $(function () {
"partition_aux", "partition_rangs", "admission",
"col_empty"
];
let mode_jury_but_bilan = $('table.table_recap').hasClass("table_jury_but_bilan");
if (mode_jury_but_bilan) {
// table bilan décisions: cache les notes
hidden_colums = hidden_colums.concat(["col_lien_saisie_but"]);
}
// Etat (tri des colonnes) de la table:
const url = new URL(document.URL);
@ -99,7 +94,7 @@ $(function () {
}
},
{
text: '<a title="Rétablir l\'affichage par défaut">&#10135;</a>',
text: '<a title="Rétablir l\'affichage par défaut" class="clearreload">&#128260;</a>',
action: function (e, dt, node, config) {
localStorage.clear();
console.log("cleared localStorage");
@ -124,7 +119,7 @@ $(function () {
// table jury: avec ou sans codes enregistrés
buttons.push(
{
text: '<span data-group="recorded_code">Code jurys</span>',
text: '<span data-group="recorded_code">Codes jury</span>',
action: toggle_col_but_visibility,
});
} else {
@ -165,6 +160,15 @@ $(function () {
);
}
}
// Boutons évaluations (si présentes)
if ($('table.table_recap').hasClass("with_evaluations")) {
buttons.push(
{
text: '<span data-group="eval">Évaluations</span>',
action: toggle_col_but_visibility,
}
);
}
// ------------- LA TABLE ---------
try {

View File

@ -114,7 +114,7 @@ class TableJury(TableRecap):
"jury_link",
"",
f"""{("&#10152; saisir" if a_saisir else "modifier")
if res.formsemestre.etat else "voir"} décisions""",
if not self.read_only else "voir"} décisions""",
group="col_jury_link",
classes=["fontred"] if a_saisir else [],
target=url_for(
@ -250,149 +250,3 @@ class RowJury(RowRecap):
# f"""{int(ects_valides)}""",
# "col_code_annee",
# )
def formsemestre_saisie_jury_but(
formsemestre: FormSemestre,
read_only: bool = False,
selected_etudid: int = None,
mode="jury",
) -> str:
"""formsemestre est un semestre PAIR
Si readonly, ne montre pas le lien "saisir la décision"
=> page html complète
Si mode == "recap", table recap des codes, sans liens de saisie.
"""
# pour chaque etud de res2 trié
# S1: UE1, ..., UEn
# S2: UE1, ..., UEn
#
# UE1_s1, UE1_s2, moy_rcue, UE2... , Nbrcue_validables, Nbrcue<8, passage_de_droit, valide_moitie_rcue
#
# Pour chaque etud de res2 trié
# DecisionsProposeesAnnee(etud, formsemestre2)
# Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur
# -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue, etc
if formsemestre.formation.referentiel_competence is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
table = TableJury(
res,
convert_values=True,
mode_jury=True,
read_only=read_only,
classes=[
"table_jury_but_bilan" if mode == "recap" else "",
"table_recap",
"apc",
"jury table_jury_but",
],
selected_row_id=selected_etudid,
)
if table.is_empty():
return (
'<div class="table_recap"><div class="message">aucun étudiant !</div></div>'
)
table.data["filename"] = scu.sanitize_filename(
f"""jury-but-{formsemestre.titre_num()}-{time.strftime("%Y-%m-%d")}"""
)
table_html = table.html()
H = [
html_sco_header.sco_header(
page_title=f"{formsemestre.sem_modalite()}: jury BUT",
no_side_bar=True,
init_qtip=True,
javascripts=["js/etud_info.js", "js/table_recap.js"],
),
sco_formsemestre_status.formsemestre_status_head(
formsemestre_id=formsemestre.id
),
]
if mode == "recap":
H.append(
f"""<h3>Décisions de jury enregistrées pour les étudiants de ce semestre</h3>
<div class="table_jury_but_links">
<div>
<ul>
<li><a href="{url_for(
"notes.pvjury_table_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}" class="stdlink">Tableau PV de jury</a>
</li>
<li><a href="{url_for(
"notes.formsemestre_lettres_individuelles",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}" class="stdlink">Courriers individuels (classeur pdf)</a>
</li>
</div>
</div>
"""
)
H.append(
f"""
<div class="table_recap">
{table_html}
</div>
<div class="table_jury_but_links">
"""
)
if (mode == "recap") and not read_only:
H.append(
f"""
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_saisie_jury",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}">Saisie des décisions du jury</a>
</p>"""
)
else:
H.append(
f"""
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_validation_auto_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}">Calcul automatique des décisions du jury</a>
</p>
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_jury_but_recap",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}">Tableau récapitulatif des décisions du jury</a>
</p>
"""
)
H.append(
f"""
</div>
<div class="jury_stats">
<div>Nb d'étudiants avec décision annuelle:
{sum(table.freq_codes_annuels.values())} / {len(table)}
</div>
<div><b>Codes annuels octroyés:</b></div>
<table class="jury_stats_codes">
"""
)
for code in sorted(table.freq_codes_annuels.keys()):
H.append(
f"""<tr>
<td>{code}</td>
<td style="text-align:right">{table.freq_codes_annuels[code]}</td>
<td style="text-align:right">{
(100*table.freq_codes_annuels[code] / len(table)):2.1f}%
</td>
</tr>"""
)
H.append(
f"""
</table>
</div>
{html_sco_header.sco_footer()}
"""
)
return "\n".join(H)

View File

@ -54,6 +54,7 @@ class TableRecap(tb.Table):
mode_jury=False,
row_class=None,
finalize=True,
read_only: bool = True,
**kwargs,
):
self.rows: list["RowRecap"] = [] # juste pour que VSCode nous aide sur .rows
@ -61,7 +62,7 @@ class TableRecap(tb.Table):
self.res = res
self.include_evaluations = include_evaluations
self.mode_jury = mode_jury
self.read_only = read_only # utilisé seulement dans sous-classes
parcours = res.formsemestre.formation.get_parcours()
self.barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE
self.barre_valid_ue = parcours.NOTES_BARRE_VALID_UE
@ -103,7 +104,7 @@ class TableRecap(tb.Table):
self.add_cursus()
self.add_admissions()
# tri par rang croissant
# Tri par rang croissant
if not res.formsemestre.block_moyenne_generale:
self.sort_rows(key=lambda row: row.rang_order)
else:
@ -361,6 +362,7 @@ class TableRecap(tb.Table):
pour tous les étudiants de la table.
Les colonnes ont la classe css "evaluation"
"""
self.group_titles["eval"] = "Évaluations"
# nouvelle ligne pour description évaluations:
row_descr_eval = tb.BottomRow(
self,
@ -382,7 +384,7 @@ class TableRecap(tb.Table):
for e in evals:
col_id = f"eval_{e.id}"
title = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
col_classes = ["evaluation"]
col_classes = []
if first_eval:
col_classes.append("first")
elif first_eval_of_mod:
@ -408,13 +410,15 @@ class TableRecap(tb.Table):
"EXC": "exc",
}.get(content, "")
]
row.add_cell(col_id, title, content, "", classes=classes)
row.add_cell(
col_id, title, content, group="eval", classes=classes
)
else:
row.add_cell(
col_id,
title,
"ni",
"",
group="eval",
classes=col_classes + ["non_inscrit"],
)
@ -505,6 +509,24 @@ class TableRecap(tb.Table):
group="cursus",
)
def html(self, extra_classes: list[str] = None) -> str:
"""HTML: pour les tables recap, un div au contenu variable"""
return f"""
<div class="table_recap">
{
'<div class="message">aucun étudiant !</div>'
if self.is_empty()
else super().html(
extra_classes=[
"table_recap",
"apc" if self.res.formsemestre.formation.is_apc() else "classic",
"jury" if self.mode_jury else "",
"with_evaluations" if self.include_evaluations else "",
])
}
</div>
"""
class RowRecap(tb.Row):
"Ligne de la table recap, pour un étudiant"

View File

@ -59,7 +59,7 @@ from app.models.formsemestre import FormSemestreUEComputationExpr
from app.models.moduleimpls import ModuleImpl
from app.models.modules import Module
from app.models.ues import DispenseUE, UniteEns
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.scodoc.sco_exceptions import ScoFormationConflict, ScoPermissionDenied
from app.tables import jury_recap
from app.views import notes_bp as bp
@ -2257,8 +2257,8 @@ def formsemestre_validation_etud_form(
sortcol=None,
):
"Formulaire choix jury pour un étudiant"
readonly = not sco_permissions_check.can_validate_sem(formsemestre_id)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
read_only = not formsemestre.can_edit_jury()
if formsemestre.formation.is_apc():
return redirect(
url_for(
@ -2273,8 +2273,8 @@ def formsemestre_validation_etud_form(
etudid=etudid,
etud_index=etud_index,
check=check,
readonly=readonly,
desturl=desturl,
read_only=read_only,
dest_url=desturl,
sortcol=sortcol,
)
@ -2291,10 +2291,14 @@ def formsemestre_validation_etud(
sortcol=None,
):
"Enregistre choix jury pour un étudiant"
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(),
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
return sco_formsemestre_validation.formsemestre_validation_etud(
@ -2321,10 +2325,14 @@ def formsemestre_validation_etud_manu(
sortcol=None,
):
"Enregistre choix jury pour un étudiant"
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(),
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
return sco_formsemestre_validation.formsemestre_validation_etud_manu(
@ -2364,7 +2372,7 @@ def formsemestre_validation_but(
etudid = int(etudid)
except ValueError:
abort(404, "invalid etudid")
read_only = not sco_permissions_check.can_validate_sem(formsemestre_id)
read_only = not formsemestre.can_edit_jury()
# --- Navigation
prev_lnk = (
@ -2391,9 +2399,13 @@ def formsemestre_validation_but(
{prev_lnk}
</div>
<div class="back_list">
<a href="{url_for(
"notes.formsemestre_saisie_jury", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id, selected_etudid=etud.id
<a href="{
url_for(
"notes.formsemestre_recapcomplet",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
mode_jury=1,
selected_etudid=etud.id
)}" class="stdlink">retour à la liste</a>
</div>
<div class="next">
@ -2583,15 +2595,16 @@ def formsemestre_validation_but(
@permission_required(Permission.ScoView)
def formsemestre_validation_auto_but(formsemestre_id: int = None):
"Saisie automatique des décisions de jury BUT"
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message=f"<p>Opération non autorisée pour {current_user}</h2>",
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
),
)
)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
form = jury_but_forms.FormSemestreValidationAutoBUTForm()
if request.method == "POST":
@ -2602,9 +2615,10 @@ def formsemestre_validation_auto_but(formsemestre_id: int = None):
flash(f"Décisions enregistrées ({nb_etud_modif} étudiants modifiés)")
return redirect(
url_for(
"notes.formsemestre_saisie_jury",
"notes.formsemestre_recapcomplet",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
mode_jury=1,
)
)
return render_template(
@ -2621,11 +2635,16 @@ def formsemestre_validation_auto_but(formsemestre_id: int = None):
@scodoc7func
def formsemestre_validate_previous_ue(formsemestre_id, etudid=None):
"Form. saisie UE validée hors ScoDoc"
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(),
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
return sco_formsemestre_validation.formsemestre_validate_previous_ue(
formsemestre_id, etudid
)
@ -2645,11 +2664,16 @@ sco_publish(
@scodoc7func
def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid=None):
"Form. edition UE semestre extérieur"
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(),
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
return sco_formsemestre_exterieurs.formsemestre_ext_edit_ue_validations(
formsemestre_id, etudid
)
@ -2668,11 +2692,16 @@ sco_publish(
@scodoc7func
def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id):
"""Suppress a validation (ue_id, etudid) and redirect to formsemestre"""
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(),
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
return sco_formsemestre_validation.etud_ue_suppress_validation(
etudid, formsemestre_id, ue_id
)
@ -2684,14 +2713,18 @@ def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id):
@scodoc7func
def formsemestre_validation_auto(formsemestre_id):
"Formulaire saisie automatisee des decisions d'un semestre"
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(),
)
formsemestre: FormSemestre = FormSemestre.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404()
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
if formsemestre.formation.is_apc():
return redirect(
url_for(
@ -2709,10 +2742,14 @@ def formsemestre_validation_auto(formsemestre_id):
@scodoc7func
def do_formsemestre_validation_auto(formsemestre_id):
"Formulaire saisie automatisee des decisions d'un semestre"
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(),
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
return sco_formsemestre_validation.do_formsemestre_validation_auto(formsemestre_id)
@ -2726,13 +2763,16 @@ def formsemestre_validation_suppress_etud(
formsemestre_id, etudid, dialog_confirmed=False
):
"""Suppression des décisions de jury pour un étudiant."""
if not sco_permissions_check.can_validate_sem(formsemestre_id):
return scu.confirm_dialog(
message="<p>Opération non autorisée pour %s</h2>" % current_user,
dest_url=scu.ScoURL(),
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
etud = Identite.query.get_or_404(etudid)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
if formsemestre.formation.is_apc():
next_url = url_for(
"scolar.ficheEtud",
@ -2800,15 +2840,8 @@ sco_publish("/pvjury_table_but", jury_but_pv.pvjury_table_but, Permission.ScoVie
@scodoc7func
def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None):
"""Page de saisie: liste des étudiants et lien vers page jury
en semestres pairs de BUT, table spécifique avec l'année
sinon, redirect vers page recap en mode jury
"""
read_only = not sco_permissions_check.can_validate_sem(formsemestre_id)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
if formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0:
return jury_recap.formsemestre_saisie_jury_but(
formsemestre, read_only, selected_etudid=selected_etudid
)
return redirect(
url_for(
"notes.formsemestre_recapcomplet",
@ -2819,23 +2852,6 @@ def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None):
)
@bp.route("/formsemestre_jury_but_recap")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def formsemestre_jury_but_recap(formsemestre_id: int, selected_etudid: int = None):
"""Tableau affichage des codes"""
read_only = not sco_permissions_check.can_validate_sem(formsemestre_id)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not (formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0):
raise ScoValueError(
"formsemestre_jury_but_recap: réservé aux semestres pairs de BUT"
)
return jury_recap.formsemestre_saisie_jury_but(
formsemestre, read_only=read_only, selected_etudid=selected_etudid, mode="recap"
)
@bp.route(
"/formsemestre_jury_but_erase/<int:formsemestre_id>",
methods=["GET", "POST"],
@ -2855,18 +2871,25 @@ def formsemestre_jury_but_erase(
Si l'étudiant n'est pas spécifié, efface les décisions de tous les inscrits.
"""
only_one_sem = int(request.args.get("only_one_sem") or False)
if not sco_permissions_check.can_validate_sem(formsemestre_id):
raise ScoValueError("opération non autorisée")
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
if not formsemestre.can_edit_jury():
raise ScoPermissionDenied(
dest_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
if not formsemestre.formation.is_apc():
raise ScoValueError("semestre non BUT")
if etudid is None:
etud = None
etuds = formsemestre.get_inscrits(include_demdef=True)
dest_url = url_for(
"notes.formsemestre_saisie_jury",
"notes.formsemestre_recapcomplet",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
mode_jury=1,
)
else:
etud: Identite = Identite.query.get_or_404(etudid)