From 71639606faed3322661910e4ce7608d4bbc58562 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Tue, 23 Jul 2024 16:49:11 +0200 Subject: [PATCH] =?UTF-8?q?G=C3=A9n=C3=A9ration=20doc=20API=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/assiduites.py | 22 ++--- app/api/etudiants.py | 8 +- app/api/formsemestres.py | 2 + app/models/assiduites.py | 4 +- scodoc.py | 15 +++- tools/__init__.py | 2 +- tools/create_api_map.py | 170 +++++++++++++++++++++++++++++++-------- 7 files changed, 176 insertions(+), 47 deletions(-) diff --git a/app/api/assiduites.py b/app/api/assiduites.py index 6b75b0998..f7be35949 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -48,6 +48,8 @@ def assiduite(assiduite_id: int = None): """Retourne un objet assiduité à partir de son id Exemple de résultat: + + ```json { "assiduite_id": 1, "etudid": 2, @@ -61,6 +63,7 @@ def assiduite(assiduite_id: int = None): "user_nom_complet": "Marie Dupont" "est_just": False or True, } + ``` """ return get_model_api_object(Assiduite, assiduite_id, Identite) @@ -78,15 +81,18 @@ def assiduite(assiduite_id: int = None): @permission_required(Permission.ScoView) @as_json def assiduite_justificatifs(assiduite_id: int = None, long: bool = False): - """Retourne la liste des justificatifs qui justifie cette assiduitée + """Retourne la liste des justificatifs qui justifient cette assiduité. Exemple de résultat: + + ```json [ 1, 2, 3, ... ] + ``` """ return get_assiduites_justif(assiduite_id, long) @@ -124,22 +130,20 @@ def assiduites_count( etudid: int = None, nip: str = None, ine: str = None, with_query: bool = False ): """ - Retourne le nombre d'assiduités d'un étudiant - chemin : /assiduites//count + Retourne le nombre d'assiduités d'un étudiant. - Un filtrage peut être donné avec une query - chemin : /assiduites//count/query? + Un filtrage peut être donné avec une `query`. Les différents filtres : - Type (type de comptage -> journee, demi, heure, nombre d'assiduite): + - Type (type de comptage -> journee, demi, heure, nombre d'assiduite): query?type=(journee, demi, heure) -> une seule valeur parmis les trois ex: .../query?type=heure Comportement par défaut : compte le nombre d'assiduité enregistrée - Etat (etat de l'étudiant -> absent, present ou retard): - query?etat=[- liste des états séparé par une virgule -] + - Etat (etat de l'étudiant -> absent, present ou retard): + `query?etat=[- liste des états séparé par une virgule -]` ex: .../query?etat=present,retard - Date debut + - Date debut (date de début de l'assiduité, sont affichés les assiduités dont la date de début est supérieur ou égale à la valeur donnée): query?date_debut=[- date au format iso -] diff --git a/app/api/etudiants.py b/app/api/etudiants.py index 829b06e96..8b4db4d3b 100755 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -414,13 +414,14 @@ def bulletin( """ Retourne le bulletin d'un étudiant dans un formsemestre. + PARAMS + ------ formsemestre_id : l'id d'un formsemestre code_type : "etudid", "nip" ou "ine" code : valeur du code INE, NIP ou etudid, selon code_type. version : type de bulletin (par défaut, "selectedevals"): short, long, selectedevals, butcourt pdf : si spécifié, bulletin au format PDF (et non JSON). - Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin """ if version == "pdf": version = "long" @@ -599,7 +600,10 @@ def etudiant_edit( code_type: str = "etudid", code: str = None, ): - """Edition des données étudiant (identité, admission, adresses)""" + """Édition des données étudiant (identité, admission, adresses). + + `code_type`: `etudid`, `ine` ou `nip`. + """ ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept) if not ok: return etud # json error diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index 641a70df8..6feba7d68 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -104,6 +104,8 @@ def formsemestres_query(): Retourne les formsemestres filtrés par étape Apogée ou année scolaire ou département (acronyme ou id) ou état ou code étudiant + PARAMS + ------ etape_apo : un code étape apogée annee_scolaire : année de début de l'année scolaire dept_acronym : acronyme du département (eg "RT") diff --git a/app/models/assiduites.py b/app/models/assiduites.py index 297a4014a..679a770c3 100644 --- a/app/models/assiduites.py +++ b/app/models/assiduites.py @@ -348,7 +348,7 @@ class Assiduite(ScoDocModel): """ Retourne le module associé à l'assiduité Si traduire est vrai, retourne le titre du module précédé du code - Sinon rentourne l'objet Module ou None + Sinon retourne l'objet Module ou None """ if self.moduleimpl_id is not None: @@ -358,7 +358,7 @@ class Assiduite(ScoDocModel): return f"{mod.code} {mod.titre}" return mod - elif self.external_data is not None and "module" in self.external_data: + if self.external_data is not None and "module" in self.external_data: return ( "Autre module (pas dans la liste)" if self.external_data["module"] == "Autre" diff --git a/scodoc.py b/scodoc.py index ebe83a931..8c198e236 100755 --- a/scodoc.py +++ b/scodoc.py @@ -748,6 +748,19 @@ def generate_ens_calendars(): # generate-ens-calendars help="Endpoint à partir duquel générer la carte des routes", ) @with_appcontext -def gen_api_map(endpoint): +def gen_api_map(endpoint): # gen-api-map """Génère la carte des routes de l'API.""" tools.gen_api_map(app, endpoint_start=endpoint) + + +@app.cli.command() +@click.option( + "-e", + "--endpoint", + default="api.", + help="Endpoint à partir duquel générer la documentation des routes", +) +@with_appcontext +def gen_api_doc(endpoint): # gen-api-map + """Génère la documentation des routes de l'API.""" + tools.gen_api_doc(app, endpoint_start=endpoint) diff --git a/tools/__init__.py b/tools/__init__.py index c7c13e6bd..2b8d11bbb 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -10,4 +10,4 @@ from tools.migrate_scodoc7_archives import migrate_scodoc7_dept_archives from tools.migrate_scodoc7_logos import migrate_scodoc7_dept_logos from tools.migrate_abs_to_assiduites import migrate_abs_to_assiduites from tools.downgrade_assiduites import downgrade_module -from tools.create_api_map import gen_api_map +from tools.create_api_map import gen_api_map, gen_api_doc diff --git a/tools/create_api_map.py b/tools/create_api_map.py index fd1fcdcc1..771f34f19 100644 --- a/tools/create_api_map.py +++ b/tools/create_api_map.py @@ -439,21 +439,15 @@ def _create_question_mark_group(coords, href): return group -# point d'entrée de la commande `flask gen-api-map` -def gen_api_map(app, endpoint_start="api"): +def analyze_api_routes(app, endpoint_start: str) -> tuple: + """Parcours de toutes les routes de l'application + analyse docstrings """ - Fonction permettant de générer une carte SVG de l'API de ScoDoc - Elle récupère les routes de l'API et les transforme en un arbre de Token - puis génère un fichier SVG à partir de cet arbre - """ - - print("DEBUG", app.view_functions["apiweb.user_info"].scodoc_permission) # Création du token racine api_map = Token("") doctable_lines: dict[str, dict] = {} - # Parcours de toutes les routes de l'application for rule in app.url_map.iter_rules(): # On ne garde que les routes de l'API / APIWEB if not rule.endpoint.lower().startswith(endpoint_start.lower()): @@ -468,8 +462,8 @@ 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__ - + doc_dict = _parse_doc_string(func.__doc__ or "") + func_name = doc_dict.get("DOC_ANCHOR", [None])[0] 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 @@ -521,16 +515,37 @@ def gen_api_map(app, endpoint_start="api"): except AttributeError: permissions = "Aucune permission requise" - doctable_lines[func_name] = { - "doctable": doctable, - "method": method, - "nom": func_name, - "href": href, - "permission": permissions, - } + if func_name not in doctable_lines: + doctable_lines[func_name] = { + "doctable": doctable, + "method": method, + "nom": func_name, + "href": href, + "permission": permissions, + "description": doc_dict.get("", ""), + "params": doc_dict.get("PARAMS", ""), + } # On met à jour le token courant pour le prochain segment current_token = child + if func_name in doctable_lines: # endpoint déjà ajouté, ajoute au besoin route + doctable_lines[func_name]["routes"] = doctable_lines[func_name].get( + "routes", [] + ) + [rule.rule] + return api_map, doctable_lines + + +# point d'entrée de la commande `flask gen-api-map` +def gen_api_map(app, endpoint_start="api."): + """ + Fonction permettant de générer une carte SVG de l'API de ScoDoc + Elle récupère les routes de l'API et les transforme en un arbre de Token + puis génère un fichier SVG à partir de cet arbre + """ + + print("DEBUG", app.view_functions["apiweb.user_info"].scodoc_permission) + + api_map, doctable_lines = analyze_api_routes(app, endpoint_start) # On génère le SVG à partir de l'arbre de Token generate_svg(api_map.to_svg_group(), "/tmp/api_map.svg") @@ -540,7 +555,8 @@ def gen_api_map(app, endpoint_start="api"): ) # On génère le tableau à partir de doctable_lines - _gen_table(sorted(doctable_lines.values(), key=lambda x: x["nom"])) + table = _gen_table(sorted(doctable_lines.values(), key=lambda x: x["nom"])) + _write_gen_table(table) def _get_bbox(element, x_offset=0, y_offset=0): @@ -646,7 +662,44 @@ def generate_svg(element, fname): tree.write(fname, encoding="utf-8", xml_declaration=True) -def _get_doc_lines(keyword, doc) -> list[str]: +def _parse_doc_string(doc_string: str) -> dict[str, list[str]]: + """Parse doc string and extract a dict: + { + "" : description_lines, + "keyword" : lines + } + + In the docstring, each keyword is associated to a section like + + KEYWORD + ------- + ... + (blank line) + + All non blank lines not associated to a keyword go to description. + """ + doc_dict = {} + matches = re.finditer( + r"^\s*(?P[A-Z_\-]+)$\n^\s*-+\n(?P(^(?!\s*$).+$\n?)+)", + doc_string, + re.MULTILINE, + ) + description = "" + i = 0 + for match in matches: + start, end = match.span() + description += doc_string[i:start] + doc_dict[match.group("kw")] = [ + x.strip() for x in match.group("txt").split("\n") if x.strip() + ] + i = end + + description += doc_string[i:] + doc_dict[""] = description.split("\n") + return doc_dict + + +def _get_doc_lines(keyword, doc_string: str) -> list[str]: """ Renvoie les lignes de la doc qui suivent le mot clé keyword Attention : s'arrête à la première ligne vide @@ -659,7 +712,7 @@ def _get_doc_lines(keyword, doc) -> list[str]: """ # Récupérer les lignes de la doc - lines = [line.strip() for line in doc.split("\n")] + lines = [line.strip() for line in doc_string.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: @@ -682,7 +735,7 @@ def _get_doc_lines(keyword, doc) -> list[str]: return kw_lines -def parse_doc_name(doc) -> str: +def parse_doc_name(doc_string: str) -> str: """ renvoie le nom de la route à partir de la docstring @@ -695,11 +748,11 @@ def parse_doc_name(doc) -> str: Il ne peut y avoir qu'une seule ligne suivant ----- """ - name_lines: list[str] = _get_doc_lines("DOC_ANCHOR", doc) + name_lines: list[str] = _get_doc_lines("DOC_ANCHOR", doc_string) return name_lines[0] if name_lines else None -def parse_query_doc(doc) -> dict[str, str]: +def parse_query_doc(doc_string: str) -> dict[str, str]: """ renvoie un dictionnaire {param: } (ex: {assiduite_id : }) @@ -714,7 +767,7 @@ def parse_query_doc(doc) -> dict[str, str]: 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 """ - query_lines: list[str] = _get_doc_lines("QUERY", doc) + query_lines: list[str] = _get_doc_lines("QUERY", doc_string) query = {} regex = re.compile(r"^(\w+):(<.+>)$") @@ -732,7 +785,7 @@ def parse_query_doc(doc) -> dict[str, str]: return query -def parse_doctable_doc(doc) -> dict[str, str]: +def parse_doctable_doc(doc_string: str) -> dict[str, str]: """ Retourne un dictionnaire représentant les informations du tableau d'api à partir de la doc (DOC-TABLE) @@ -747,9 +800,7 @@ def parse_doctable_doc(doc) -> dict[str, str]: href: une-fonction """ - doc_lines: list[str] = _get_doc_lines("DOC-TABLE", doc) - - # On crée un dictionnaire + doc_lines: list[str] = _get_doc_lines("DOC-TABLE", doc_string) table = {} # on parcourt les lignes de la doc @@ -762,7 +813,9 @@ def parse_doctable_doc(doc) -> dict[str, str]: return table -def _gen_table_line(nom, href, method, permission, doctable: dict): +def _gen_table_line( + nom="", href="", method="", permission="", doctable: dict = None, **kwargs +): """ Génère une ligne de tableau markdown @@ -791,7 +844,7 @@ def _gen_table_head() -> str: return f"{headers}\n{line}\n" -def _gen_table(lines: list[dict], filename: str = "/tmp/api_table.md") -> str: +def _gen_table(lines: list[dict]) -> str: """ Génère un tableau markdown à partir d'une liste de lignes @@ -805,10 +858,13 @@ def _gen_table(lines: list[dict], filename: str = "/tmp/api_table.md") -> str: """ table = _gen_table_head() table += "\n".join([_gen_table_line(**line) for line in lines]) + return table + +def _write_gen_table(table: str, filename: str = "/tmp/api_table.md"): + """Ecriture du fichier md avec la table""" with open(filename, "w", encoding="UTF-8") as f: f.write(table) - print( f"Le tableau a été généré avec succès. Vous pouvez le consulter à l'adresse suivante : {filename}" ) @@ -854,3 +910,53 @@ if __name__ == "__main__": "href": hf, } print(_gen_table([doc])) + + +def doc_route(doctable: dict) -> str: + """Generate markdown doc for a route""" + doc = f""" +#### **`{doctable['nom']}`** + +""" + if doctable.get("routes"): + if len(doctable["routes"]) == 1: + doc += f"""* ** Route :** `{doctable["routes"][0]}`\n""" + else: + doc += "* ** Routes :**\n" + for route in doctable["routes"]: + doc += f""" * `{route}`\n""" + doc += f"""* **Méthode: {doctable['method']}** +* **Permission: `{doctable.get('permission', '')}`** +""" + if doctable.get("params"): + for param in doctable["params"]: + frags = param.split(":", maxsplit=1) + if len(frags) == 2: + name, descr = frags + else: + print(f"Warning: {doctable['nom']} : invalid PARAMS {param}") + name, descr = param, "" + doc += f""" * `{name}`: {descr}\n""" + if doctable.get("data"): + doc += f"""* **Data:** {doctable['data']}\n""" + if doctable.get("description"): + descr = "\n".join(s for s in doctable["description"]) + doc += f"""* **Description:** {descr}\n""" + return doc + + +def gen_api_doc(app, endpoint_start="api."): + "commande gen-api-doc" + _, doctable_lines = analyze_api_routes(app, endpoint_start) + mddoc = "\n".join( + doc_route(doctable) + for doctable in sorted(doctable_lines.values(), key=lambda x: x["nom"]) + ) + + fname = "/tmp/apidoc.md" + with open(fname, "w", encoding="utf-8") as f: + f.write(mddoc) + print( + "La documentation API a été générée avec succès. " + f"Vous pouvez la consulter à l'adresse suivante : {fname}" + )