diff --git a/app/api/departements.py b/app/api/departements.py index 7d056e468..140482b25 100644 --- a/app/api/departements.py +++ b/app/api/departements.py @@ -226,50 +226,27 @@ def dept_formsemestres_ids_by_id(dept_id: int): @bp.route("/departement//formsemestres_courants") +@bp.route("/departement/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: - 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//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] diff --git a/app/api/etudiants.py b/app/api/etudiants.py index 9b21db434..5ec9f04a5 100755 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -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: 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: etudid : l'etudid de l'étudiant nip : le code nip de l'étudiant diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 664a663bf..7d91516dd 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -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: + annee_scolaire: + dept_acronym: + dept_id: + etat: + nip: + 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: + + """ 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: + """ 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: + show_modules_titles: + """ query = FormSemestre.query.filter_by(id=formsemestre_id) if g.scodoc_dept: diff --git a/app/api/partitions.py b/app/api/partitions.py index 7b47c3148..54c30355f 100644 --- a/app/api/partitions.py +++ b/app/api/partitions.py @@ -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: + + """ 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") diff --git a/app/api/users.py b/app/api/users.py index c038ffb8d..f1a600935 100644 --- a/app/api/users.py +++ b/app/api/users.py @@ -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: + departement: + starts_with: + """ query = User.query active = request.args.get("active") diff --git a/app/auth/cas.py b/app/auth/cas.py index 3b9f14e47..268611469 100644 --- a/app/auth/cas.py +++ b/app/auth/cas.py @@ -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 ! diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index b6cca91b9..eef881a1d 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -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.
- 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). +
+ 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. + """, }, ) ] diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index ff36538bc..94abc5ed4 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -564,7 +564,7 @@ def fiche_etud(etudid=None): %(etat_civil)s %(email_link)s -%(etudfoto)s +%(etudfoto)s """ + situation_template diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index 7df74fa36..78bc97832 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -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 diff --git a/app/views/scolar.py b/app/views/scolar.py index 34e7384e1..c0280874b 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -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/") @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"]), - "

%s

" % etud["nomprenom"], - '", - 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) + } +

{etud.nomprenom}

+ + {html_sco_header.sco_footer()} + """ @bp.route("/form_change_photo", methods=["GET", "POST"]) diff --git a/sco_version.py b/sco_version.py index 336790981..0b5fefee9 100644 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.6.980" +SCOVERSION = "9.6.981" SCONAME = "ScoDoc" diff --git a/tests/api/test_api_departements.py b/tests/api/test_api_departements.py index 7553f7396..ba5f07dd3 100644 --- a/tests/api/test_api_departements.py +++ b/tests/api/test_api_departements.py @@ -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 diff --git a/tests/api/test_api_permissions.py b/tests/api/test_api_permissions.py index 1b884b486..ae3dad923 100755 --- a/tests/api/test_api_permissions.py +++ b/tests/api/test_api_permissions.py @@ -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 diff --git a/tools/create_api_map.py b/tools/create_api_map.py index 953f384a6..9afa57327 100644 --- a/tools/create_api_map.py +++ b/tools/create_api_map.py @@ -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: } (ex: {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+):(<.+>)$")