Génération doc API (WIP)

This commit is contained in:
Emmanuel Viennet 2024-07-23 16:49:11 +02:00
parent 9ca86e7900
commit 71639606fa
7 changed files with 176 additions and 47 deletions

View File

@ -48,6 +48,8 @@ def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id """Retourne un objet assiduité à partir de son id
Exemple de résultat: Exemple de résultat:
```json
{ {
"assiduite_id": 1, "assiduite_id": 1,
"etudid": 2, "etudid": 2,
@ -61,6 +63,7 @@ def assiduite(assiduite_id: int = None):
"user_nom_complet": "Marie Dupont" "user_nom_complet": "Marie Dupont"
"est_just": False or True, "est_just": False or True,
} }
```
""" """
return get_model_api_object(Assiduite, assiduite_id, Identite) return get_model_api_object(Assiduite, assiduite_id, Identite)
@ -78,15 +81,18 @@ def assiduite(assiduite_id: int = None):
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
@as_json @as_json
def assiduite_justificatifs(assiduite_id: int = None, long: bool = False): 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: Exemple de résultat:
```json
[ [
1, 1,
2, 2,
3, 3,
... ...
] ]
```
""" """
return get_assiduites_justif(assiduite_id, long) 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 etudid: int = None, nip: str = None, ine: str = None, with_query: bool = False
): ):
""" """
Retourne le nombre d'assiduités d'un étudiant Retourne le nombre d'assiduités d'un étudiant.
chemin : /assiduites/<int:etudid>/count
Un filtrage peut être donné avec une query Un filtrage peut être donné avec une `query`.
chemin : /assiduites/<int:etudid>/count/query?
Les différents filtres : 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 query?type=(journee, demi, heure) -> une seule valeur parmis les trois
ex: .../query?type=heure ex: .../query?type=heure
Comportement par défaut : compte le nombre d'assiduité enregistrée Comportement par défaut : compte le nombre d'assiduité enregistrée
Etat (etat de l'étudiant -> absent, present ou retard): - Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -] `query?etat=[- liste des états séparé par une virgule -]`
ex: .../query?etat=present,retard ex: .../query?etat=present,retard
Date debut - Date debut
(date de début de l'assiduité, sont affichés les assiduités (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): dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -] query?date_debut=[- date au format iso -]

View File

@ -414,13 +414,14 @@ def bulletin(
""" """
Retourne le bulletin d'un étudiant dans un formsemestre. Retourne le bulletin d'un étudiant dans un formsemestre.
PARAMS
------
formsemestre_id : l'id d'un formsemestre formsemestre_id : l'id d'un formsemestre
code_type : "etudid", "nip" ou "ine" code_type : "etudid", "nip" ou "ine"
code : valeur du code INE, NIP ou etudid, selon code_type. code : valeur du code INE, NIP ou etudid, selon code_type.
version : type de bulletin (par défaut, "selectedevals"): short, long, selectedevals, butcourt version : type de bulletin (par défaut, "selectedevals"): short, long, selectedevals, butcourt
pdf : si spécifié, bulletin au format PDF (et non JSON). pdf : si spécifié, bulletin au format PDF (et non JSON).
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
""" """
if version == "pdf": if version == "pdf":
version = "long" version = "long"
@ -599,7 +600,10 @@ def etudiant_edit(
code_type: str = "etudid", code_type: str = "etudid",
code: str = None, 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) ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok: if not ok:
return etud # json error return etud # json error

View File

@ -104,6 +104,8 @@ def formsemestres_query():
Retourne les formsemestres filtrés par Retourne les formsemestres filtrés par
étape Apogée ou année scolaire ou département (acronyme ou id) ou état ou code étudiant é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 etape_apo : un code étape apogée
annee_scolaire : année de début de l'année scolaire annee_scolaire : année de début de l'année scolaire
dept_acronym : acronyme du département (eg "RT") dept_acronym : acronyme du département (eg "RT")

View File

@ -348,7 +348,7 @@ class Assiduite(ScoDocModel):
""" """
Retourne le module associé à l'assiduité Retourne le module associé à l'assiduité
Si traduire est vrai, retourne le titre du module précédé du code 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: if self.moduleimpl_id is not None:
@ -358,7 +358,7 @@ class Assiduite(ScoDocModel):
return f"{mod.code} {mod.titre}" return f"{mod.code} {mod.titre}"
return mod 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 ( return (
"Autre module (pas dans la liste)" "Autre module (pas dans la liste)"
if self.external_data["module"] == "Autre" if self.external_data["module"] == "Autre"

View File

@ -748,6 +748,19 @@ def generate_ens_calendars(): # generate-ens-calendars
help="Endpoint à partir duquel générer la carte des routes", help="Endpoint à partir duquel générer la carte des routes",
) )
@with_appcontext @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.""" """Génère la carte des routes de l'API."""
tools.gen_api_map(app, endpoint_start=endpoint) 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)

View File

@ -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_scodoc7_logos import migrate_scodoc7_dept_logos
from tools.migrate_abs_to_assiduites import migrate_abs_to_assiduites from tools.migrate_abs_to_assiduites import migrate_abs_to_assiduites
from tools.downgrade_assiduites import downgrade_module 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

View File

@ -439,21 +439,15 @@ def _create_question_mark_group(coords, href):
return group return group
# point d'entrée de la commande `flask gen-api-map` def analyze_api_routes(app, endpoint_start: str) -> tuple:
def gen_api_map(app, endpoint_start="api"): """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 # Création du token racine
api_map = Token("") api_map = Token("")
doctable_lines: dict[str, dict] = {} doctable_lines: dict[str, dict] = {}
# Parcours de toutes les routes de l'application
for rule in app.url_map.iter_rules(): for rule in app.url_map.iter_rules():
# On ne garde que les routes de l'API / APIWEB # On ne garde que les routes de l'API / APIWEB
if not rule.endpoint.lower().startswith(endpoint_start.lower()): 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 # 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__ 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 # Pour chaque segment de la route
for i, segment in enumerate(segments): for i, segment in enumerate(segments):
# On cherche si le segment est déjà un enfant du token courant # 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: except AttributeError:
permissions = "Aucune permission requise" permissions = "Aucune permission requise"
doctable_lines[func_name] = { if func_name not in doctable_lines:
"doctable": doctable, doctable_lines[func_name] = {
"method": method, "doctable": doctable,
"nom": func_name, "method": method,
"href": href, "nom": func_name,
"permission": permissions, "href": href,
} "permission": permissions,
"description": doc_dict.get("", ""),
"params": doc_dict.get("PARAMS", ""),
}
# On met à jour le token courant pour le prochain segment # On met à jour le token courant pour le prochain segment
current_token = child 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 # On génère le SVG à partir de l'arbre de Token
generate_svg(api_map.to_svg_group(), "/tmp/api_map.svg") 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 # 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): 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) 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<kw>[A-Z_\-]+)$\n^\s*-+\n(?P<txt>(^(?!\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 Renvoie les lignes de la doc qui suivent le mot clé keyword
Attention : s'arrête à la première ligne vide 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 # 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 "-----" # 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 # Si ce n'est pas le cas, on renvoie un dictionnaire vide
try: try:
@ -682,7 +735,7 @@ def _get_doc_lines(keyword, doc) -> list[str]:
return kw_lines 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 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 ----- 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 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: <type:nom_param>} (ex: {assiduite_id : <int:assiduite_id>}) renvoie un dictionnaire {param: <type:nom_param>} (ex: {assiduite_id : <int: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 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
""" """
query_lines: list[str] = _get_doc_lines("QUERY", doc) query_lines: list[str] = _get_doc_lines("QUERY", doc_string)
query = {} query = {}
regex = re.compile(r"^(\w+):(<.+>)$") regex = re.compile(r"^(\w+):(<.+>)$")
@ -732,7 +785,7 @@ def parse_query_doc(doc) -> dict[str, str]:
return query 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 Retourne un dictionnaire représentant les informations du tableau d'api
à partir de la doc (DOC-TABLE) à partir de la doc (DOC-TABLE)
@ -747,9 +800,7 @@ def parse_doctable_doc(doc) -> dict[str, str]:
href: une-fonction href: une-fonction
""" """
doc_lines: list[str] = _get_doc_lines("DOC-TABLE", doc) doc_lines: list[str] = _get_doc_lines("DOC-TABLE", doc_string)
# On crée un dictionnaire
table = {} table = {}
# on parcourt les lignes de la doc # on parcourt les lignes de la doc
@ -762,7 +813,9 @@ def parse_doctable_doc(doc) -> dict[str, str]:
return table 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 Génère une ligne de tableau markdown
@ -791,7 +844,7 @@ def _gen_table_head() -> str:
return f"{headers}\n{line}\n" 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 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 = _gen_table_head()
table += "\n".join([_gen_table_line(**line) for line in lines]) 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: with open(filename, "w", encoding="UTF-8") as f:
f.write(table) f.write(table)
print( print(
f"Le tableau a été généré avec succès. Vous pouvez le consulter à l'adresse suivante : {filename}" 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, "href": hf,
} }
print(_gen_table([doc])) 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}"
)