Compare commits

...

2 Commits

Author SHA1 Message Date
71639606fa Génération doc API (WIP) 2024-07-23 16:49:11 +02:00
9ca86e7900 Génération tableau API 2024-07-23 07:07:29 +02:00
7 changed files with 317 additions and 54 deletions

View File

@ -19,7 +19,8 @@ import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.api import api_bp as bp from app.api import api_bp as bp
from app.api import api_web_bp, get_model_api_object, tools from app.api import api_web_bp, get_model_api_object, tools
from app.decorators import permission_required, scodoc from app.api import api_permission_required as permission_required
from app.decorators import scodoc
from app.models import ( from app.models import (
Assiduite, Assiduite,
Evaluation, Evaluation,
@ -47,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,
@ -60,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)
@ -77,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)
@ -123,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

@ -7,6 +7,8 @@ Script permettant de générer une carte SVG de l'API de ScoDoc
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import re import re
from app.auth.models import Permission
class COLORS: class COLORS:
""" """
@ -437,16 +439,15 @@ def _create_question_mark_group(coords, href):
return group return group
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
Fonction permettant de générer une carte SVG de l'API de ScoDoc analyse docstrings
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
""" """
# Création du token racine # Création du token racine
api_map = Token("") api_map = Token("")
# Parcours de toutes les routes de l'application doctable_lines: dict[str, dict] = {}
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()):
@ -461,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
@ -500,8 +501,51 @@ def gen_api_map(app, endpoint_start="api"):
child.method = method child.method = method
current_token.add_child(child) current_token.add_child(child)
# Gestion de doctable
doctable = parse_doctable_doc(func.__doc__ or "")
href = func_name.replace("_", "-")
if child.query and not href.endswith("-query"):
href += "-query"
permissions: str
try:
permissions: str = ", ".join(
sorted(Permission.permissions_names(func.scodoc_permission))
)
except AttributeError:
permissions = "Aucune permission requise"
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 # 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")
@ -510,6 +554,10 @@ def gen_api_map(app, endpoint_start="api"):
+ "Vous pouvez la consulter à l'adresse suivante : /tmp/api_map.svg" + "Vous pouvez la consulter à l'adresse suivante : /tmp/api_map.svg"
) )
# On génère le tableau à partir de doctable_lines
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):
""" """
@ -614,9 +662,47 @@ 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
La doc doit contenir des lignes de la forme: La doc doit contenir des lignes de la forme:
@ -626,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:
@ -638,10 +724,18 @@ def _get_doc_lines(keyword, doc) -> list[str]:
return [] return []
# On récupère les lignes de la doc qui correspondent au keyword (enfin on espère) # On récupère les lignes de la doc qui correspondent au keyword (enfin on espère)
kw_lines = lines[kw_index + 2 :] kw_lines = lines[kw_index + 2 :]
# On s'arrête à la première ligne vide
first_empty_line: int
try:
first_empty_line: int = kw_lines.index("")
except ValueError:
first_empty_line = len(kw_lines)
kw_lines = kw_lines[:first_empty_line]
return kw_lines return kw_lines
def parse_doc_name(doc): 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
@ -654,11 +748,11 @@ def parse_doc_name(doc):
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): 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>})
@ -673,7 +767,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
""" """
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+):(<.+>)$")
@ -691,33 +785,178 @@ def parse_query_doc(doc):
return query return query
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)
éléments optionnels:
- `permissions` permissions nécessaires pour accéder à la route (ScoView, AbsChange, ...)
- `href` nom (sans #) de l'ancre dans la page ScoDoc9API
DOC-TABLE
---------
permissions: ScoView
href: une-fonction
"""
doc_lines: list[str] = _get_doc_lines("DOC-TABLE", doc_string)
table = {}
# on parcourt les lignes de la doc
for line in doc_lines:
# On sépare le paramètre et sa valeur
param, value = line.split(":")
# On met à jour le dictionnaire
table[param.strip()] = value.strip()
return table
def _gen_table_line(
nom="", href="", method="", permission="", doctable: dict = None, **kwargs
):
"""
Génère une ligne de tableau markdown
| nom de la route| methode HTTP| Permission |
"""
lien: str = href
if "href" in doctable:
lien: str = doctable.get("href")
nav: str = f"[{nom}]({'#'+lien})"
table: str = "|"
for string in [nav, method, doctable.get("permissions") or permission]:
table += f" {string} |"
return table
def _gen_table_head() -> str:
"""
Génère la première ligne du tableau markdown
"""
headers: str = "| Route | Méthode | Permission |"
line: str = "|---|---|---|"
return f"{headers}\n{line}\n"
def _gen_table(lines: list[dict]) -> str:
"""
Génère un tableau markdown à partir d'une liste de lignes
lines : liste de dictionnaire au format :
- doctable : dict généré par parse_doctable_doc
- nom : nom de la fonction associée à la route
- method : GET ou POST
- permission : Permissions de la route (auto récupérée)
"""
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}"
)
if __name__ == "__main__": if __name__ == "__main__":
# Exemple d'utilisation de la classe Token # Exemple d'utilisation de la classe Token
# Exemple simple de création d'un arbre de Token # Exemple simple de création d'un arbre de Token
root = Token("api") # root = Token("api")
child1 = Token("assiduites", leaf=True) # child1 = Token("assiduites", leaf=True)
child1.func_name = "assiduites_get" # child1.func_name = "assiduites_get"
child2 = Token("count") # child2 = Token("count")
child22 = Token("all") # child22 = Token("all")
child23 = Token( # child23 = Token(
"query", # "query",
query={ # query={
"etat": "<string:etat>", # "etat": "<string:etat>",
"moduleimpl_id": "<int:moduleimpl_id>", # "moduleimpl_id": "<int:moduleimpl_id>",
"count": "<int:count>", # "count": "<int:count>",
"formsemestre_id": "<int:formsemestre_id>", # "formsemestre_id": "<int:formsemestre_id>",
}, # },
# )
# child3 = Token("justificatifs", "POST")
# child3.func_name = "justificatifs_post"
# root.add_child(child1)
# child1.add_child(child2)
# child2.add_child(child22)
# child2.add_child(child23)
# root.add_child(child3)
# group_element = root.to_svg_group()
# generate_svg(group_element, "/tmp/api_map.svg")
dt: dict = parse_doctable_doc(parse_doctable_doc.__doc__)
md: str = "POST"
hf: str = "assiduites-query"
doc: dict = {
"doctable": dt,
"method": md,
"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"])
) )
child3 = Token("justificatifs", "POST")
child3.func_name = "justificatifs_post"
root.add_child(child1) fname = "/tmp/apidoc.md"
child1.add_child(child2) with open(fname, "w", encoding="utf-8") as f:
child2.add_child(child22) f.write(mddoc)
child2.add_child(child23) print(
root.add_child(child3) "La documentation API a été générée avec succès. "
f"Vous pouvez la consulter à l'adresse suivante : {fname}"
group_element = root.to_svg_group() )
generate_svg(group_element, "/tmp/api_map.svg")