Merge branch 'master' into prepajury9

This commit is contained in:
Jean-Marie Place 2024-06-24 06:44:42 +02:00
commit 4403da99e9
14 changed files with 194 additions and 127 deletions

View File

@ -226,50 +226,27 @@ def dept_formsemestres_ids_by_id(dept_id: int):
@bp.route("/departement/<string:acronym>/formsemestres_courants")
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_courants(acronym: str):
def dept_formsemestres_courants(acronym: str = "", dept_id: int | None = None):
"""
Liste des semestres actifs d'un département d'acronyme donné
Liste les semestres du département indiqué (par son acronyme ou son id)
contenant la date courante, ou à défaut celle indiquée en argument
(au format ISO).
QUERY
-----
date_courante:<string:date_courante>
Exemple de résultat :
[
{
"titre": "master machine info",
"gestion_semestrielle": false,
"scodoc7_id": null,
"date_debut": "01/09/2021",
"bul_bgcolor": null,
"date_fin": "15/12/2022",
"resp_can_edit": false,
"dept_id": 1,
"etat": true,
"resp_can_change_ens": false,
"id": 1,
"modalite": "FI",
"ens_can_edit_eval": false,
"formation_id": 1,
"gestion_compensation": false,
"elt_sem_apo": null,
"semestre_id": 1,
"bul_hide_xml": false,
"elt_annee_apo": null,
"block_moyennes": false,
"formsemestre_id": 1,
"titre_num": "master machine info semestre 1",
"date_debut_iso": "2021-09-01",
"date_fin_iso": "2022-12-15",
"responsables": [
3,
2
]
},
...
]
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
dept = (
Departement.query.filter_by(acronym=acronym).first_or_404()
if acronym
else Departement.query.get_or_404(dept_id)
)
date_courante = request.args.get("date_courante")
date_courante = datetime.fromisoformat(date_courante) if date_courante else None
return [
@ -278,29 +255,3 @@ def dept_formsemestres_courants(acronym: str):
dept, date_courante
)
]
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
@login_required
@scodoc
@permission_required(Permission.ScoView)
@as_json
def dept_formsemestres_courants_by_id(dept_id: int):
"""
Liste des semestres actifs d'un département d'id donné
"""
# Le département, spécifié par un id ou un acronyme
dept = Departement.query.get_or_404(dept_id)
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = db.func.current_date()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
return [d.to_dict_api() for d in formsemestres]

View File

@ -89,12 +89,17 @@ def _get_etud_by_code(
@scodoc
@permission_required(Permission.ScoView)
@as_json
def etudiants_courants(long=False):
def etudiants_courants(long: bool = False):
"""
La liste des étudiants des semestres "courants" (tous départements)
(date du jour comprise dans la période couverte par le sem.)
dans lesquels l'utilisateur a la permission ScoView
(donc tous si le dept du rôle est None).
La liste des étudiants des semestres "courants".
Considère tous les départements dans lesquels l'utilisateur a la
permission `ScoView` (donc tous si le dépt. du rôle est `None`),
et les formsemestres contenant la date courante,
ou à défaut celle indiquée en argument (au format ISO).
QUERY
-----
date_courante:<string:date_courante>
Exemple de résultat :
[
@ -183,8 +188,13 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
@permission_required(Permission.ScoView)
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne la photo de l'étudiant
correspondant ou un placeholder si non existant.
Retourne la photo de l'étudiant ou un placeholder si non existant.
Le paramètre `size` peut prendre la valeur `small` (taille réduite, hauteur
environ 90 pixels) ou `orig` (défaut, image de la taille originale).
QUERY
-----
size:<string:size>
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant

View File

@ -108,6 +108,17 @@ def formsemestres_query():
dept_id : id du département
ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit.
etat: 0 si verrouillé, 1 sinon
QUERY
-----
etape_apo:<string:etape_apo>
annee_scolaire:<string:annee_scolaire>
dept_acronym:<string:dept_acronym>
dept_id:<int:dept_id>
etat:<int:etat>
nip:<string:nip>
ine:<string:ine>
"""
etape_apo = request.args.get("etape_apo")
annee_scolaire = request.args.get("annee_scolaire")
@ -376,7 +387,16 @@ def formsemestre_programme(formsemestre_id: int):
def formsemestre_etudiants(
formsemestre_id: int, with_query: bool = False, long: bool = False
):
"""Étudiants d'un formsemestre."""
"""Étudiants d'un formsemestre.
Si l'état est spécifié, ne renvoie que les inscrits (`I`), les
démissionnaires (`D`) ou les défaillants (`DEF`)
QUERY
-----
etat:<string:etat>
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
@ -531,6 +551,13 @@ def etat_evals(formsemestre_id: int):
def formsemestre_resultat(formsemestre_id: int):
"""Tableau récapitulatif des résultats
Pour chaque étudiant, son état, ses groupes, ses moyennes d'UE et de modules.
Si `format=raw`, ne converti pas les valeurs.
QUERY
-----
format:<string:format>
"""
format_spec = request.args.get("format", None)
if format_spec is not None and format_spec != "raw":
@ -623,6 +650,12 @@ def formsemestre_edt(formsemestre_id: int):
group_ids permet de filtrer sur les groupes ScoDoc.
show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
QUERY
-----
group_ids:<string:group_ids>
show_modules_titles:<bool:show_modules_titles>
"""
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:

View File

@ -151,7 +151,13 @@ def etud_in_group(group_id: int):
@permission_required(Permission.ScoView)
@as_json
def etud_in_group_query(group_id: int):
"""Étudiants du groupe, filtrés par état"""
"""Étudiants du groupe, filtrés par état (aucun, I, D, DEF)
QUERY
-----
etat:<string:etat>
"""
etat = request.args.get("etat")
if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}:
return json_error(API_CLIENT_ERROR, "etat: valeur invalide")

View File

@ -59,6 +59,13 @@ def users_info_query():
Seuls les utilisateurs "accessibles" (selon les permissions) sont retournés.
Si accès via API web, le département de l'URL est ignoré, seules
les permissions de l'utilisateur sont prises en compte.
QUERY
-----
active:<bool:active>
departement:<string:departement>
starts_with:<string:starts_with>
"""
query = User.query
active = request.args.get("active")

View File

@ -35,9 +35,9 @@ def after_cas_login():
if user.cas_allow_login:
current_app.logger.info(f"CAS: login {user.user_name}")
if login_user(user):
flask.session[
"scodoc_cas_login_date"
] = datetime.datetime.now().isoformat()
flask.session["scodoc_cas_login_date"] = (
datetime.datetime.now().isoformat()
)
user.cas_last_login = datetime.datetime.utcnow()
if flask.session.get("CAS_EDT_ID"):
# essaie de récupérer l'edt_id s'il est présent
@ -45,8 +45,10 @@ def after_cas_login():
# via l'expression `cas_edt_id_from_xml_regexp`
# voir flask_cas.routing
edt_id = flask.session.get("CAS_EDT_ID")
current_app.logger.info(f"""after_cas_login: storing edt_id for {
user.user_name}: '{edt_id}'""")
current_app.logger.info(
f"""after_cas_login: storing edt_id for {
user.user_name}: '{edt_id}'"""
)
user.edt_id = edt_id
db.session.add(user)
db.session.commit()
@ -55,12 +57,17 @@ def after_cas_login():
current_app.logger.info(
f"CAS login denied for {user.user_name} (not allowed to use CAS)"
)
else:
else: # pas d'utilisateur ScoDoc ou bien compte inactif
current_app.logger.info(
f"""CAS login denied for {
user.user_name if user else ""
} cas_id={cas_id} (unknown or inactive)"""
)
if ScoDocSiteConfig.is_cas_forced():
# Dans ce cas, pas de redirect vers la page de login pour éviter de boucler
raise ScoValueError(
"compte ScoDoc inexistant ou inactif pour cet utilisateur CAS"
)
else:
current_app.logger.info(
f"""CAS attribute '{ScoDocSiteConfig.get("cas_attribute_id")}' not found !

View File

@ -691,9 +691,13 @@ def module_edit(
str(parcour.id) for parcour in ref_comp.parcours
]
+ ["-1"],
"explanation": """Parcours dans lesquels est utilisé ce module.<br>
Attention: si le module ne doit pas avoir les mêmes coefficients suivant le parcours,
il faut en créer plusieurs versions, car dans ScoDoc chaque module a ses coefficients.""",
"explanation": """Parcours dans lesquels est utilisé ce module (inutile
hors BUT, pour les modules standards et dans les UEs de bonus).
<br>
Attention: si le module ne doit pas avoir les mêmes coefficients suivant
le parcours, il faut en créer plusieurs versions, car dans ScoDoc chaque
module a ses coefficients.
""",
},
)
]

View File

@ -564,7 +564,7 @@ def fiche_etud(etudid=None):
%(etat_civil)s
<span>%(email_link)s</span>
</td><td class="photocell">
<a href="etud_photo_orig_page?etudid=%(etudid)s">%(etudfoto)s</a>
<a href="etud_photo_orig_page/%(etudid)s">%(etudfoto)s</a>
</td></tr></table>
"""
+ situation_template

View File

@ -931,11 +931,12 @@ def saisie_notes_tableur(evaluation_id, group_ids=()):
return "\n".join(H)
def feuille_saisie_notes(evaluation_id, group_ids=[]):
def feuille_saisie_notes(evaluation_id, group_ids: list[int] = None):
"""Document Excel pour saisie notes dans l'évaluation et les groupes indiqués"""
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if not evaluation:
raise ScoValueError("invalid evaluation_id")
group_ids = group_ids or []
modimpl = evaluation.moduleimpl
formsemestre = modimpl.formsemestre
mod_responsable = sco_users.user_info(modimpl.responsable_id)
@ -950,7 +951,8 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
if evaluation.date_debut
else "(sans date)"
)
eval_titre = f"""{evaluation.description if evaluation.description else "évaluation"} {date_str}"""
eval_titre = f"""{evaluation.description if evaluation.description else "évaluation"
} {date_str}"""
description = f"""{eval_titre} en {evaluation.moduleimpl.module.abbrev or ""} ({
evaluation.moduleimpl.module.code
@ -986,7 +988,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
rows.append(
[
str(etudid),
e["nom"].upper(),
e.get("nom_disp", "") or e.get("nom_usuel", "") or e["nom"],
e["prenom"].lower().capitalize(),
e["inscr"]["etat"],
grc,
@ -1206,7 +1208,7 @@ def _get_sorted_etuds(evaluation: Evaluation, etudids: list, formsemestre_id: in
def _form_saisie_notes(
evaluation: Evaluation, modimpl: ModuleImpl, groups_infos, destination=""
):
"""Formulaire HTML saisie des notes dans l'évaluation E du moduleimpl M
"""Formulaire HTML saisie des notes dans l'évaluation du moduleimpl
pour les groupes indiqués.
On charge tous les étudiants, ne seront montrés que ceux

View File

@ -1018,23 +1018,21 @@ sco_publish("/get_photo_image", sco_photos.get_photo_image, Permission.ScoView)
sco_publish("/etud_photo_html", sco_photos.etud_photo_html, Permission.ScoView)
@bp.route("/etud_photo_orig_page")
@bp.route("/etud_photo_orig_page/<int:etudid>")
@scodoc
@permission_required(Permission.ScoView)
@scodoc7func
def etud_photo_orig_page(etudid=None):
def etud_photo_orig_page(etudid):
"Page with photo in orig. size"
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
H = [
html_sco_header.sco_header(page_title=etud["nomprenom"]),
"<h2>%s</h2>" % etud["nomprenom"],
'<div><a href="%s">'
% url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid),
sco_photos.etud_photo_orig_html(etud),
"</a></div>",
html_sco_header.sco_footer(),
]
return "\n".join(H)
etud = Identite.get_etud(etudid)
return f"""{
html_sco_header.sco_header(etudid=etud.id, page_title=etud.nomprenom)
}
<h2>{etud.nomprenom}</h2>
<div>
<a href="{etud.url_fiche()}">{etud.photo_html(size='orig')}</a>
</div>
{html_sco_header.sco_footer()}
"""
@bp.route("/form_change_photo", methods=["GET", "POST"])

View File

@ -1,7 +1,7 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
SCOVERSION = "9.6.980"
SCOVERSION = "9.6.981"
SCONAME = "ScoDoc"

View File

@ -280,3 +280,19 @@ def test_semestres_courant(api_headers):
assert len(result_a) > 0
sem = result_a[0]
assert verify_fields(sem, FORMSEMESTRE_FIELDS) is True
# accès avec id incorrect
r = requests.get(
f"{API_URL}/departement/id/bad/formsemestres_courants?date_courante=2022-07-01",
headers=api_headers,
verify=CHECK_CERTIFICATE,
timeout=scu.SCO_TEST_API_TIMEOUT,
)
assert r.status_code == 404
r = requests.get(
f"{API_URL}/departement/id/-1/formsemestres_courants?date_courante=2022-07-01",
headers=api_headers,
verify=CHECK_CERTIFICATE,
timeout=scu.SCO_TEST_API_TIMEOUT,
)
assert r.status_code == 404

View File

@ -66,6 +66,7 @@ def test_permissions(api_headers):
"assiduite_id": 1,
"justif_id": 1,
"etudids": "1",
"ue_id": 1,
}
# Arguments spécifiques pour certaines routes
# par défaut, on passe tous les arguments de all_args

View File

@ -172,7 +172,7 @@ class Token:
sub_group = ET.Element("g")
# On décale l'élément de la query vers la droite par rapport à l'élément parent
translate_x = x_offset + x_step
translate_x = x_offset + _get_group_width(group) + x_step // 2
# création élément (param)
param_el = _create_svg_element(key, COLORS.BLUE)
@ -192,7 +192,7 @@ class Token:
# création élément (=)
equal_el = _create_svg_element("=", COLORS.GREY)
# On met à jour le décalage en fonction de l'élément précédent
translate_x = x_offset + x_step + _get_element_width(param_el)
translate_x += _get_element_width(param_el)
equal_el.set(
"transform",
f"translate({translate_x}, {query_y_offset})",
@ -202,11 +202,7 @@ class Token:
# création élément (value)
value_el = _create_svg_element(value, COLORS.GREEN)
# On met à jour le décalage en fonction des éléments précédents
translate_x = (
x_offset
+ x_step
+ sum(_get_element_width(el) for el in [param_el, equal_el])
)
translate_x += _get_element_width(equal_el)
value_el.set(
"transform",
f"translate({translate_x}, {query_y_offset})",
@ -214,16 +210,13 @@ class Token:
sub_group.append(value_el)
# Si il y a qu'un seul élément dans la query, on ne met pas de `&`
if len(self.query) == 1:
query_sub_element.append(sub_group)
continue
# création élément (&)
ampersand_group = _create_svg_element("&", "rgb(224,224,224)")
# On met à jour le décalage en fonction des éléments précédents
translate_x = (
x_offset
+ x_step
+ sum(_get_element_width(el) for el in [param_el, equal_el, value_el])
)
translate_x += _get_element_width(value_el)
ampersand_group.set(
"transform",
f"translate({translate_x}, {query_y_offset})",
@ -466,6 +459,10 @@ def gen_api_map(app, endpoint_start="api"):
# On positionne le token courant sur le token racine
current_token = api_map
# Récupération de la fonction associée à la route
func = app.view_functions[rule.endpoint]
func_name = parse_doc_name(func.__doc__ or "") or func.__name__
# Pour chaque segment de la route
for i, segment in enumerate(segments):
# On cherche si le segment est déjà un enfant du token courant
@ -473,7 +470,6 @@ def gen_api_map(app, endpoint_start="api"):
# Si ce n'est pas le cas on crée un nouveau token et on l'ajoute comme enfant
if child is None:
func = app.view_functions[rule.endpoint]
# Si c'est le dernier segment, on marque le token comme une leaf
# On utilise force_leaf car il est possible que le token ne soit que
# momentanément une leaf
@ -499,7 +495,7 @@ def gen_api_map(app, endpoint_start="api"):
# On ajoute le token comme enfant du token courant
# en donnant la méthode et le nom de la fonction associée
child.func_name = func.__name__
child.func_name = func_name
method: str = "POST" if "POST" in rule.methods else "GET"
child.method = method
current_token.add_child(child)
@ -607,6 +603,9 @@ def generate_svg(element, fname):
)
ET.SubElement(marker, "polygon", {"points": "0 0, 10 3.5, 0 7"})
# Ajoute un décalage vertical pour avoir un peu de padding en haut
element.set("transform", "translate(0, 10)")
# Ajout de l'élément principal à l'élément racine
svg.append(element)
@ -615,6 +614,50 @@ def generate_svg(element, fname):
tree.write(fname, encoding="utf-8", xml_declaration=True)
def _get_doc_lines(keyword, doc) -> list[str]:
"""
Renvoie les lignes de la doc qui suivent le mot clé keyword
La doc doit contenir des lignes de la forme:
KEYWORD
-------
...
"""
# Récupérer les lignes de la doc
lines = [line.strip() for line in doc.split("\n")]
# On cherche la ligne "KEYWORD" et on vérifie que la ligne suivante est "-----"
# Si ce n'est pas le cas, on renvoie un dictionnaire vide
try:
kw_index = lines.index(keyword)
kw_line = "-" * len(keyword)
if lines[kw_index + 1] != kw_line:
return []
except ValueError:
return []
# On récupère les lignes de la doc qui correspondent au keyword (enfin on espère)
kw_lines = lines[kw_index + 2 :]
return kw_lines
def parse_doc_name(doc):
"""
renvoie le nom de la route à partir de la docstring
La doc doit contenir des lignes de la forme:
DOC_ANCHOR
----------
nom_de_la_route
Il ne peut y avoir qu'une seule ligne suivant -----
"""
name_lines: list[str] = _get_doc_lines("DOC_ANCHOR", doc)
return name_lines[0] if name_lines else None
def parse_query_doc(doc):
"""
renvoie un dictionnaire {param: <type:nom_param>} (ex: {assiduite_id : <int:assiduite_id>})
@ -630,18 +673,7 @@ def parse_query_doc(doc):
Dès qu'une ligne ne respecte pas ce format (voir regex dans la fonction), on arrête de parser
Attention, la ligne ----- doit être collée contre QUERY et contre le premier paramètre
"""
# Récupérer les lignes de la doc
lines = [line.strip() for line in doc.split("\n")]
# On cherche la ligne "QUERY" et on vérifie que la ligne suivante est "-----"
# Si ce n'est pas le cas, on renvoie un dictionnaire vide
try:
query_index = lines.index("QUERY")
if lines[query_index + 1] != "-----":
return {}
except ValueError:
return {}
# On récupère les lignes de la doc qui correspondent à la query (enfin on espère)
query_lines = lines[query_index + 2 :]
query_lines: list[str] = _get_doc_lines("QUERY", doc)
query = {}
regex = re.compile(r"^(\w+):(<.+>)$")