From c37a92aa5ca7a0f2713664c53c617f74f4de5f25 Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 20 Jun 2024 15:48:11 +0200 Subject: [PATCH 1/4] WIP : gen_api_map (no query) --- scodoc.py | 11 +- tools/__init__.py | 1 + tools/create_api_map.py | 422 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 tools/create_api_map.py diff --git a/scodoc.py b/scodoc.py index 1b5b8eccf..148916875 100755 --- a/scodoc.py +++ b/scodoc.py @@ -1,10 +1,8 @@ # -*- coding: UTF-8 -*- -"""Application Flask: ScoDoc +"""Application Flask: ScoDoc""" - -""" import datetime from pprint import pprint as pp import re @@ -723,3 +721,10 @@ def generate_ens_calendars(): # generate-ens-calendars from tools.edt import edt_ens edt_ens.generate_ens_calendars() + + +@app.cli.command() +@with_appcontext +def gen_api_map(): + """Show the API map""" + tools.gen_api_map(app) diff --git a/tools/__init__.py b/tools/__init__.py index da9214bfa..c7c13e6bd 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -10,3 +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 diff --git a/tools/create_api_map.py b/tools/create_api_map.py new file mode 100644 index 000000000..5f002d3fe --- /dev/null +++ b/tools/create_api_map.py @@ -0,0 +1,422 @@ +import xml.etree.ElementTree as ET + + +class COLORS: + BLUE = "rgb(114,159,207)" + GREEN = "rgb(165,214,165)" + PINK = "rgb(230,156,190)" + GREY = "rgb(224,224,224)" + ORANGE = "rgb(253,191,111)" + + +class Token: + def __init__(self, name, method="GET", query=None, leaf=False): + self.children: list["Token"] = [] + self.name: str = name + self.method: str = method + self.query: dict[str, str] = query or {} + self.force_leaf: bool = leaf + + def add_child(self, child): + self.children.append(child) + + def find_child(self, name): + for child in self.children: + if child.name == name: + return child + return None + + def __repr__(self, level=0): + ret = "\t" * level + f"({self.name})\n" + for child in self.children: + ret += child.__repr__(level + 1) + return ret + + def is_leaf(self): + return len(self.children) == 0 + + def get_height(self, y_step): + # Calculer la hauteur totale des enfants + children_height = sum(child.get_height(y_step) for child in self.children) + + # Calculer la hauteur des éléments de la query + query_height = len(self.query) * y_step + + # La hauteur totale est la somme de la hauteur des enfants et des éléments de la query + return children_height + query_height + y_step + + def to_svg_group( + self, + x_offset=0, + y_offset=0, + x_step=150, + y_step=50, + parent_coords=None, + parent_children_nb=0, + ): + group = ET.Element("g") + color = COLORS.BLUE + if self.force_leaf or self.is_leaf(): + if self.method == "GET": + color = COLORS.GREEN + elif self.method == "POST": + color = COLORS.PINK + + element = _create_svg_element(self.name, color) + element.set("transform", f"translate({x_offset}, {y_offset})") + current_start_coords, current_end_coords = _get_anchor_coords( + element, x_offset, y_offset + ) + group.append(element) + + # Add an arrow from the parent element to the current element + if parent_coords and parent_children_nb > 1: + arrow = _create_arrow(parent_coords, current_start_coords) + group.append(arrow) + + if not self.is_leaf(): + slash_group = _create_svg_element("/", COLORS.GREY) + slash_group.set( + "transform", + f"translate({x_offset + _get_element_width(element)}, {y_offset})", + ) + group.append(slash_group) + if self.is_leaf() and self.query: + slash_group = _create_svg_element("?", COLORS.GREY) + slash_group.set( + "transform", + f"translate({x_offset + _get_element_width(element)}, {y_offset})", + ) + group.append(slash_group) + current_end_coords = _get_anchor_coords(group, 0, 0)[1] + + query_y_offset = y_offset + ampersand_start_coords = None + ampersand_end_coords = None + for key, value in self.query.items(): + # = + translate_x = x_offset + x_step + + # + param_el = _create_svg_element(key, COLORS.BLUE) + param_el.set( + "transform", + f"translate({translate_x}, {query_y_offset})", + ) + group.append(param_el) + + # add Arrow from "query" to element + coords = ( + current_end_coords, + _get_anchor_coords(param_el, translate_x, query_y_offset)[0], + ) + group.append(_create_arrow(*coords)) + + # = + equal_el = _create_svg_element("=", COLORS.GREY) + translate_x = x_offset + x_step + _get_element_width(param_el) + equal_el.set( + "transform", + f"translate({translate_x}, {query_y_offset})", + ) + group.append(equal_el) + + # + value_el = _create_svg_element(value, COLORS.GREEN) + translate_x = ( + x_offset + + x_step + + sum(_get_element_width(el) for el in [param_el, equal_el]) + ) + value_el.set( + "transform", + f"translate({translate_x}, {query_y_offset})", + ) + group.append(value_el) + if len(self.query) == 1: + continue + ampersand_group = _create_svg_element("&", "rgb(224,224,224)") + translate_x = ( + x_offset + + x_step + + sum(_get_element_width(el) for el in [param_el, equal_el, value_el]) + ) + ampersand_group.set( + "transform", + f"translate({translate_x}, {query_y_offset})", + ) + group.append(ampersand_group) + + # Track the start and end coordinates of the ampersands + if ampersand_start_coords is None: + ampersand_start_coords = _get_anchor_coords( + ampersand_group, translate_x, query_y_offset + )[1] + ampersand_end_coords = _get_anchor_coords( + ampersand_group, translate_x, query_y_offset + )[1] + # Draw line connecting all ampersands + if ampersand_start_coords and ampersand_end_coords and len(self.query) > 1: + line = _create_line(ampersand_start_coords, ampersand_end_coords) + group.append(line) + + query_y_offset += y_step + + y_offset = query_y_offset + + current_y_offset = y_offset + + for child in self.children: + rel_x_offset = x_offset + _get_group_width(group) + if len(self.children) > 1: + rel_x_offset += x_step + child_group = child.to_svg_group( + rel_x_offset, + current_y_offset, + x_step, + y_step, + parent_coords=current_end_coords, + parent_children_nb=len(self.children), + ) + group.append(child_group) + current_y_offset += child.get_height(y_step) + + return group + + +def _create_line(start_coords, end_coords): + start_x, start_y = start_coords + end_x, end_y = end_coords + + path_data = f"M {start_x},{start_y} L {start_x + 20},{start_y} L {start_x + 20},{end_y} L {end_x},{end_y}" + + path = ET.Element( + "path", + { + "d": path_data, + "style": "stroke:black;stroke-width:2;fill:none", + }, + ) + return path + + +def _create_svg_element(text, color="rgb(230,156,190)"): + # Dimensions and styling + padding = 5 + font_size = 16 + rect_height = 30 + rect_x = 10 + rect_y = 20 + + # Estimate the text width + text_width = ( + len(text) * font_size * 0.6 + ) # Estimate based on average character width + rect_width = text_width + padding * 2 + + # Create the SVG group element + group = ET.Element("g") + + # Create the rectangle + ET.SubElement( + group, + "rect", + { + "x": str(rect_x), + "y": str(rect_y), + "width": str(rect_width), + "height": str(rect_height), + "style": f"fill:{color};stroke:black;stroke-width:2;fill-opacity:1;stroke-opacity:1", + }, + ) + + # Create the text element + text_element = ET.SubElement( + group, + "text", + { + "x": str(rect_x + padding), + "y": str( + rect_y + rect_height / 2 + font_size / 2.5 + ), # Adjust to vertically center the text + "font-family": "Courier New, monospace", + "font-size": str(font_size), + "fill": "black", + "style": "white-space: pre;", + }, + ) + text_element.text = text + + return group + + +def _get_anchor_coords(element, x_offset, y_offset): + bbox = _get_bbox(element, x_offset, y_offset) + startX = bbox["x_min"] + endX = bbox["x_max"] + y = bbox["y_min"] + (bbox["y_max"] - bbox["y_min"]) / 2 + return (startX, y), (endX, y) + + +def _create_arrow(start_coords, end_coords): + start_x, start_y = start_coords + end_x, end_y = end_coords + mid_x = (start_x + end_x) / 2 + + path_data = ( + f"M {start_x},{start_y} L {mid_x},{start_y} L {mid_x},{end_y} L {end_x},{end_y}" + ) + + path = ET.Element( + "path", + { + "d": path_data, + "style": "stroke:black;stroke-width:2;fill:none", + "marker-end": "url(#arrowhead)", + }, + ) + return path + + +def _get_element_width(element): + rect = element.find("rect") + if rect is not None: + return float(rect.get("width", 0)) + return 0 + + +def _get_group_width(group): + return sum(_get_element_width(child) for child in group) + + +def gen_api_map(app): + api_map = Token("") + for rule in app.url_map.iter_rules(): + # On ne garde que les routes de l'API / APIWEB + if not rule.endpoint.lower().startswith("api"): + continue + + segments = rule.rule.strip("/").split("/") + current_token = api_map + + for i, segment in enumerate(segments): + # Check if the segment already exists in the current level + child = current_token.find_child(segment) + if child is None: + # If it's the last segment and it has query parameters + if i == len(segments) - 1 and segment == "query": + child = Token( + segment, + leaf=True, + ) + # TODO Parse QUERY doc + else: + child = Token(segment) + method: str = "POST" if "POST" in rule.methods else "GET" + child.method = method + current_token.add_child(child) + current_token = child + + # Mark the last segment as a leaf node if it's not already marked + if not current_token.is_leaf(): + current_token.force_leaf = True + + generate_svg(api_map.to_svg_group(), "/tmp/api_map.svg") + + +def _get_bbox(element, x_offset=0, y_offset=0): + # Helper function to calculate the bounding box of an SVG element + bbox = { + "x_min": float("inf"), + "y_min": float("inf"), + "x_max": float("-inf"), + "y_max": float("-inf"), + } + + for child in element: + transform = child.get("transform") + child_x_offset = x_offset + child_y_offset = y_offset + + if transform: + translate = transform.replace("translate(", "").replace(")", "").split(",") + if len(translate) == 2: + child_x_offset += float(translate[0]) + child_y_offset += float(translate[1]) + + if child.tag == "rect": + x = child_x_offset + float(child.get("x", 0)) + y = child_y_offset + float(child.get("y", 0)) + width = float(child.get("width", 0)) + height = float(child.get("height", 0)) + bbox["x_min"] = min(bbox["x_min"], x) + bbox["y_min"] = min(bbox["y_min"], y) + bbox["x_max"] = max(bbox["x_max"], x + width) + bbox["y_max"] = max(bbox["y_max"], y + height) + + if len(child): + child_bbox = _get_bbox(child, child_x_offset, child_y_offset) + bbox["x_min"] = min(bbox["x_min"], child_bbox["x_min"]) + bbox["y_min"] = min(bbox["y_min"], child_bbox["y_min"]) + bbox["x_max"] = max(bbox["x_max"], child_bbox["x_max"]) + bbox["y_max"] = max(bbox["y_max"], child_bbox["y_max"]) + + return bbox + + +def generate_svg(element, fname): + bbox = _get_bbox(element) + width = bbox["x_max"] - bbox["x_min"] + 20 # Add some padding + height = bbox["y_max"] - bbox["y_min"] + 20 # Add some padding + + svg = ET.Element( + "svg", + { + "width": str(width), + "height": str(height), + "xmlns": "http://www.w3.org/2000/svg", + "viewBox": f"{bbox['x_min'] - 10} {bbox['y_min'] - 10} {width} {height}", + }, + ) + + # Define the marker for the arrowhead + defs = ET.SubElement(svg, "defs") + marker = ET.SubElement( + defs, + "marker", + { + "id": "arrowhead", + "markerWidth": "10", + "markerHeight": "7", + "refX": "10", + "refY": "3.5", + "orient": "auto", + }, + ) + ET.SubElement(marker, "polygon", {"points": "0 0, 10 3.5, 0 7"}) + + svg.append(element) + + tree = ET.ElementTree(svg) + tree.write(fname, encoding="utf-8", xml_declaration=True) + + +if __name__ == "__main__": + root = Token("api") + child1 = Token("assiduites", leaf=True) + child2 = Token("count") + child22 = Token("all") + child23 = Token( + "query", query={"param1": "value1", "param2": "value2", "param3": "value3"} + ) + child3 = Token("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") From 06727f1b9b2ceb2dc28b892178db9228ab224b42 Mon Sep 17 00:00:00 2001 From: Iziram Date: Thu, 20 Jun 2024 17:56:12 +0200 Subject: [PATCH 2/4] gen_api_map fini + annotation QUERY assiduites/justificatifs --- app/api/assiduites.py | 58 ++++++++++++- app/api/justificatifs.py | 39 ++++++++- tools/create_api_map.py | 181 ++++++++++++++++++++++++++++----------- 3 files changed, 221 insertions(+), 57 deletions(-) diff --git a/app/api/assiduites.py b/app/api/assiduites.py index 3f3aee5d3..7d46ddfe4 100644 --- a/app/api/assiduites.py +++ b/app/api/assiduites.py @@ -161,8 +161,17 @@ def count_assiduites( query?est_just=f query?est_just=t - - + QUERY + ----- + user_id: + est_just: + moduleimpl_id: + date_debut: + date_fin: + etat: + formsemestre_id: + metric: + split: """ @@ -253,6 +262,15 @@ def assiduites(etudid: int = None, nip=None, ine=None, with_query: bool = False) query?est_just=f query?est_just=t + QUERY + ----- + user_id: + est_just: + moduleimpl_id: + date_debut: + date_fin: + etat: + formsemestre_id: """ @@ -329,6 +347,16 @@ def assiduites_group(with_query: bool = False): query?est_just=f query?est_just=t + QUERY + ----- + user_id: + est_just: + moduleimpl_id: + date_debut: + date_fin: + etat: + etudids: """ @@ -388,7 +416,16 @@ def assiduites_group(with_query: bool = False): @as_json @permission_required(Permission.ScoView) def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): - """Retourne toutes les assiduités du formsemestre""" + """Retourne toutes les assiduités du formsemestre + QUERY + ----- + user_id: + est_just: + moduleimpl_id: + date_debut: + date_fin: + etat: + """ # Récupération du formsemestre à partir du formsemestre_id formsemestre: FormSemestre = None @@ -438,7 +475,20 @@ def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False): def count_assiduites_formsemestre( formsemestre_id: int = None, with_query: bool = False ): - """Comptage des assiduités du formsemestre""" + """Comptage des assiduités du formsemestre + + QUERY + ----- + user_id: + est_just: + moduleimpl_id: + date_debut: + date_fin: + etat: + formsemestre_id: + metric: + split: + """ # Récupération du formsemestre à partir du formsemestre_id formsemestre: FormSemestre = None diff --git a/app/api/justificatifs.py b/app/api/justificatifs.py index b3528362a..ad77f54cd 100644 --- a/app/api/justificatifs.py +++ b/app/api/justificatifs.py @@ -3,8 +3,8 @@ # Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## -"""ScoDoc 9 API : Justificatifs -""" +"""ScoDoc 9 API : Justificatifs""" + from datetime import datetime from flask_json import as_json @@ -113,6 +113,16 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal user_id (l'id de l'auteur du justificatif) query?user_id=[int] ex query?user_id=3 + QUERY + ----- + user_id: + est_just: + date_debut: + date_fin: + etat: + order: + courant: + group_id: """ # Récupération de l'étudiant etud: Identite = tools.get_etud(etudid, nip, ine) @@ -154,6 +164,17 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False): """ Renvoie tous les justificatifs d'un département (en ajoutant un champ "formsemestre" si possible) + + QUERY + ----- + user_id: + est_just: + date_debut: + date_fin: + etat: + order: + courant: + group_id: """ # Récupération du département et des étudiants du département @@ -225,7 +246,19 @@ def _set_sems(justi: Justificatif, restrict: bool) -> dict: @as_json @permission_required(Permission.ScoView) def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False): - """Retourne tous les justificatifs du formsemestre""" + """Retourne tous les justificatifs du formsemestre + + QUERY + ----- + user_id: + est_just: + date_debut: + date_fin: + etat: + order: + courant: + group_id: + """ # Récupération du formsemestre formsemestre: FormSemestre = None diff --git a/tools/create_api_map.py b/tools/create_api_map.py index 5f002d3fe..f1e793c0a 100644 --- a/tools/create_api_map.py +++ b/tools/create_api_map.py @@ -1,4 +1,5 @@ import xml.etree.ElementTree as ET +import re class COLORS: @@ -6,16 +7,16 @@ class COLORS: GREEN = "rgb(165,214,165)" PINK = "rgb(230,156,190)" GREY = "rgb(224,224,224)" - ORANGE = "rgb(253,191,111)" class Token: - def __init__(self, name, method="GET", query=None, leaf=False): + def __init__(self, name, method="GET", query=None, leaf=False, func_name=""): self.children: list["Token"] = [] self.name: str = name self.method: str = method self.query: dict[str, str] = query or {} self.force_leaf: bool = leaf + self.func_name = "" def add_child(self, child): self.children.append(child) @@ -56,17 +57,23 @@ class Token: ): group = ET.Element("g") color = COLORS.BLUE - if self.force_leaf or self.is_leaf(): + if self.is_leaf(): if self.method == "GET": color = COLORS.GREEN elif self.method == "POST": color = COLORS.PINK + # if self.force_leaf and not self.is_leaf(): + # color = COLORS.ORANGE element = _create_svg_element(self.name, color) element.set("transform", f"translate({x_offset}, {y_offset})") current_start_coords, current_end_coords = _get_anchor_coords( element, x_offset, y_offset ) + href = "#" + self.func_name.replace("_", "-") + if self.query: + href += "-query" + question_mark_group = _create_question_mark_group(current_end_coords, href) group.append(element) # Add an arrow from the parent element to the current element @@ -91,9 +98,10 @@ class Token: current_end_coords = _get_anchor_coords(group, 0, 0)[1] query_y_offset = y_offset - ampersand_start_coords = None - ampersand_end_coords = None + query_sub_element = ET.Element("g") for key, value in self.query.items(): + sub_group = ET.Element("g") + # = translate_x = x_offset + x_step @@ -103,14 +111,14 @@ class Token: "transform", f"translate({translate_x}, {query_y_offset})", ) - group.append(param_el) + sub_group.append(param_el) # add Arrow from "query" to element coords = ( current_end_coords, _get_anchor_coords(param_el, translate_x, query_y_offset)[0], ) - group.append(_create_arrow(*coords)) + sub_group.append(_create_arrow(*coords)) # = equal_el = _create_svg_element("=", COLORS.GREY) @@ -119,7 +127,7 @@ class Token: "transform", f"translate({translate_x}, {query_y_offset})", ) - group.append(equal_el) + sub_group.append(equal_el) # value_el = _create_svg_element(value, COLORS.GREEN) @@ -132,7 +140,7 @@ class Token: "transform", f"translate({translate_x}, {query_y_offset})", ) - group.append(value_el) + sub_group.append(value_el) if len(self.query) == 1: continue ampersand_group = _create_svg_element("&", "rgb(224,224,224)") @@ -145,27 +153,15 @@ class Token: "transform", f"translate({translate_x}, {query_y_offset})", ) - group.append(ampersand_group) - - # Track the start and end coordinates of the ampersands - if ampersand_start_coords is None: - ampersand_start_coords = _get_anchor_coords( - ampersand_group, translate_x, query_y_offset - )[1] - ampersand_end_coords = _get_anchor_coords( - ampersand_group, translate_x, query_y_offset - )[1] - # Draw line connecting all ampersands - if ampersand_start_coords and ampersand_end_coords and len(self.query) > 1: - line = _create_line(ampersand_start_coords, ampersand_end_coords) - group.append(line) + sub_group.append(ampersand_group) query_y_offset += y_step + query_sub_element.append(sub_group) + group.append(query_sub_element) + y_offset = query_y_offset - current_y_offset = y_offset - for child in self.children: rel_x_offset = x_offset + _get_group_width(group) if len(self.children) > 1: @@ -181,25 +177,13 @@ class Token: group.append(child_group) current_y_offset += child.get_height(y_step) + # add `?` circle a:href to element + if self.force_leaf or self.is_leaf(): + group.append(question_mark_group) + return group -def _create_line(start_coords, end_coords): - start_x, start_y = start_coords - end_x, end_y = end_coords - - path_data = f"M {start_x},{start_y} L {start_x + 20},{start_y} L {start_x + 20},{end_y} L {end_x},{end_y}" - - path = ET.Element( - "path", - { - "d": path_data, - "style": "stroke:black;stroke-width:2;fill:none", - }, - ) - return path - - def _create_svg_element(text, color="rgb(230,156,190)"): # Dimensions and styling padding = 5 @@ -289,6 +273,51 @@ def _get_group_width(group): return sum(_get_element_width(child) for child in group) +def _create_question_mark_group(coords, href): + x, y = coords + radius = 10 # Radius of the circle + y -= radius * 2 + font_size = 17 # Font size of the question mark + + group = ET.Element("g") + + # Create the circle + ET.SubElement( + group, + "circle", + { + "cx": str(x), + "cy": str(y), + "r": str(radius), + "fill": COLORS.GREY, + "stroke": "black", + "stroke-width": "2", + }, + ) + + # Create the link element + link = ET.Element("a", {"href": href}) + + # Create the text element + text_element = ET.SubElement( + link, + "text", + { + "x": str(x + 1), + "y": str(y + font_size / 3), # Adjust to vertically center the text + "text-anchor": "middle", # Center the text horizontally + "font-family": "Arial", + "font-size": str(font_size), + "fill": "black", + }, + ) + text_element.text = "?" + + group.append(link) + + return group + + def gen_api_map(app): api_map = Token("") for rule in app.url_map.iter_rules(): @@ -303,15 +332,19 @@ def gen_api_map(app): # Check if the segment already exists in the current level child = current_token.find_child(segment) if child is None: - # If it's the last segment and it has query parameters - if i == len(segments) - 1 and segment == "query": + func = app.view_functions[rule.endpoint] + # If it's the last segment + if i == len(segments) - 1: child = Token( segment, leaf=True, + query=parse_query_doc(func.__doc__ or ""), ) - # TODO Parse QUERY doc else: - child = Token(segment) + child = Token( + segment, + ) + child.func_name = func.__name__ method: str = "POST" if "POST" in rule.methods else "GET" child.method = method current_token.add_child(child) @@ -322,6 +355,10 @@ def gen_api_map(app): current_token.force_leaf = True generate_svg(api_map.to_svg_group(), "/tmp/api_map.svg") + print( + "La carte a été générée avec succès. " + + "Vous pouvez la consulter à l'adresse suivante : /tmp/api_map.svg" + ) def _get_bbox(element, x_offset=0, y_offset=0): @@ -366,8 +403,8 @@ def _get_bbox(element, x_offset=0, y_offset=0): def generate_svg(element, fname): bbox = _get_bbox(element) - width = bbox["x_max"] - bbox["x_min"] + 20 # Add some padding - height = bbox["y_max"] - bbox["y_min"] + 20 # Add some padding + width = bbox["x_max"] - bbox["x_min"] + 80 # Add some padding + height = bbox["y_max"] - bbox["y_min"] + 80 # Add some padding svg = ET.Element( "svg", @@ -401,15 +438,59 @@ def generate_svg(element, fname): tree.write(fname, encoding="utf-8", xml_declaration=True) +def parse_query_doc(doc): + """ + renvoie un dictionnaire {param: } (ex: {assiduite_id : }) + + La doc doit contenir des lignes de la forme: + + QUERY + ----- + param: + param1: + param2: + + 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 + """ + + lines = [line.strip() for line in doc.split("\n")] + try: + query_index = lines.index("QUERY") + if lines[query_index + 1] != "-----": + return {} + except ValueError: + return {} + + query_lines = lines[query_index + 2 :] + + query = {} + regex = re.compile(r"^(\w+):(<.+>)$") + for line in query_lines: + parts = regex.match(line) + if not parts: + break + param, type_nom_param = parts.groups() + query[param] = type_nom_param + + return query + + if __name__ == "__main__": root = Token("api") - child1 = Token("assiduites", leaf=True) + child1 = Token("assiduites", leaf=True, func_name="assiduites_get") child2 = Token("count") child22 = Token("all") child23 = Token( - "query", query={"param1": "value1", "param2": "value2", "param3": "value3"} + "query", + query={ + "etat": "", + "moduleimpl_id": "", + "count": "", + "formsemestre_id": "", + }, ) - child3 = Token("justificatifs", "POST") + child3 = Token("justificatifs", "POST", func_name="justificatifs_post") root.add_child(child1) child1.add_child(child2) From de47277e7cc4e85260191632be45dfa4c0882e38 Mon Sep 17 00:00:00 2001 From: Iziram Date: Fri, 21 Jun 2024 09:28:47 +0200 Subject: [PATCH 3/4] gen_api_map: compression espace vertical --- tools/create_api_map.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tools/create_api_map.py b/tools/create_api_map.py index f1e793c0a..f0383e66a 100644 --- a/tools/create_api_map.py +++ b/tools/create_api_map.py @@ -10,7 +10,7 @@ class COLORS: class Token: - def __init__(self, name, method="GET", query=None, leaf=False, func_name=""): + def __init__(self, name, method="GET", query=None, leaf=False): self.children: list["Token"] = [] self.name: str = name self.method: str = method @@ -41,10 +41,13 @@ class Token: children_height = sum(child.get_height(y_step) for child in self.children) # Calculer la hauteur des éléments de la query - query_height = len(self.query) * y_step + query_height = len(self.query) * (y_step * 1.33) # La hauteur totale est la somme de la hauteur des enfants et des éléments de la query - return children_height + query_height + y_step + height = children_height + query_height + if height == 0: + height = y_step + return height def to_svg_group( self, @@ -62,8 +65,6 @@ class Token: color = COLORS.GREEN elif self.method == "POST": color = COLORS.PINK - # if self.force_leaf and not self.is_leaf(): - # color = COLORS.ORANGE element = _create_svg_element(self.name, color) element.set("transform", f"translate({x_offset}, {y_offset})") @@ -155,7 +156,7 @@ class Token: ) sub_group.append(ampersand_group) - query_y_offset += y_step + query_y_offset += y_step * 1.33 query_sub_element.append(sub_group) group.append(query_sub_element) @@ -478,7 +479,8 @@ def parse_query_doc(doc): if __name__ == "__main__": root = Token("api") - child1 = Token("assiduites", leaf=True, func_name="assiduites_get") + child1 = Token("assiduites", leaf=True) + child1.func_name = "assiduites_get" child2 = Token("count") child22 = Token("all") child23 = Token( @@ -490,7 +492,8 @@ if __name__ == "__main__": "formsemestre_id": "", }, ) - child3 = Token("justificatifs", "POST", func_name="justificatifs_post") + child3 = Token("justificatifs", "POST") + child3.func_name = "justificatifs_post" root.add_child(child1) child1.add_child(child2) From 9b89ca436e5498b60f25c837020e4f1c38359f9d Mon Sep 17 00:00:00 2001 From: Iziram Date: Fri, 21 Jun 2024 15:38:44 +0200 Subject: [PATCH 4/4] =?UTF-8?q?gen=5Fapi=5Fmap=20:=20commentaire=20+=20g?= =?UTF-8?q?=C3=A9n=C3=A9ralisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scodoc.py | 12 +- tools/create_api_map.py | 285 +++++++++++++++++++++++++++++++++------- 2 files changed, 244 insertions(+), 53 deletions(-) diff --git a/scodoc.py b/scodoc.py index 148916875..2e2f06d88 100755 --- a/scodoc.py +++ b/scodoc.py @@ -724,7 +724,13 @@ def generate_ens_calendars(): # generate-ens-calendars @app.cli.command() +@click.option( + "-e", + "--endpoint", + default="api", + help="Endpoint à partir duquel générer la carte des routes", +) @with_appcontext -def gen_api_map(): - """Show the API map""" - tools.gen_api_map(app) +def gen_api_map(endpoint): + """Génère la carte des routes de l'API.""" + tools.gen_api_map(app, endpoint_start=endpoint) diff --git a/tools/create_api_map.py b/tools/create_api_map.py index f0383e66a..953f384a6 100644 --- a/tools/create_api_map.py +++ b/tools/create_api_map.py @@ -1,15 +1,48 @@ +""" +Script permettant de générer une carte SVG de l'API de ScoDoc + +Écrit par Matthias HARTMANN +""" + import xml.etree.ElementTree as ET import re class COLORS: - BLUE = "rgb(114,159,207)" - GREEN = "rgb(165,214,165)" - PINK = "rgb(230,156,190)" - GREY = "rgb(224,224,224)" + """ + Couleurs utilisées pour les éléments de la carte + """ + + BLUE = "rgb(114,159,207)" # Couleur de base / élément simple + GREEN = "rgb(165,214,165)" # Couleur route GET / valeur query + PINK = "rgb(230,156,190)" # Couleur route POST + GREY = "rgb(224,224,224)" # Couleur séparateur class Token: + """ + Classe permettant de représenter un élément de l'API + Exemple : + + /ScoDoc/api/test + + Token(ScoDoc)-> Token(api) -> Token(test) + + Chaque token peut avoir des enfants (Token) et des paramètres de query + Chaque token dispose + d'un nom (texte écrit dans le rectangle), + d'une méthode (GET ou POST) par défaut GET, + d'une func_name (nom de la fonction associée à ce token) + [OPTIONNEL] d'une query (dictionnaire {param: }) + + Un token est une leaf si il n'a pas d'enfants. + Une LEAF possède un `?` renvoyant vers la doc de la route + + Il est possible de forcer un token à être une pseudo LEAF en mettant force_leaf=True + Une PSEUDO LEAF possède aussi un `?` renvoyant vers la doc de la route + tout en ayant des enfants. + """ + def __init__(self, name, method="GET", query=None, leaf=False): self.children: list["Token"] = [] self.name: str = name @@ -19,24 +52,42 @@ class Token: self.func_name = "" def add_child(self, child): + """ + Ajoute un enfant à ce token + """ self.children.append(child) def find_child(self, name): + """ + Renvoie l'enfant portant le nom `name` ou None si aucun enfant ne correspond + """ for child in self.children: if child.name == name: return child return None def __repr__(self, level=0): + """ + représentation textuelle simplifiée de l'arbre + (ne prend pas en compte les query, les méthodes, les func_name, ...) + """ ret = "\t" * level + f"({self.name})\n" for child in self.children: ret += child.__repr__(level + 1) return ret def is_leaf(self): + """ + Renvoie True si le token est une leaf, False sinon + (i.e. s'il n'a pas d'enfants) + (force_leaf n'est pas pris en compte ici) + """ return len(self.children) == 0 def get_height(self, y_step): + """ + Renvoie la hauteur de l'élément en prenant en compte la hauteur de ses enfants + """ # Calculer la hauteur totale des enfants children_height = sum(child.get_height(y_step) for child in self.children) @@ -51,14 +102,19 @@ class Token: def to_svg_group( self, - x_offset=0, - y_offset=0, - x_step=150, - y_step=50, - parent_coords=None, - parent_children_nb=0, + x_offset: int = 0, + y_offset: int = 0, + x_step: int = 150, + y_step: int = 50, + parent_coords: tuple[tuple[int, int], tuple[int, int]] = None, + parent_children_nb: int = 0, ): - group = ET.Element("g") + """ + Transforme un token en un groupe SVG + (récursif, appelle la fonction sur ses enfants) + """ + + group = ET.Element("g") # groupe principal color = COLORS.BLUE if self.is_leaf(): if self.method == "GET": @@ -66,22 +122,27 @@ class Token: elif self.method == "POST": color = COLORS.PINK + # Création du rectangle avec le nom du token et placement sur la carte element = _create_svg_element(self.name, color) element.set("transform", f"translate({x_offset}, {y_offset})") + group.append(element) + + # On récupère les coordonnées de début et de fin de l'élément pour les flèches current_start_coords, current_end_coords = _get_anchor_coords( element, x_offset, y_offset ) + # Préparation du lien vers la doc de la route href = "#" + self.func_name.replace("_", "-") if self.query: href += "-query" question_mark_group = _create_question_mark_group(current_end_coords, href) - group.append(element) - # Add an arrow from the parent element to the current element + # Ajout de la flèche partant du parent jusqu'à l'élément courant if parent_coords and parent_children_nb > 1: arrow = _create_arrow(parent_coords, current_start_coords) group.append(arrow) + # Ajout du `/` si le token n'est pas une leaf (ne prend pas en compte force_leaf) if not self.is_leaf(): slash_group = _create_svg_element("/", COLORS.GREY) slash_group.set( @@ -89,6 +150,7 @@ class Token: f"translate({x_offset + _get_element_width(element)}, {y_offset})", ) group.append(slash_group) + # Ajout du `?` si le token est une leaf et possède une query if self.is_leaf() and self.query: slash_group = _create_svg_element("?", COLORS.GREY) slash_group.set( @@ -96,17 +158,23 @@ class Token: f"translate({x_offset + _get_element_width(element)}, {y_offset})", ) group.append(slash_group) + + # Actualisation des coordonnées de fin current_end_coords = _get_anchor_coords(group, 0, 0)[1] + # Gestion des éléments de la query + # Pour chaque élément on va créer : + # (param) (=) (valeur) (&) query_y_offset = y_offset query_sub_element = ET.Element("g") for key, value in self.query.items(): + # Création d'un sous-groupe pour chaque élément de la query sub_group = ET.Element("g") - # = + # On décale l'élément de la query vers la droite par rapport à l'élément parent translate_x = x_offset + x_step - # + # création élément (param) param_el = _create_svg_element(key, COLORS.BLUE) param_el.set( "transform", @@ -114,15 +182,16 @@ class Token: ) sub_group.append(param_el) - # add Arrow from "query" to element + # Ajout d'une flèche partant de l'élément "query" vers le paramètre courant coords = ( current_end_coords, _get_anchor_coords(param_el, translate_x, query_y_offset)[0], ) sub_group.append(_create_arrow(*coords)) - # = + # création élément (=) equal_el = _create_svg_element("=", COLORS.GREY) + # On met à jour le décalage en fonction de l'élément précédent translate_x = x_offset + x_step + _get_element_width(param_el) equal_el.set( "transform", @@ -130,8 +199,9 @@ class Token: ) sub_group.append(equal_el) - # + # création élément (value) value_el = _create_svg_element(value, COLORS.GREEN) + # On met à jour le décalage en fonction des éléments précédents translate_x = ( x_offset + x_step @@ -142,9 +212,13 @@ class Token: f"translate({translate_x}, {query_y_offset})", ) sub_group.append(value_el) + # Si il y a qu'un seul élément dans la query, on ne met pas de `&` if len(self.query) == 1: continue + + # création élément (&) ampersand_group = _create_svg_element("&", "rgb(224,224,224)") + # On met à jour le décalage en fonction des éléments précédents translate_x = ( x_offset + x_step @@ -156,17 +230,29 @@ class Token: ) sub_group.append(ampersand_group) + # On décale le prochain élément de la query vers le bas query_y_offset += y_step * 1.33 - + # On ajoute le sous-groupe (param = value &) au groupe de la query query_sub_element.append(sub_group) + + # On ajoute le groupe de la query à l'élément principal group.append(query_sub_element) + # Gestion des enfants du Token + + # On met à jour les décalages en fonction des éléments précédents y_offset = query_y_offset current_y_offset = y_offset + + # Pour chaque enfant, on crée un groupe SVG de façon récursive for child in self.children: + # On décale l'enfant vers la droite par rapport à l'élément parent + # Si il n'y a qu'un enfant, alors on colle l'enfant à l'élément parent rel_x_offset = x_offset + _get_group_width(group) if len(self.children) > 1: rel_x_offset += x_step + + # On crée le groupe SVG de l'enfant child_group = child.to_svg_group( rel_x_offset, current_y_offset, @@ -175,10 +261,12 @@ class Token: parent_coords=current_end_coords, parent_children_nb=len(self.children), ) + # On ajoute le groupe de l'enfant au groupe principal group.append(child_group) + # On met à jour le décalage Y en fonction de la hauteur de l'enfant current_y_offset += child.get_height(y_step) - # add `?` circle a:href to element + # Ajout du `?` si le token est une pseudo leaf ou une leaf if self.force_leaf or self.is_leaf(): group.append(question_mark_group) @@ -186,23 +274,32 @@ class Token: def _create_svg_element(text, color="rgb(230,156,190)"): - # Dimensions and styling + """ + Fonction générale pour créer un élément SVG simple + (rectangle avec du texte à l'intérieur) + + text : texte à afficher dans l'élément + color : couleur de l'élément + """ + + # Paramètres de style de l'élément padding = 5 font_size = 16 rect_height = 30 rect_x = 10 rect_y = 20 - # Estimate the text width + # Estimation de la largeur du texte text_width = ( len(text) * font_size * 0.6 - ) # Estimate based on average character width + ) # On suppose que la largeur d'un caractère est 0.6 * font_size + # Largeur du rectangle = Largeur du texte + padding à gauche et à droite rect_width = text_width + padding * 2 - # Create the SVG group element + # Création du groupe SVG group = ET.Element("g") - # Create the rectangle + # Création du rectangle ET.SubElement( group, "rect", @@ -215,7 +312,7 @@ def _create_svg_element(text, color="rgb(230,156,190)"): }, ) - # Create the text element + # Création du texte text_element = ET.SubElement( group, "text", @@ -223,47 +320,66 @@ def _create_svg_element(text, color="rgb(230,156,190)"): "x": str(rect_x + padding), "y": str( rect_y + rect_height / 2 + font_size / 2.5 - ), # Adjust to vertically center the text + ), # Ajustement pour centrer verticalement "font-family": "Courier New, monospace", "font-size": str(font_size), "fill": "black", "style": "white-space: pre;", }, ) + + # Ajout du texte à l'élément text_element.text = text return group def _get_anchor_coords(element, x_offset, y_offset): + """ + Récupération des coordonnées des points d'ancrage d'un élément SVG + (début et fin de l'élément pour les flèches) + (le milieu vertical de l'élément est utilisé pour les flèches) + """ bbox = _get_bbox(element, x_offset, y_offset) startX = bbox["x_min"] endX = bbox["x_max"] + # Milieu vertical de l'élément y = bbox["y_min"] + (bbox["y_max"] - bbox["y_min"]) / 2 return (startX, y), (endX, y) def _create_arrow(start_coords, end_coords): + """ + Création d'une flèche entre deux points + """ + # On récupère les coordonnées de début et de fin de la flèche start_x, start_y = start_coords end_x, end_y = end_coords + # On calcule le milieu horizontal de la flèche mid_x = (start_x + end_x) / 2 + # On crée le chemin de la flèche path_data = ( f"M {start_x},{start_y} L {mid_x},{start_y} L {mid_x},{end_y} L {end_x},{end_y}" ) - + # On crée l'élément path de la flèche path = ET.Element( "path", { "d": path_data, "style": "stroke:black;stroke-width:2;fill:none", - "marker-end": "url(#arrowhead)", + "marker-end": "url(#arrowhead)", # Ajout de la flèche à la fin du path }, ) return path def _get_element_width(element): + """ + Retourne la largueur d'un élément simple + L'élément simple correspond à un rectangle avec du texte à l'intérieur + on récupère la largueur du rectangle + """ rect = element.find("rect") if rect is not None: return float(rect.get("width", 0)) @@ -271,18 +387,27 @@ def _get_element_width(element): def _get_group_width(group): + """ + Récupère la largeur d'un groupe d'éléments + on fait la somme des largeurs de chaque élément du groupe + """ return sum(_get_element_width(child) for child in group) def _create_question_mark_group(coords, href): + """ + Création d'un groupe SVG contenant un cercle et un lien vers la doc de la route + le `?` renvoie vers la doc de la route + """ + # Récupération du point d'ancrage de l'élément x, y = coords - radius = 10 # Radius of the circle + radius = 10 # Rayon du cercle y -= radius * 2 - font_size = 17 # Font size of the question mark + font_size = 17 # Taille de la police group = ET.Element("g") - # Create the circle + # Création du cercle ET.SubElement( group, "circle", @@ -296,17 +421,17 @@ def _create_question_mark_group(coords, href): }, ) - # Create the link element + # Création du lien (a) vers la doc de la route link = ET.Element("a", {"href": href}) - # Create the text element + # Création du texte `?` text_element = ET.SubElement( link, "text", { "x": str(x + 1), - "y": str(y + font_size / 3), # Adjust to vertically center the text - "text-anchor": "middle", # Center the text horizontally + "y": str(y + font_size / 3), # Ajustement pour centrer verticalement + "text-anchor": "middle", # Centrage horizontal "font-family": "Arial", "font-size": str(font_size), "fill": "black", @@ -319,23 +444,49 @@ def _create_question_mark_group(coords, href): return group -def gen_api_map(app): +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 + """ + # Création du token racine api_map = Token("") + + # 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("api"): + if not rule.endpoint.lower().startswith(endpoint_start.lower()): continue + # Transformation de la route en segments + # ex : /ScoDoc/api/test -> ["ScoDoc", "api", "test"] segments = rule.rule.strip("/").split("/") + + # On positionne le token courant sur le token racine current_token = api_map + # Pour chaque segment de la route for i, segment in enumerate(segments): - # Check if the segment already exists in the current level + # On cherche si le segment est déjà un enfant du token courant child = current_token.find_child(segment) + + # Si ce n'est pas le cas on crée un nouveau token et on l'ajoute comme enfant if child is None: func = app.view_functions[rule.endpoint] - # If it's the last segment + # Si c'est le dernier segment, on marque le token comme une leaf + # On utilise force_leaf car il est possible que le token ne soit que + # momentanément une leaf + # ex : + # - /ScoDoc/api/test/ -> ["ScoDoc", "api", "test"] + # - /ScoDoc/api/test/1 -> ["ScoDoc", "api", "test", "1"] + # dans le premier cas test est une leaf, dans le deuxième cas test n'est pas une leaf + # force_leaf permet de forcer le token à être une leaf même s'il a des enfants + # permettant d'afficher le `?` renvoyant vers la doc de la route + # car la route peut être utilisée sans forcément la continuer. + if i == len(segments) - 1: + # Un Token sera query si parse_query_doc retourne un dictionnaire non vide child = Token( segment, leaf=True, @@ -345,16 +496,18 @@ def gen_api_map(app): child = Token( segment, ) + + # On ajoute le token comme enfant du token courant + # en donnant la méthode et le nom de la fonction associée child.func_name = func.__name__ method: str = "POST" if "POST" in rule.methods else "GET" child.method = method current_token.add_child(child) + + # On met à jour le token courant pour le prochain segment current_token = child - # Mark the last segment as a leaf node if it's not already marked - if not current_token.is_leaf(): - current_token.force_leaf = True - + # On génère le SVG à partir de l'arbre de Token generate_svg(api_map.to_svg_group(), "/tmp/api_map.svg") print( "La carte a été générée avec succès. " @@ -363,7 +516,12 @@ def gen_api_map(app): def _get_bbox(element, x_offset=0, y_offset=0): - # Helper function to calculate the bounding box of an SVG element + """ + Récupérer les coordonnées de la boîte englobante d'un élément SVG + Utilisé pour calculer les coordonnées d'un élément SVG et pour avoir la taille + total du SVG + """ + # Initialisation des coordonnées de la boîte englobante bbox = { "x_min": float("inf"), "y_min": float("inf"), @@ -371,17 +529,26 @@ def _get_bbox(element, x_offset=0, y_offset=0): "y_max": float("-inf"), } + # Parcours récursif des enfants de l'élément for child in element: + # On récupère la transformation (position) de l'enfant + # On met les OffSet par défaut à leur valeur donnée en paramètre transform = child.get("transform") child_x_offset = x_offset child_y_offset = y_offset + # Si la transformation est définie, on récupère les coordonnées de translation + # et on les ajoute aux offsets if transform: translate = transform.replace("translate(", "").replace(")", "").split(",") if len(translate) == 2: child_x_offset += float(translate[0]) child_y_offset += float(translate[1]) + # On regarde ensuite la boite englobante de l'enfant + # On met à jour les coordonnées de la boîte englobante en fonction de l'enfant + # x_min, y_min, x_max, y_max. + if child.tag == "rect": x = child_x_offset + float(child.get("x", 0)) y = child_y_offset + float(child.get("y", 0)) @@ -403,10 +570,16 @@ def _get_bbox(element, x_offset=0, y_offset=0): def generate_svg(element, fname): + """ + Génère un fichier SVG à partir d'un élément SVG + """ + # On récupère les dimensions de l'élément racine bbox = _get_bbox(element) - width = bbox["x_max"] - bbox["x_min"] + 80 # Add some padding - height = bbox["y_max"] - bbox["y_min"] + 80 # Add some padding + # On définit la taille du SVG en fonction des dimensions de l'élément racine + width = bbox["x_max"] - bbox["x_min"] + 80 + height = bbox["y_max"] - bbox["y_min"] + 80 + # Création de l'élément racine du SVG svg = ET.Element( "svg", { @@ -417,7 +590,8 @@ def generate_svg(element, fname): }, ) - # Define the marker for the arrowhead + # Création du motif de la flèche pour les liens + # (définition d'un marqueur pour les flèches) defs = ET.SubElement(svg, "defs") marker = ET.SubElement( defs, @@ -433,8 +607,10 @@ def generate_svg(element, fname): ) ET.SubElement(marker, "polygon", {"points": "0 0, 10 3.5, 0 7"}) + # Ajout de l'élément principal à l'élément racine svg.append(element) + # Écriture du fichier SVG tree = ET.ElementTree(svg) tree.write(fname, encoding="utf-8", xml_declaration=True) @@ -454,30 +630,39 @@ 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 """ - + # Récupérer les lignes de la doc lines = [line.strip() for line in doc.split("\n")] + # On cherche la ligne "QUERY" et on vérifie que la ligne suivante est "-----" + # Si ce n'est pas le cas, on renvoie un dictionnaire vide try: query_index = lines.index("QUERY") if lines[query_index + 1] != "-----": return {} except ValueError: return {} - + # On récupère les lignes de la doc qui correspondent à la query (enfin on espère) query_lines = lines[query_index + 2 :] query = {} regex = re.compile(r"^(\w+):(<.+>)$") for line in query_lines: + # On verifie que la ligne respecte le format attendu + # Si ce n'est pas le cas, on arrête de parser parts = regex.match(line) if not parts: break + # On récupère le paramètre et son type:nom param, type_nom_param = parts.groups() + # On ajoute le paramètre au dictionnaire query[param] = type_nom_param return query 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"