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
from app.api import api_bp as bp
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 (
Assiduite,
Evaluation,
@ -47,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,
@ -60,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)
@ -77,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)
@ -123,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

@ -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 re
from app.auth.models import Permission
class COLORS:
"""
@ -437,16 +439,15 @@ def _create_question_mark_group(coords, href):
return group
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
def analyze_api_routes(app, endpoint_start: str) -> tuple:
"""Parcours de toutes les routes de l'application
analyse docstrings
"""
# Création du token racine
api_map = Token("")
# Parcours de toutes les routes de l'application
doctable_lines: dict[str, dict] = {}
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()):
@ -461,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
@ -500,8 +501,51 @@ def gen_api_map(app, endpoint_start="api"):
child.method = method
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
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")
@ -510,6 +554,10 @@ def gen_api_map(app, endpoint_start="api"):
+ "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):
"""
@ -614,9 +662,47 @@ 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
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
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:
@ -638,10 +724,18 @@ def _get_doc_lines(keyword, doc) -> list[str]:
return []
# On récupère les lignes de la doc qui correspondent au keyword (enfin on espère)
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
def parse_doc_name(doc):
def parse_doc_name(doc_string: str) -> str:
"""
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 -----
"""
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):
def parse_query_doc(doc_string: str) -> dict[str, str]:
"""
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
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+):(<.+>)$")
@ -691,33 +785,178 @@ def parse_query_doc(doc):
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__":
# Exemple d'utilisation de la classe Token
# Exemple simple de création d'un arbre de Token
root = Token("api")
child1 = Token("assiduites", leaf=True)
child1.func_name = "assiduites_get"
child2 = Token("count")
child22 = Token("all")
child23 = Token(
"query",
query={
"etat": "<string:etat>",
"moduleimpl_id": "<int:moduleimpl_id>",
"count": "<int:count>",
"formsemestre_id": "<int:formsemestre_id>",
},
# root = Token("api")
# child1 = Token("assiduites", leaf=True)
# child1.func_name = "assiduites_get"
# child2 = Token("count")
# child22 = Token("all")
# child23 = Token(
# "query",
# query={
# "etat": "<string:etat>",
# "moduleimpl_id": "<int:moduleimpl_id>",
# "count": "<int:count>",
# "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)
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")
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}"
)