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.decorators import permission_required, scodoc
from app.models import ( from app.models import (
Assiduite, Assiduite,
Evaluation,
FormSemestre, FormSemestre,
Identite, Identite,
ModuleImpl, ModuleImpl,
@ -282,6 +283,7 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False)
404, 404,
message="étudiant inconnu", message="étudiant inconnu",
) )
# Récupération des assiduités de l'étudiant # Récupération des assiduités de l'étudiant
assiduites_query: Query = etud.assiduites 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 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}) @bp.route("/assiduites/group/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/group/query", defaults={"with_query": True}) @api_web_bp.route("/assiduites/group/query", defaults={"with_query": True})
@login_required @login_required

View File

@ -89,12 +89,17 @@ def _get_etud_by_code(
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def etudiants_courants(long=False): def etudiants_courants(long: bool = False):
""" """
La liste des étudiants des semestres "courants" (tous départements) La liste des étudiants des semestres "courants".
(date du jour comprise dans la période couverte par le sem.) Considère tous les départements dans lesquels l'utilisateur a la
dans lesquels l'utilisateur a la permission ScoView permission `ScoView` (donc tous si le dépt. du rôle est `None`),
(donc tous si le dept 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 : Exemple de résultat :
[ [
@ -183,8 +188,13 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None): def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
""" """
Retourne la photo de l'étudiant Retourne la photo de l'étudiant ou un placeholder si non existant.
correspondant 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 etudid : l'etudid de l'étudiant
nip : le code nip 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 dept_id : id du département
ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit. ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit.
etat: 0 si verrouillé, 1 sinon 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") etape_apo = request.args.get("etape_apo")
annee_scolaire = request.args.get("annee_scolaire") annee_scolaire = request.args.get("annee_scolaire")
@ -376,7 +387,16 @@ def formsemestre_programme(formsemestre_id: int):
def formsemestre_etudiants( def formsemestre_etudiants(
formsemestre_id: int, with_query: bool = False, long: bool = False 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) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) 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): def formsemestre_resultat(formsemestre_id: int):
"""Tableau récapitulatif des résultats """Tableau récapitulatif des résultats
Pour chaque étudiant, son état, ses groupes, ses moyennes d'UE et de modules. 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) format_spec = request.args.get("format", None)
if format_spec is not None and format_spec != "raw": 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. group_ids permet de filtrer sur les groupes ScoDoc.
show_modules_titles affiche le titre complet du module (défaut), sinon juste le code. 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) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:

View File

@ -151,7 +151,13 @@ def etud_in_group(group_id: int):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def etud_in_group_query(group_id: int): 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") etat = request.args.get("etat")
if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}: if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}:
return json_error(API_CLIENT_ERROR, "etat: valeur invalide") 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. 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 Si accès via API web, le département de l'URL est ignoré, seules
les permissions de l'utilisateur sont prises en compte. les permissions de l'utilisateur sont prises en compte.
QUERY
-----
active:<bool:active>
departement:<string:departement>
starts_with:<string:starts_with>
""" """
query = User.query query = User.query
active = request.args.get("active") active = request.args.get("active")

View File

@ -35,9 +35,9 @@ def after_cas_login():
if user.cas_allow_login: if user.cas_allow_login:
current_app.logger.info(f"CAS: login {user.user_name}") current_app.logger.info(f"CAS: login {user.user_name}")
if login_user(user): if login_user(user):
flask.session[ flask.session["scodoc_cas_login_date"] = (
"scodoc_cas_login_date" datetime.datetime.now().isoformat()
] = datetime.datetime.now().isoformat() )
user.cas_last_login = datetime.datetime.utcnow() user.cas_last_login = datetime.datetime.utcnow()
if flask.session.get("CAS_EDT_ID"): if flask.session.get("CAS_EDT_ID"):
# essaie de récupérer l'edt_id s'il est présent # 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` # via l'expression `cas_edt_id_from_xml_regexp`
# voir flask_cas.routing # voir flask_cas.routing
edt_id = flask.session.get("CAS_EDT_ID") edt_id = flask.session.get("CAS_EDT_ID")
current_app.logger.info(f"""after_cas_login: storing edt_id for { current_app.logger.info(
user.user_name}: '{edt_id}'""") f"""after_cas_login: storing edt_id for {
user.user_name}: '{edt_id}'"""
)
user.edt_id = edt_id user.edt_id = edt_id
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@ -55,12 +57,17 @@ def after_cas_login():
current_app.logger.info( current_app.logger.info(
f"CAS login denied for {user.user_name} (not allowed to use CAS)" 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( current_app.logger.info(
f"""CAS login denied for { f"""CAS login denied for {
user.user_name if user else "" user.user_name if user else ""
} cas_id={cas_id} (unknown or inactive)""" } 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: else:
current_app.logger.info( current_app.logger.info(
f"""CAS attribute '{ScoDocSiteConfig.get("cas_attribute_id")}' not found ! 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 import log, db, set_sco_dept
from app.models import ( from app.models import (
Evaluation,
Identite, Identite,
FormSemestre, FormSemestre,
FormSemestreInscription, FormSemestreInscription,
@ -731,6 +732,95 @@ def create_absence_billet(
return calculator.to_dict()["demi"] 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 # Gestion du cache
def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int, int]: def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre: """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 str(parcour.id) for parcour in ref_comp.parcours
] ]
+ ["-1"], + ["-1"],
"explanation": """Parcours dans lesquels est utilisé ce module.<br> "explanation": """Parcours dans lesquels est utilisé ce module (inutile
Attention: si le module ne doit pas avoir les mêmes coefficients suivant le parcours, hors BUT, pour les modules standards et dans les UEs de bonus).
il faut en créer plusieurs versions, car dans ScoDoc chaque module a ses coefficients.""", <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) 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""" """Document Excel pour saisie notes dans l'évaluation et les groupes indiqués"""
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id) evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if not evaluation: if not evaluation:
raise ScoValueError("invalid evaluation_id") raise ScoValueError("invalid evaluation_id")
group_ids = group_ids or []
modimpl = evaluation.moduleimpl modimpl = evaluation.moduleimpl
formsemestre = modimpl.formsemestre formsemestre = modimpl.formsemestre
mod_responsable = sco_users.user_info(modimpl.responsable_id) 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 if evaluation.date_debut
else "(sans date)" 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 ""} ({ description = f"""{eval_titre} en {evaluation.moduleimpl.module.abbrev or ""} ({
evaluation.moduleimpl.module.code evaluation.moduleimpl.module.code
@ -986,7 +988,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
rows.append( rows.append(
[ [
str(etudid), str(etudid),
e["nom"].upper(), e.get("nom_disp", "") or e.get("nom_usuel", "") or e["nom"],
e["prenom"].lower().capitalize(), e["prenom"].lower().capitalize(),
e["inscr"]["etat"], e["inscr"]["etat"],
grc, grc,
@ -1206,7 +1208,7 @@ def _get_sorted_etuds(evaluation: Evaluation, etudids: list, formsemestre_id: in
def _form_saisie_notes( def _form_saisie_notes(
evaluation: Evaluation, modimpl: ModuleImpl, groups_infos, destination="" 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. pour les groupes indiqués.
On charge tous les étudiants, ne seront montrés que ceux On charge tous les étudiants, ne seront montrés que ceux

View File

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

View File

@ -66,6 +66,7 @@ def test_permissions(api_headers):
"assiduite_id": 1, "assiduite_id": 1,
"justif_id": 1, "justif_id": 1,
"etudids": "1", "etudids": "1",
"ue_id": 1,
} }
# Arguments spécifiques pour certaines routes # Arguments spécifiques pour certaines routes
# par défaut, on passe tous les arguments de all_args # 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 # Récupération de la fonction associée à la route
func = app.view_functions[rule.endpoint] func = app.view_functions[rule.endpoint]
func_name = parse_doc_name(func.__doc__ or "") or func.__name__
# Pour chaque segment de la route # Pour chaque segment de la route
for i, segment in enumerate(segments): 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 # On ajoute le token comme enfant du token courant
# en donnant la méthode et le nom de la fonction associée # 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" method: str = "POST" if "POST" in rule.methods else "GET"
child.method = method child.method = method
current_token.add_child(child) 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"}) 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 # Ajout de l'élément principal à l'élément racine
svg.append(element) svg.append(element)
@ -610,6 +614,50 @@ def generate_svg(element, fname):
tree.write(fname, encoding="utf-8", xml_declaration=True) 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): def parse_query_doc(doc):
""" """
renvoie un dictionnaire {param: <type:nom_param>} (ex: {assiduite_id : <int:assiduite_id>}) 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 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 Attention, la ligne ----- doit être collée contre QUERY et contre le premier paramètre
""" """
# Récupérer les lignes de la doc query_lines: list[str] = _get_doc_lines("QUERY", 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 = {} query = {}
regex = re.compile(r"^(\w+):(<.+>)$") regex = re.compile(r"^(\w+):(<.+>)$")