Compare commits

...

7 Commits

12 changed files with 318 additions and 36 deletions

View File

@ -21,6 +21,7 @@ from app.api import api_web_bp, get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.models import (
Assiduite,
Evaluation,
FormSemestre,
Identite,
ModuleImpl,
@ -282,6 +283,7 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
404,
message="étudiant inconnu",
)
# Récupération des assiduités de l'étudiant
assiduites_query: Query = etud.assiduites
@ -304,6 +306,89 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
return data_set
@bp.route("/assiduites/<int:etudid>/evaluations")
@api_web_bp.route("/assiduites/<int:etudid>/evaluations")
# etudid
@bp.route("/assiduites/etudid/<int:etudid>/evaluations")
@api_web_bp.route("/assiduites/etudid/<int:etudid>/evaluations")
# ine
@bp.route("/assiduites/ine/<ine>/evaluations")
@api_web_bp.route("/assiduites/ine/<ine>/evaluations")
# nip
@bp.route("/assiduites/nip/<nip>/evaluations")
@api_web_bp.route("/assiduites/nip/<nip>/evaluations")
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def assiduites_evaluations(etudid: int = None, nip=None, ine=None):
"""
Retourne la liste de toutes les évaluations de l'étudiant
Pour chaque évaluation, retourne la liste des objets assiduités
sur la plage de l'évaluation
Présentation du retour :
[
{
"evaluation_id": 1234,
"assiduites": [
{
"assiduite_id":1234,
...
},
]
}
]
"""
# Récupération de l'étudiant
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
# Récupération des évaluations et des assidiutés
etud_evaluations_assiduites: list[dict] = scass.get_etud_evaluations_assiduites(
etud
)
return etud_evaluations_assiduites
@api_web_bp.route("/evaluation/<int:evaluation_id>/assiduites")
@bp.route("/evaluation/<int:evaluation_id>/assiduites")
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def evaluation_assiduites(evaluation_id):
"""
Retourne les objets assiduités de chaque étudiant sur la plage de l'évaluation
Présentation du retour :
{
"<etudid>" : [
{
"assiduite_id":1234,
...
},
]
}
"""
# Récupération de l'évaluation
evaluation: Evaluation = Evaluation.get_evaluation(evaluation_id)
evaluation_assiduites_par_etudid: dict[int, list[Assiduite]] = {}
for assi in scass.get_evaluation_assiduites(evaluation):
etudid: str = str(assi.etudid)
etud_assiduites = evaluation_assiduites_par_etudid.get(etudid, [])
etud_assiduites.append(assi.to_dict(format_api=True))
evaluation_assiduites_par_etudid[etudid] = etud_assiduites
return evaluation_assiduites_par_etudid
@bp.route("/assiduites/group/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True})
@login_required

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

@ -10,6 +10,7 @@ from flask_sqlalchemy.query import Query
from app import log, db, set_sco_dept
from app.models import (
Evaluation,
Identite,
FormSemestre,
FormSemestreInscription,
@ -731,6 +732,95 @@ def create_absence_billet(
return calculator.to_dict()["demi"]
def get_evaluation_assiduites(evaluation: Evaluation) -> Query:
"""
Renvoie une query d'assiduité en fonction des étudiants inscrits à l'évaluation
et de la date de l'évaluation.
Attention : Si l'évaluation n'a pas de date, renvoie None
"""
# Evaluation sans date
if evaluation.date_debut is None:
return None
# Récupération des étudiants inscrits à l'évaluation
etuds: Query = Identite.query.join(
ModuleImplInscription, Identite.id == ModuleImplInscription.etudid
).filter(ModuleImplInscription.moduleimpl_id == evaluation.moduleimpl_id)
etudids: list[int] = [etud.id for etud in etuds]
# Récupération des assiduités des étudiants inscrits à l'évaluation
date_debut: datetime = evaluation.date_debut
date_fin: datetime
if evaluation.date_fin is not None:
date_fin = evaluation.date_fin
else:
# On met à la fin de la journée de date_debut
date_fin = datetime.combine(date_debut.date(), time.max)
# Filtrage par rapport à la plage de l'évaluation
assiduites: Query = Assiduite.query.filter(
Assiduite.date_debut >= date_debut,
Assiduite.date_fin <= date_fin,
Assiduite.etudid.in_(etudids),
)
return assiduites
def get_etud_evaluations_assiduites(etud: Identite) -> list[dict]:
"""
Retourne la liste des évaluations d'un étudiant. Pour chaque évaluation,
retourne la liste des assiduités concernant la plage de l'évaluation.
"""
etud_evaluations_assiduites: list[dict] = []
# On récupère les moduleimpls puis les évaluations liés aux moduleimpls
modsimpl_ids: list[int] = [
modimp_inscr.moduleimpl_id
for modimp_inscr in ModuleImplInscription.query.filter_by(etudid=etud.id)
]
evaluations: Query = Evaluation.query.filter(
Evaluation.moduleimpl_id.in_(modsimpl_ids)
)
# Pour chaque évaluation, on récupère l'assiduité de l'étudiant sur la plage
# de l'évaluation
for evaluation in evaluations:
eval_assis: dict = {"evaluation_id": evaluation.id, "assiduites": []}
# Pas d'assiduités si pas de date
if evaluation.date_debut is None:
continue
date_debut: datetime = evaluation.date_debut
date_fin: datetime
if evaluation.date_fin is not None:
date_fin = evaluation.date_fin
else:
# On met à la fin de la journée de date_debut
date_fin = datetime.combine(date_debut.date(), time.max)
# Filtrage par rapport à la plage de l'évaluation
assiduites: Query = etud.assiduites.filter(
Assiduite.date_debut >= date_debut,
Assiduite.date_fin <= date_fin,
)
# On récupère les assiduités et on met à jour le dictionnaire
eval_assis["assiduites"] = [
assi.to_dict(format_api=True) for assi in assiduites
]
# On ajoute le dictionnaire à la liste des évaluations
etud_evaluations_assiduites.append(eval_assis)
return etud_evaluations_assiduites
# Gestion du cache
def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre:

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

@ -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

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

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

@ -461,6 +461,7 @@ def gen_api_map(app, endpoint_start="api"):
# 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):
@ -494,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)
@ -602,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)
@ -610,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>})
@ -625,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+):(<.+>)$")