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
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/<int:etudid>/count
Retourne le nombre d'assiduités d'un étudiant.
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/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 -]

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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<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
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: <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
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}"
)