From 56e573e6814e8e91f6e2a8d3cb109b8dad7fbc5e Mon Sep 17 00:00:00 2001 From: Angel-Dijoux Date: Fri, 27 Oct 2023 00:50:06 +0200 Subject: [PATCH 1/6] feat: get formations from backend is done. --- .vscode/settings.default.json | 22 +++++++ .vscode/settings.json | 6 +- pyproject.toml | 45 ++++++++++++++ src/blueprints/formations.py | 59 ++++++++++++++----- src/blueprints/route_handler.py | 39 ++++++++++++ src/business_logic/formation/__init__.py | 2 +- .../formation/scrap/get_facets.py | 52 ++++++++++++++++ .../formation/scrap/get_formation.py | 45 ++++++++++++++ src/business_logic/formation/scrap/types.py | 22 +++++++ src/docs/formations/formationByLibelle.yaml | 24 ++++++++ src/docs/formations/searchFormation.yaml | 33 +++++++++++ 11 files changed, 330 insertions(+), 19 deletions(-) create mode 100644 .vscode/settings.default.json create mode 100644 pyproject.toml create mode 100644 src/blueprints/route_handler.py create mode 100644 src/business_logic/formation/scrap/get_facets.py create mode 100644 src/business_logic/formation/scrap/get_formation.py create mode 100644 src/business_logic/formation/scrap/types.py create mode 100644 src/docs/formations/formationByLibelle.yaml create mode 100644 src/docs/formations/searchFormation.yaml diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json new file mode 100644 index 0000000..7ab54dd --- /dev/null +++ b/.vscode/settings.default.json @@ -0,0 +1,22 @@ +{ + "editor.formatOnSave": true, + "editor.formatOnType": false, + "editor.formatOnPaste": true, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": true, + "source.organizeImports": true + } + }, + "python.formatting.provider": "black", + "python.formatting.blackArgs": [ + "--config", + "${workspaceFolder}/pyproject.toml" + ], + "python.analysis.extraPaths": ["backend_flask"], + "python.analysis.autoImportCompletions": true +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 3b77ce3..61c9825 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,3 @@ { - "editor.tabCompletion": "on", - "diffEditor.codeLens": true, - "python.analysis.typeCheckingMode": "basic" -} \ No newline at end of file + "python.analysis.autoImportCompletions": true +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..81dae70 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[tool.isort] +profile = "black" + +[tool.black] +py36 = true +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + + # The following are specific to Black, you probably don't want those. + | blib2to3 + | tests/data + )/ +''' + +[tool.ruff] +ignore = ["E501"] +line-length = 89 +select = [ + "B", + "B9", + "C", + "E", + "F", + "W", +] +target-version = "py39" + +[tool.ruff.mccabe] +max-complexity = 18 + +[tool.ruff.per-file-ignores] +"__init__.py" = [ + "F401", + "F403", +] \ No newline at end of file diff --git a/src/blueprints/formations.py b/src/blueprints/formations.py index c753302..777e45e 100644 --- a/src/blueprints/formations.py +++ b/src/blueprints/formations.py @@ -1,30 +1,61 @@ -from flask import Blueprint, jsonify, Response, abort -from werkzeug.exceptions import HTTPException -from flasgger import swag_from import json from typing import Any, Tuple +from flask import Blueprint, Response, request +from werkzeug.exceptions import HTTPException +from src.blueprints.route_handler import HttpMethod, route_handler +from src.business_logic.formation.scrap.get_formation import ( + get_libelle_type_formation, + search_formations, +) from src.constants.http_status_codes import ( HTTP_200_OK, - HTTP_500_INTERNAL_SERVER_ERROR, ) formations = Blueprint("formations", __name__, url_prefix="/api/v1/formations") -def filter_by_link(formations: list[dict[str, Any]], for_id: str) -> dict[str, Any]: +def _filter_by_link(formations: list[dict[str, Any]], for_id: str) -> dict[str, Any]: filtered_list = list(filter(lambda f: f["identifiant"] == for_id, formations)) return filtered_list[0] if filtered_list else {} -@formations.route("/") -@swag_from("../docs/formations/formation.yaml") +@route_handler( + formations, + "/", + HttpMethod.GET, + "../docs/formations/formation.yaml", +) def get_formation_by_id(id: str) -> Tuple[Response, int] | HTTPException: - try: - with open("assets/formation/data.json", "r") as json_file: - result = filter_by_link(json.load(json_file)["formations"]["formation"], id) - return jsonify(result), HTTP_200_OK if len(result) > 0 else HTTP_200_OK - except Exception as e: - print("Error in get_formation_by_id : ", str(e)) - abort(HTTP_500_INTERNAL_SERVER_ERROR) + with open("assets/formation/data.json", "r") as json_file: + result = _filter_by_link(json.load(json_file)["formations"]["formation"], id) + return result, HTTP_200_OK if len(result) > 0 else HTTP_200_OK + + +@route_handler( + formations, + "/search", + HttpMethod.POST, + "../docs/formations/searchFormation.yaml", +) +def get_search_formation() -> Tuple[Response, int] | HTTPException: + post = request.get_json() + query = post.get("query") + limit = post.get("limit") + offset = post.get("offset") + + return search_formations(query, limit, offset) + + +@route_handler( + formations, + "/formation_by_libelle", + HttpMethod.POST, + "../docs/formations/formationByLibelle.yaml", +) +def get_formation_by_libelle() -> Tuple[Response, int] | HTTPException: + post = request.get_json() + query = post.get("query") + + return get_libelle_type_formation(query) diff --git a/src/blueprints/route_handler.py b/src/blueprints/route_handler.py new file mode 100644 index 0000000..6c1140e --- /dev/null +++ b/src/blueprints/route_handler.py @@ -0,0 +1,39 @@ +from typing import Callable +from flask import Blueprint, jsonify +from flasgger import swag_from +from enum import Enum +from loguru import logger + +from src.constants.http_status_codes import HTTP_200_OK, HTTP_500_INTERNAL_SERVER_ERROR + + +class HttpMethod(Enum): + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + PATCH = "PATCH" + OPTIONS = "OPTIONS" + + +def route_handler( + blueprint: Blueprint, route: str, method: HttpMethod, swag_yaml: str = None +) -> Callable: + def decorator(func: Callable) -> Callable: + @ swag_from(swag_yaml) if swag_yaml else lambda: None + def inner_wrapper(*args, **kwargs): + try: + return jsonify(func(*args, **kwargs)), HTTP_200_OK + except Exception as e: + logger.warning(f"Error in {func.__name__}: {str(e)}") + return "", HTTP_500_INTERNAL_SERVER_ERROR + + # Define a unique endpoint function name based on the route and method + endpoint_name = f"{func.__name__}_{method.value}_{route.replace('/', '_')}" + blueprint.add_url_rule( + route, endpoint_name, inner_wrapper, methods=[method.value] + ) + + return inner_wrapper + + return decorator diff --git a/src/business_logic/formation/__init__.py b/src/business_logic/formation/__init__.py index fad233f..672584a 100644 --- a/src/business_logic/formation/__init__.py +++ b/src/business_logic/formation/__init__.py @@ -1 +1 @@ -ONISEP_URL = "https://api.opendata.onisep.fr/api/1.0/dataset/605344579a7d7" +ONISEP_URL = "https://api.opendata.onisep.fr/api/1.0/dataset/" diff --git a/src/business_logic/formation/scrap/get_facets.py b/src/business_logic/formation/scrap/get_facets.py new file mode 100644 index 0000000..8d270c0 --- /dev/null +++ b/src/business_logic/formation/scrap/get_facets.py @@ -0,0 +1,52 @@ +import requests + +from src.business_logic.formation.scrap.types import Facet +from .. import ONISEP_URL + +# Idéo-Actions de formation initiale-Univers enseignement supérieur +# https://opendata.onisep.fr/data/605344579a7d7/2-ideo-actions-de-formation-initiale-univers-enseignement-superieur.htm +DATASET = "605344579a7d7" + + +def _get_data() -> dict: + url = ONISEP_URL + DATASET + "/search?size=1" + response = requests.get(url) + if response.status_code == 200: + return response.json() + + +def _find_data_by_key(key: str) -> list[Facet]: + data = _get_data() + return data["facets"][key] + + +def get_for_type() -> list[Facet]: + return _find_data_by_key("for_type") + + +def get_nature_du_certificat() -> list[Facet]: + return _find_data_by_key("for_nature_du_certificat") + + +def get_niveau_de_sortie() -> list[Facet]: + return _find_data_by_key("for_niveau_de_sortie") + + +def get_ens_status() -> list[Facet]: + return _find_data_by_key("ens_statut") + + +def get_ens_departement() -> list[Facet]: + return _find_data_by_key("ens_departement") + + +def get_ens_academie() -> list[Facet]: + return _find_data_by_key("ens_academie") + + +def get_ens_region() -> list[Facet]: + return _find_data_by_key("ens_region") + + +def get_duree_cycle() -> list[Facet]: + return _find_data_by_key("af_duree_cycle_standard") diff --git a/src/business_logic/formation/scrap/get_formation.py b/src/business_logic/formation/scrap/get_formation.py new file mode 100644 index 0000000..c846687 --- /dev/null +++ b/src/business_logic/formation/scrap/get_formation.py @@ -0,0 +1,45 @@ +import requests +from src.business_logic.formation import ONISEP_URL +from src.business_logic.formation.scrap.types import ( + Facet, + Formation, + SearchedFormations, +) + +# Idéo-Formations initiales en France +# https://opendata.onisep.fr/data/5fa591127f501/2-ideo-formations-initiales-en-france.htm +DATASET = "5fa591127f501" + + +def _get_data(params: str) -> dict: + url = ONISEP_URL + DATASET + params + response = requests.get(url) + print(response.status_code, url) + if response.status_code == 200: + return response.json() + + +def search_formations(query: str, limit: int, offset: int = None) -> SearchedFormations: + params = f"/search?q={query}&size={limit}" + if offset: + params += f"&from={offset}" + data = _get_data(params) + + filtered_formations = [ + Formation( + formation["sigle_type_formation"] or formation["libelle_type_formation"], + formation["libelle_formation_principal"], + formation["url_et_id_onisep"], + formation["domainesous-domaine"], + formation["niveau_de_sortie_indicatif"], + ) + for formation in data["results"] + ] + + return {"total": data["total"], "formations": filtered_formations} + + +def get_libelle_type_formation(query: str) -> list[Facet]: + params = f"/search?q={query}" + data = _get_data(params) + return data["facets"]["libelle_type_formation"] diff --git a/src/business_logic/formation/scrap/types.py b/src/business_logic/formation/scrap/types.py new file mode 100644 index 0000000..32b987c --- /dev/null +++ b/src/business_logic/formation/scrap/types.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass + + +@dataclass +class Facet: + key: str + doc_count: int + + +@dataclass +class Formation: + type: str + libelle: str + url: str + domain: str + niveau_de_sortie: str + + +@dataclass +class SearchedFormations: + total: int + formations: list[Formation] diff --git a/src/docs/formations/formationByLibelle.yaml b/src/docs/formations/formationByLibelle.yaml new file mode 100644 index 0000000..10f9b01 --- /dev/null +++ b/src/docs/formations/formationByLibelle.yaml @@ -0,0 +1,24 @@ +Nombre de formations par libelle +--- + +tags: + - Formations + +post: + parameters: + - name: formations_by_libelle + description: Formations par libelle + in: body + required: true + schema: + type: object + required: + - "query" + properties: + query: + required: true + type: "String" + example: "sio" +responses: + 201: + description: La recherche s'est bien passée. \ No newline at end of file diff --git a/src/docs/formations/searchFormation.yaml b/src/docs/formations/searchFormation.yaml new file mode 100644 index 0000000..a41af5c --- /dev/null +++ b/src/docs/formations/searchFormation.yaml @@ -0,0 +1,33 @@ +Rechercher plusieurs formations ONISEP +--- + +tags: + - Formations + +post: + parameters: + - name: search_formations + description: Rechercher des Formations + in: body + required: true + schema: + type: object + required: + - "query" + - "limit" + - "offset" + properties: + query: + type: "String" + example: "sio" + limit: + type: integer + example: 10 + offset: + required: false + type: integer + example: 10 + +responses: + 201: + description: La recherche s'est bien passée. \ No newline at end of file From 2deca235cec8c2a024a7b4cb8226d0a59490e4bf Mon Sep 17 00:00:00 2001 From: Angel-Dijoux Date: Sun, 29 Oct 2023 17:35:42 +0100 Subject: [PATCH 2/6] feat: create formaton table --- Pipfile | 1 + Pipfile.lock | 10 ++++- .../a3fe3092f50c_create_formation_table.py | 43 +++++++++++++++++++ src/blueprints/formations.py | 33 ++++++++++++++ .../formation/scrap/get_formation.py | 4 +- src/business_logic/formation/scrap/types.py | 3 ++ src/models/__init__.py | 3 +- src/models/favori.py | 42 ++++++------------ src/models/formation.py | 39 +++++++++++++++++ src/models/user.py | 2 +- 10 files changed, 148 insertions(+), 32 deletions(-) create mode 100644 migrations/versions/a3fe3092f50c_create_formation_table.py create mode 100644 src/models/formation.py diff --git a/Pipfile b/Pipfile index 05fb763..55044cb 100644 --- a/Pipfile +++ b/Pipfile @@ -16,6 +16,7 @@ flask-migrate = "==3.1.0" xmltodict = "==0.13.0" secure = "==0.3.0" loguru = "*" +cuid2 = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index ba21fd1..d5838cd 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "32efbd8da0f5f28eb1a81a95926d81bf6191d75cb836c3163abe414552fd3788" + "sha256": "35d106246b737671ac684fbfb10361f84a238496b67f2a04d6d1d2d19143bc49" }, "pipfile-spec": 6, "requires": { @@ -40,6 +40,14 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, + "cuid2": { + "hashes": [ + "sha256:3595ca0b1f61ff9d65da1a3a1359d291e2243b682cdd52ed1e7bc05ab7b7247d", + "sha256:5105ae457fdd1448013f6de73008d221c2654766dd3dafd459ef02c59a34a077" + ], + "index": "pypi", + "version": "==2.0.0" + }, "decorator": { "hashes": [ "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", diff --git a/migrations/versions/a3fe3092f50c_create_formation_table.py b/migrations/versions/a3fe3092f50c_create_formation_table.py new file mode 100644 index 0000000..a460a5a --- /dev/null +++ b/migrations/versions/a3fe3092f50c_create_formation_table.py @@ -0,0 +1,43 @@ +"""Create formation table + +Revision ID: a3fe3092f50c +Revises: 8ce7337ffbf4 +Create Date: 2023-10-29 15:59:03.950861 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "a3fe3092f50c" +down_revision = "8ce7337ffbf4" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "formation", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("code_nsf", sa.Integer(), nullable=False), + sa.Column("type", sa.String(length=120), nullable=False), + sa.Column("libelle", sa.String(length=120), nullable=False), + sa.Column("tutelle", sa.String(length=120), nullable=False), + sa.Column("url", sa.String(length=255), nullable=False), + sa.Column("domain", sa.String(length=255), nullable=False), + sa.Column("niveau_de_sortie", sa.String(length=120), nullable=False), + sa.Column("duree", sa.String(length=15), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("url"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("formation") + # ### end Alembic commands ### diff --git a/src/blueprints/formations.py b/src/blueprints/formations.py index 777e45e..79b16ae 100644 --- a/src/blueprints/formations.py +++ b/src/blueprints/formations.py @@ -12,6 +12,10 @@ from src.constants.http_status_codes import ( HTTP_200_OK, ) +from src.models.favori import Favori +from src.models.user import User + +from src import db formations = Blueprint("formations", __name__, url_prefix="/api/v1/formations") @@ -28,6 +32,35 @@ def _filter_by_link(formations: list[dict[str, Any]], for_id: str) -> dict[str, "../docs/formations/formation.yaml", ) def get_formation_by_id(id: str) -> Tuple[Response, int] | HTTPException: + new_user = User( + username="john_tt", + password="password", + email="john@hey.com", + profile_pic_url="profile.jpg", + ) + + db.session.add(new_user) + db.session.flush() + + # Create a Favori object + new_favori = Favori( + code_nsf="123", + sigle_type_formation="ABC", + libelle_type_formation="Type ABC", + libelle_formation_principal="Main Formation", + sigle_formation="Formation ABC", + duree="12 months", + niveau_de_sortie_indicatif="Intermediate", + code_rncp="456", + niveau_de_certification="Certified", + libelle_niveau_de_certification="Certification ABC", + tutelle="Example Tutelle", + url_et_id_onisep="https://example.com/2", + request_user_id=new_user.id, # Assign the user object to the Favori + ) + db.session.add(new_favori) + db.session.commit() + with open("assets/formation/data.json", "r") as json_file: result = _filter_by_link(json.load(json_file)["formations"]["formation"], id) return result, HTTP_200_OK if len(result) > 0 else HTTP_200_OK diff --git a/src/business_logic/formation/scrap/get_formation.py b/src/business_logic/formation/scrap/get_formation.py index c846687..515e9ee 100644 --- a/src/business_logic/formation/scrap/get_formation.py +++ b/src/business_logic/formation/scrap/get_formation.py @@ -14,7 +14,6 @@ def _get_data(params: str) -> dict: url = ONISEP_URL + DATASET + params response = requests.get(url) - print(response.status_code, url) if response.status_code == 200: return response.json() @@ -27,11 +26,14 @@ def search_formations(query: str, limit: int, offset: int = None) -> SearchedFor filtered_formations = [ Formation( + formation["code_nsf"], formation["sigle_type_formation"] or formation["libelle_type_formation"], formation["libelle_formation_principal"], + formation["tutelle"], formation["url_et_id_onisep"], formation["domainesous-domaine"], formation["niveau_de_sortie_indicatif"], + formation["duree"], ) for formation in data["results"] ] diff --git a/src/business_logic/formation/scrap/types.py b/src/business_logic/formation/scrap/types.py index 32b987c..b275b01 100644 --- a/src/business_logic/formation/scrap/types.py +++ b/src/business_logic/formation/scrap/types.py @@ -9,11 +9,14 @@ class Facet: @dataclass class Formation: + code_nsf: str type: str libelle: str + tutelle: str url: str domain: str niveau_de_sortie: str + duree: str @dataclass diff --git a/src/models/__init__.py b/src/models/__init__.py index 7d9427b..4d98c67 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,4 +1,5 @@ +from src.models.formation import Formation from .user import User from .favori import Favori -models = [User, Favori] +models = [User, Favori, Formation] diff --git a/src/models/favori.py b/src/models/favori.py index e42b564..c9ed90b 100644 --- a/src/models/favori.py +++ b/src/models/favori.py @@ -7,32 +7,18 @@ class Favori(BaseModel): __tablename__ = "favori" - id: int - code_nsf: str - sigle_type_formation: str - libelle_type_formation: str - libelle_formation_principal: str - sigle_formation: str - duree: str - niveau_de_sortie_indicatif: str - code_rncp: str - niveau_de_certification: str - libelle_niveau_de_certification: str - tutelle: str - url_et_id_onisep: str - request_user_id: int + formation_id: str + user_id: int - id = db.Column(db.Integer, primary_key=True) - code_nsf = db.Column(db.Text) - sigle_type_formation = db.Column(db.Text) - libelle_type_formation = db.Column(db.Text) - libelle_formation_principal = db.Column(db.Text) - sigle_formation = db.Column(db.Text) - duree = db.Column(db.Text) - niveau_de_sortie_indicatif = db.Column(db.Text) - code_rncp = db.Column(db.Text) - niveau_de_certification = db.Column(db.Text) - libelle_niveau_de_certification = db.Column(db.Text) - tutelle = db.Column(db.Text) - url_et_id_onisep = db.Column(db.Text, nullable=False) - request_user_id = db.Column(db.Integer, db.ForeignKey("user.id")) + formation_id = db.Column( + db.String(36), + db.ForeignKey("formation.id", ondelete="CASCADE"), + index=True, + primary_key=True, + ) + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id", ondelete="CASCADE"), + index=True, + primary_key=True, + ) diff --git a/src/models/formation.py b/src/models/formation.py new file mode 100644 index 0000000..38ec1fa --- /dev/null +++ b/src/models/formation.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from enum import unique +from typing import Callable + +from cuid2 import cuid_wrapper + +from src import db +from src.models.base_model import BaseModel + + +@dataclass +class Formation(BaseModel): + __tablename__ = "formation" + + id: str + code_nsf: int + type: str + libelle: str + tutelle: str + url: str + domain: str + niveau_de_sortie: str + duree: str + + cuid_generator: Callable[[], str] = cuid_wrapper() + + id = db.Column( + db.String(36), + default=cuid_generator(), + primary_key=True, + ) + code_nsf = db.Column(db.Integer, nullable=False) + type = db.Column(db.String(120), nullable=False) + libelle = db.Column(db.String(120), nullable=False) + tutelle = db.Column(db.String(120), nullable=False) + url = db.Column(db.String(255), nullable=False, unique=True) + domain = db.Column(db.String(255), nullable=False) + niveau_de_sortie = db.Column(db.String(120), nullable=False) + duree = db.Column(db.String(15), nullable=False) diff --git a/src/models/user.py b/src/models/user.py index 80428b3..fe62544 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -20,4 +20,4 @@ class User(BaseModel): email = db.Column(db.String(200), unique=True, nullable=False) password = db.Column(db.Text(), nullable=False) profile_pic_url = db.Column(db.Text) - favoris = db.relationship("Favori", backref="user") + favoris = db.relationship("Favori", backref="user", lazy="dynamic") From f78df04ff7168c0ef2f4a9f6ecbef3bd9a2b41cc Mon Sep 17 00:00:00 2001 From: Angel-Dijoux Date: Sun, 29 Oct 2023 19:12:23 +0100 Subject: [PATCH 3/6] feat: implement user_favori table and drop favori --- .../16c8bc08a922_create_user_favori_table.py | 100 ++++++++++++++++++ .../a3fe3092f50c_create_formation_table.py | 18 ++++ src/blueprints/auth.py | 4 +- src/blueprints/favoris.py | 16 +-- src/blueprints/formations.py | 8 +- src/models/__init__.py | 4 +- src/models/user.py | 2 +- src/models/{favori.py => user_favori.py} | 18 ++-- 8 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 migrations/versions/16c8bc08a922_create_user_favori_table.py rename src/models/{favori.py => user_favori.py} (62%) diff --git a/migrations/versions/16c8bc08a922_create_user_favori_table.py b/migrations/versions/16c8bc08a922_create_user_favori_table.py new file mode 100644 index 0000000..2577941 --- /dev/null +++ b/migrations/versions/16c8bc08a922_create_user_favori_table.py @@ -0,0 +1,100 @@ +"""Delete and re-create favori table + +Revision ID: 16c8bc08a922 +Revises: a3fe3092f50c +Create Date: 2023-10-29 18:56:52.112998 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "16c8bc08a922" +down_revision = "a3fe3092f50c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user_favori", + sa.Column("formation_id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["formation_id"], + ["formation.id"], + ondelete="CASCADE", + name="favori_formation_id_fk", + ), + sa.ForeignKeyConstraint( + ["user_id"], ["user.id"], ondelete="CASCADE", name="favori_user_id_fk" + ), + sa.PrimaryKeyConstraint("formation_id", "user_id", name="favori_pk"), + ) + op.create_index( + op.f("ix_user_favori_formation_id"), + "user_favori", + ["formation_id"], + unique=False, + ) + op.create_index( + op.f("ix_user_favori_user_id"), "user_favori", ["user_id"], unique=False + ) + + op.execute( + """ + INSERT INTO user_favori (formation_id, user_id, created_at, updated_at) + SELECT fr.id, f.request_user_id, f.created_at, f.updated_at + FROM favori f + JOIN formation fr ON f.code_nsf = fr.code_nsf + AND f.sigle_type_formation = fr.type + AND f.libelle_formation_principal = fr.libelle + AND f.tutelle = fr.tutelle + AND f.url_et_id_onisep = fr.url + AND f.niveau_de_sortie_indicatif = fr.niveau_de_sortie + AND f.duree = fr.duree + AND f.created_at = fr.created_at + AND f.updated_at = fr.updated_at; + """ + ) + + op.drop_table("favori") + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "favori", + sa.Column("id", mysql.INTEGER(), autoincrement=True, nullable=False), + sa.Column("code_nsf", mysql.TEXT(), nullable=True), + sa.Column("sigle_type_formation", mysql.TEXT(), nullable=True), + sa.Column("libelle_type_formation", mysql.TEXT(), nullable=True), + sa.Column("libelle_formation_principal", mysql.TEXT(), nullable=True), + sa.Column("sigle_formation", mysql.TEXT(), nullable=True), + sa.Column("duree", mysql.TEXT(), nullable=True), + sa.Column("niveau_de_sortie_indicatif", mysql.TEXT(), nullable=True), + sa.Column("code_rncp", mysql.TEXT(), nullable=True), + sa.Column("niveau_de_certification", mysql.TEXT(), nullable=True), + sa.Column("libelle_niveau_de_certification", mysql.TEXT(), nullable=True), + sa.Column("tutelle", mysql.TEXT(), nullable=True), + sa.Column("url_et_id_onisep", mysql.TEXT(), nullable=False), + sa.Column( + "request_user_id", mysql.INTEGER(), autoincrement=False, nullable=True + ), + sa.Column("created_at", mysql.DATETIME(), nullable=False), + sa.Column("updated_at", mysql.DATETIME(), nullable=False), + sa.ForeignKeyConstraint(["request_user_id"], ["user.id"], name="favori_ibfk_1"), + sa.PrimaryKeyConstraint("id"), + mysql_collate="utf8mb4_0900_ai_ci", + mysql_default_charset="utf8mb4", + mysql_engine="InnoDB", + ) + op.drop_index(op.f("ix_user_favori_user_id"), table_name="user_favori") + op.drop_index(op.f("ix_user_favori_formation_id"), table_name="user_favori") + op.drop_table("user_favori") + # ### end Alembic commands ### diff --git a/migrations/versions/a3fe3092f50c_create_formation_table.py b/migrations/versions/a3fe3092f50c_create_formation_table.py index a460a5a..5167fee 100644 --- a/migrations/versions/a3fe3092f50c_create_formation_table.py +++ b/migrations/versions/a3fe3092f50c_create_formation_table.py @@ -34,6 +34,24 @@ def upgrade(): sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("url"), ) + op.execute( + """ + INSERT INTO formation (id, code_nsf, type, libelle, tutelle, url, domain, niveau_de_sortie, duree, created_at, updated_at) + SELECT + UUID(), + f.code_nsf, + f.sigle_type_formation, + f.libelle_formation_principal, + f.tutelle, + f.url_et_id_onisep, + "NULL", + f.niveau_de_sortie_indicatif, + f.duree, + f.created_at, + f.updated_at + FROM favori f; + """ + ) # ### end Alembic commands ### diff --git a/src/blueprints/auth.py b/src/blueprints/auth.py index ede2314..beac534 100644 --- a/src/blueprints/auth.py +++ b/src/blueprints/auth.py @@ -19,7 +19,7 @@ HTTP_409_CONFLICT, ) from src import db -from src.models import User, Favori +from src.models import User, UserFavori import re from typing import Tuple @@ -212,7 +212,7 @@ def edit_user() -> Tuple[Response, int] | HTTPException: def remove_favoris(user_id: int) -> None: - favoris = Favori.query.filter_by(request_user_id=user_id).all() + favoris = UserFavori.query.filter_by(request_user_id=user_id).all() list(map(lambda f: db.session.delete(f), favoris)) db.session.commit() diff --git a/src/blueprints/favoris.py b/src/blueprints/favoris.py index 2e4cfe3..5c68723 100644 --- a/src/blueprints/favoris.py +++ b/src/blueprints/favoris.py @@ -12,7 +12,7 @@ HTTP_409_CONFLICT, ) from src import db -from src.models import Favori +from src.models import UserFavori from typing import Tuple @@ -30,12 +30,12 @@ def post_favori_by_user_id() -> Tuple[Response, int] | HTTPException: if not validators.url(favori_data.get("url_et_id_onisep", "")): abort(HTTP_400_BAD_REQUEST, "Enter valid url") - if Favori.query.filter_by( + if UserFavori.query.filter_by( request_user_id=current_user, url_et_id_onisep=favori_data.get("url_et_id_onisep", ""), ).first(): abort(HTTP_409_CONFLICT, "URL already exists") - favori = Favori(**favori_data, request_user_id=current_user) + favori = UserFavori(**favori_data, request_user_id=current_user) db.session.add(favori) db.session.commit() return ( @@ -50,8 +50,8 @@ def post_favori_by_user_id() -> Tuple[Response, int] | HTTPException: def get_favoris_by_user_id() -> Tuple[Response, int]: current_user = get_jwt_identity() favoris = ( - Favori.query.filter(Favori.request_user_id == current_user) - .order_by(Favori.created_at.asc()) + UserFavori.query.filter(UserFavori.request_user_id == current_user) + .order_by(UserFavori.created_at.asc()) .all() ) return jsonify({"size": len(favoris), "results": favoris}), HTTP_200_OK @@ -65,8 +65,8 @@ def get_favoris_by_user_id() -> Tuple[Response, int]: def get_favoris_ids() -> Tuple[Response, int]: current_user = get_jwt_identity() result = ( - Favori.query.with_entities(Favori.url_et_id_onisep, Favori.id) - .filter(Favori.request_user_id == current_user) + UserFavori.query.with_entities(UserFavori.url_et_id_onisep, UserFavori.id) + .filter(UserFavori.request_user_id == current_user) .all() ) favori_data = [{"id": row.id, "url": row.url_et_id_onisep} for row in result] @@ -79,7 +79,7 @@ def get_favoris_ids() -> Tuple[Response, int]: def remove_favori(id: int) -> Tuple[Response, int] | HTTPException: current_user = get_jwt_identity() - favori = Favori.query.filter_by(request_user_id=current_user, id=id).first() + favori = UserFavori.query.filter_by(request_user_id=current_user, id=id).first() if not favori: abort(HTTP_404_NOT_FOUND, "Favoris not found") diff --git a/src/blueprints/formations.py b/src/blueprints/formations.py index 79b16ae..0bfc59c 100644 --- a/src/blueprints/formations.py +++ b/src/blueprints/formations.py @@ -12,7 +12,7 @@ from src.constants.http_status_codes import ( HTTP_200_OK, ) -from src.models.favori import Favori +from src.models.user_favori import UserFavori from src.models.user import User from src import db @@ -42,8 +42,8 @@ def get_formation_by_id(id: str) -> Tuple[Response, int] | HTTPException: db.session.add(new_user) db.session.flush() - # Create a Favori object - new_favori = Favori( + # Create a UserFavori object + new_favori = UserFavori( code_nsf="123", sigle_type_formation="ABC", libelle_type_formation="Type ABC", @@ -56,7 +56,7 @@ def get_formation_by_id(id: str) -> Tuple[Response, int] | HTTPException: libelle_niveau_de_certification="Certification ABC", tutelle="Example Tutelle", url_et_id_onisep="https://example.com/2", - request_user_id=new_user.id, # Assign the user object to the Favori + request_user_id=new_user.id, # Assign the user object to the UserFavori ) db.session.add(new_favori) db.session.commit() diff --git a/src/models/__init__.py b/src/models/__init__.py index 4d98c67..73b55b9 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,5 +1,5 @@ from src.models.formation import Formation from .user import User -from .favori import Favori +from .user_favori import UserFavori -models = [User, Favori, Formation] +models = [User, UserFavori, Formation] diff --git a/src/models/user.py b/src/models/user.py index fe62544..752228b 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -20,4 +20,4 @@ class User(BaseModel): email = db.Column(db.String(200), unique=True, nullable=False) password = db.Column(db.Text(), nullable=False) profile_pic_url = db.Column(db.Text) - favoris = db.relationship("Favori", backref="user", lazy="dynamic") + favoris = db.relationship("UserFavori", backref="user", lazy="dynamic") diff --git a/src/models/favori.py b/src/models/user_favori.py similarity index 62% rename from src/models/favori.py rename to src/models/user_favori.py index c9ed90b..700f60c 100644 --- a/src/models/favori.py +++ b/src/models/user_favori.py @@ -1,24 +1,28 @@ from dataclasses import dataclass +from typing import Callable + +from cuid2 import cuid_wrapper from src import db from src.models.base_model import BaseModel @dataclass -class Favori(BaseModel): - __tablename__ = "favori" - - formation_id: str - user_id: int +class UserFavori(BaseModel): + __tablename__ = "user_favori" formation_id = db.Column( db.String(36), db.ForeignKey("formation.id", ondelete="CASCADE"), - index=True, primary_key=True, + index=True, ) + user_id = db.Column( db.Integer, db.ForeignKey("user.id", ondelete="CASCADE"), - index=True, primary_key=True, + index=True, ) + + formation = db.relationship("Formation", back_populates="favoris") + user = db.relationship("User", back_populates="users") From 0261ad6ea55097cf6d640597a90bc4213260e34c Mon Sep 17 00:00:00 2001 From: Angel-Dijoux Date: Sun, 29 Oct 2023 22:23:25 +0100 Subject: [PATCH 4/6] feat: implement UUID finally --- ...ab4a_alter_formation_user_favori_tables.py | 69 +++++++++++++++++++ src/blueprints/auth.py | 4 +- src/blueprints/favoris.py | 49 +++++++++---- src/blueprints/formations.py | 33 --------- .../formation/scrap/get_formation.py | 12 +++- src/models/formation.py | 20 ++++-- src/models/helpers/UUIDType.py | 19 +++++ src/models/user.py | 12 +++- src/models/user_favori.py | 8 ++- 9 files changed, 164 insertions(+), 62 deletions(-) create mode 100644 migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py create mode 100644 src/models/helpers/UUIDType.py diff --git a/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py b/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py new file mode 100644 index 0000000..2303574 --- /dev/null +++ b/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py @@ -0,0 +1,69 @@ +"""empty message + +Revision ID: ee56a7eaab4a +Revises: 16c8bc08a922 +Create Date: 2023-10-29 22:09:53.444244 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +from src.models.helpers.UUIDType import UUIDType + +# revision identifiers, used by Alembic. +revision = "ee56a7eaab4a" +down_revision = "16c8bc08a922" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.drop_constraint("favori_formation_id_fk", "user_favori", type_="foreignkey") + + op.alter_column( + "formation", + "id", + existing_type=mysql.VARCHAR(length=36), + type_=UUIDType(), + existing_nullable=False, + ) + op.alter_column( + "user_favori", + "formation_id", + existing_type=mysql.VARCHAR(length=36), + type_=UUIDType(), + existing_nullable=False, + ) + + op.create_foreign_key( + "favori_formation_id_fk", "user_favori", "formation", ["formation_id"], ["id"] + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("favori_formation_id_fk", "user_favori", type_="foreignkey") + + op.alter_column( + "user_favori", + "formation_id", + existing_type=UUIDType(), + type_=mysql.VARCHAR(length=36), + existing_nullable=False, + ) + op.alter_column( + "formation", + "id", + existing_type=UUIDType(), + type_=mysql.VARCHAR(length=36), + existing_nullable=False, + ) + + op.create_foreign_key( + "favori_formation_id_fk", "user_favori", "formation", ["formation_id"], ["id"] + ) + # ### end Alembic commands ### diff --git a/src/blueprints/auth.py b/src/blueprints/auth.py index beac534..0fb5288 100644 --- a/src/blueprints/auth.py +++ b/src/blueprints/auth.py @@ -212,8 +212,8 @@ def edit_user() -> Tuple[Response, int] | HTTPException: def remove_favoris(user_id: int) -> None: - favoris = UserFavori.query.filter_by(request_user_id=user_id).all() - list(map(lambda f: db.session.delete(f), favoris)) + favoris = UserFavori.query.filter(UserFavori.user_id == user_id).all() + [db.session.delete(f) for f in favoris] db.session.commit() diff --git a/src/blueprints/favoris.py b/src/blueprints/favoris.py index 5c68723..ef3cde5 100644 --- a/src/blueprints/favoris.py +++ b/src/blueprints/favoris.py @@ -1,4 +1,5 @@ from flask import Blueprint, jsonify, request, Response, abort +from sqlalchemy import and_, exists from werkzeug.exceptions import HTTPException import validators from flask_jwt_extended import get_jwt_identity, jwt_required @@ -16,32 +17,52 @@ from typing import Tuple +from src.models.formation import Formation + favoris = Blueprint("favoris", __name__, url_prefix="/api/v1/favoris") +def _create_new_formation(favori: dict) -> Formation: + formation = Formation(**favori) + db.session.add(formation) + db.session.flush() + return formation + + @favoris.route("/", methods=["POST"]) @jwt_required() @swag_from("../docs/favoris/postFavoris.yaml") def post_favori_by_user_id() -> Tuple[Response, int] | HTTPException: current_user = get_jwt_identity() - # Collect informations + favori_data = request.get_json() - favori_data.pop("domainesous-domaine", None) - if not validators.url(favori_data.get("url_et_id_onisep", "")): - abort(HTTP_400_BAD_REQUEST, "Enter valid url") - - if UserFavori.query.filter_by( - request_user_id=current_user, - url_et_id_onisep=favori_data.get("url_et_id_onisep", ""), - ).first(): + url = favori_data.get("url", "") + + if not validators.url(url): + abort(HTTP_400_BAD_REQUEST, "Enter a valid URL") + + formation = Formation.query.filter(Formation.url == url).first() + + if formation is None: + formation = _create_new_formation(favori_data) + + favori_exist = db.session.query( + exists().where( + and_( + UserFavori.user_id == current_user, + UserFavori.formation_id == formation.id, + ) + ) + ).scalar() + + if favori_exist: abort(HTTP_409_CONFLICT, "URL already exists") - favori = UserFavori(**favori_data, request_user_id=current_user) + + favori = UserFavori(formation_id=formation.id, user_id=current_user) db.session.add(favori) db.session.commit() - return ( - jsonify(favori), - HTTP_201_CREATED, - ) + + return jsonify(favori), HTTP_201_CREATED @favoris.route("/", methods=["GET"]) diff --git a/src/blueprints/formations.py b/src/blueprints/formations.py index 0bfc59c..777e45e 100644 --- a/src/blueprints/formations.py +++ b/src/blueprints/formations.py @@ -12,10 +12,6 @@ from src.constants.http_status_codes import ( HTTP_200_OK, ) -from src.models.user_favori import UserFavori -from src.models.user import User - -from src import db formations = Blueprint("formations", __name__, url_prefix="/api/v1/formations") @@ -32,35 +28,6 @@ def _filter_by_link(formations: list[dict[str, Any]], for_id: str) -> dict[str, "../docs/formations/formation.yaml", ) def get_formation_by_id(id: str) -> Tuple[Response, int] | HTTPException: - new_user = User( - username="john_tt", - password="password", - email="john@hey.com", - profile_pic_url="profile.jpg", - ) - - db.session.add(new_user) - db.session.flush() - - # Create a UserFavori object - new_favori = UserFavori( - code_nsf="123", - sigle_type_formation="ABC", - libelle_type_formation="Type ABC", - libelle_formation_principal="Main Formation", - sigle_formation="Formation ABC", - duree="12 months", - niveau_de_sortie_indicatif="Intermediate", - code_rncp="456", - niveau_de_certification="Certified", - libelle_niveau_de_certification="Certification ABC", - tutelle="Example Tutelle", - url_et_id_onisep="https://example.com/2", - request_user_id=new_user.id, # Assign the user object to the UserFavori - ) - db.session.add(new_favori) - db.session.commit() - with open("assets/formation/data.json", "r") as json_file: result = _filter_by_link(json.load(json_file)["formations"]["formation"], id) return result, HTTP_200_OK if len(result) > 0 else HTTP_200_OK diff --git a/src/business_logic/formation/scrap/get_formation.py b/src/business_logic/formation/scrap/get_formation.py index 515e9ee..1d81f81 100644 --- a/src/business_logic/formation/scrap/get_formation.py +++ b/src/business_logic/formation/scrap/get_formation.py @@ -1,10 +1,10 @@ +from dataclasses import dataclass import requests from src.business_logic.formation import ONISEP_URL from src.business_logic.formation.scrap.types import ( Facet, - Formation, - SearchedFormations, ) +from src.models.formation import Formation # Idéo-Formations initiales en France # https://opendata.onisep.fr/data/5fa591127f501/2-ideo-formations-initiales-en-france.htm @@ -18,6 +18,12 @@ def _get_data(params: str) -> dict: return response.json() +@dataclass +class SearchedFormations: + total: int + formations: list[Formation] + + def search_formations(query: str, limit: int, offset: int = None) -> SearchedFormations: params = f"/search?q={query}&size={limit}" if offset: @@ -38,7 +44,7 @@ def search_formations(query: str, limit: int, offset: int = None) -> SearchedFor for formation in data["results"] ] - return {"total": data["total"], "formations": filtered_formations} + return SearchedFormations(data["total"], filtered_formations) def get_libelle_type_formation(query: str) -> list[Facet]: diff --git a/src/models/formation.py b/src/models/formation.py index 38ec1fa..4cbd478 100644 --- a/src/models/formation.py +++ b/src/models/formation.py @@ -1,18 +1,28 @@ +import uuid from dataclasses import dataclass +from email.policy import default from enum import unique from typing import Callable -from cuid2 import cuid_wrapper +from cuid2 import Cuid, cuid_wrapper +from sqlalchemy import UUID from src import db from src.models.base_model import BaseModel +from src.models.helpers.UUIDType import UUIDType + + +def default_uuid5(): + namespace = uuid.uuid4() + name = "com.onisep.app" + return uuid.uuid5(namespace, name) @dataclass class Formation(BaseModel): __tablename__ = "formation" - id: str + id: UUIDType code_nsf: int type: str libelle: str @@ -22,11 +32,9 @@ class Formation(BaseModel): niveau_de_sortie: str duree: str - cuid_generator: Callable[[], str] = cuid_wrapper() - id = db.Column( - db.String(36), - default=cuid_generator(), + UUIDType, + default=default_uuid5, primary_key=True, ) code_nsf = db.Column(db.Integer, nullable=False) diff --git a/src/models/helpers/UUIDType.py b/src/models/helpers/UUIDType.py new file mode 100644 index 0000000..1ee7a81 --- /dev/null +++ b/src/models/helpers/UUIDType.py @@ -0,0 +1,19 @@ +import uuid +from sqlalchemy.types import TypeDecorator, CHAR + + +class UUIDType(TypeDecorator): + impl = CHAR(length=32) + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is not None: + return value.hex + return None + + def process_literal_param(self, value, dialect): + return value.hex + + def process_result_value(self, value, dialect): + if value is not None: + return uuid.UUID(value) diff --git a/src/models/user.py b/src/models/user.py index 752228b..7604de6 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from src import db from src.models.base_model import BaseModel +from src.models.formation import Formation +from src.models.user_favori import UserFavori # Create User row @@ -20,4 +22,12 @@ class User(BaseModel): email = db.Column(db.String(200), unique=True, nullable=False) password = db.Column(db.Text(), nullable=False) profile_pic_url = db.Column(db.Text) - favoris = db.relationship("UserFavori", backref="user", lazy="dynamic") + + favoris = db.relationship( + "UserFavori", + secondary=UserFavori.__tablename__, + primaryjoin="User.id == UserFavori.user_id", + secondaryjoin="UserFavori.formation_id == Formation.id", + back_populates="users", + viewonly=True, + ) diff --git a/src/models/user_favori.py b/src/models/user_favori.py index 700f60c..8fcf705 100644 --- a/src/models/user_favori.py +++ b/src/models/user_favori.py @@ -2,8 +2,10 @@ from typing import Callable from cuid2 import cuid_wrapper +from sqlalchemy import UUID from src import db from src.models.base_model import BaseModel +from src.models.helpers.UUIDType import UUIDType @dataclass @@ -11,7 +13,7 @@ class UserFavori(BaseModel): __tablename__ = "user_favori" formation_id = db.Column( - db.String(36), + UUIDType, db.ForeignKey("formation.id", ondelete="CASCADE"), primary_key=True, index=True, @@ -24,5 +26,5 @@ class UserFavori(BaseModel): index=True, ) - formation = db.relationship("Formation", back_populates="favoris") - user = db.relationship("User", back_populates="users") + users = db.relationship("User", back_populates="favoris") + formation = db.relationship("Formation") From 9c010d86afc2604354376f438f9fefdb68692119 Mon Sep 17 00:00:00 2001 From: Angel-Dijoux Date: Sun, 29 Oct 2023 23:46:32 +0100 Subject: [PATCH 5/6] feat: Formation is re-writed. --- Pipfile | 2 +- Pipfile.lock | 17 ++++--- ...ab4a_alter_formation_user_favori_tables.py | 2 +- src/blueprints/favoris.py | 45 +++++++++++-------- src/docs/favoris/remove.yaml | 2 +- src/models/formation.py | 6 --- src/models/user_favori.py | 9 ++-- 7 files changed, 42 insertions(+), 41 deletions(-) diff --git a/Pipfile b/Pipfile index 55044cb..0bec174 100644 --- a/Pipfile +++ b/Pipfile @@ -16,7 +16,7 @@ flask-migrate = "==3.1.0" xmltodict = "==0.13.0" secure = "==0.3.0" loguru = "*" -cuid2 = "*" +sqlalchemy-serializer = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index d5838cd..835786e 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "35d106246b737671ac684fbfb10361f84a238496b67f2a04d6d1d2d19143bc49" + "sha256": "f70219121860ee18b5f166a70c7de35ca8118d684f38092656b12f5ece4dc20b" }, "pipfile-spec": 6, "requires": { @@ -40,14 +40,6 @@ "markers": "python_version >= '3.7'", "version": "==8.1.7" }, - "cuid2": { - "hashes": [ - "sha256:3595ca0b1f61ff9d65da1a3a1359d291e2243b682cdd52ed1e7bc05ab7b7247d", - "sha256:5105ae457fdd1448013f6de73008d221c2654766dd3dafd459ef02c59a34a077" - ], - "index": "pypi", - "version": "==2.0.0" - }, "decorator": { "hashes": [ "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", @@ -621,6 +613,13 @@ "markers": "python_version >= '3.7'", "version": "==2.0.22" }, + "sqlalchemy-serializer": { + "hashes": [ + "sha256:c4cf3e3eebbffa5b46a77ddb886230e5d8c17fd0b9ddbd57eed1a837eb1463cc" + ], + "index": "pypi", + "version": "==1.4.1" + }, "typing-extensions": { "hashes": [ "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", diff --git a/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py b/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py index 2303574..a65a063 100644 --- a/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py +++ b/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py @@ -1,4 +1,4 @@ -"""empty message +"""Add UUID in formation table. Revision ID: ee56a7eaab4a Revises: 16c8bc08a922 diff --git a/src/blueprints/favoris.py b/src/blueprints/favoris.py index ef3cde5..6e96ad3 100644 --- a/src/blueprints/favoris.py +++ b/src/blueprints/favoris.py @@ -1,10 +1,14 @@ -from flask import Blueprint, jsonify, request, Response, abort -from sqlalchemy import and_, exists -from werkzeug.exceptions import HTTPException +from typing import Tuple +from uuid import UUID + import validators -from flask_jwt_extended import get_jwt_identity, jwt_required from flasgger import swag_from +from flask import Blueprint, Response, abort, jsonify, request +from flask_jwt_extended import get_jwt_identity, jwt_required +from sqlalchemy import and_, exists +from werkzeug.exceptions import HTTPException +from src import db from src.constants.http_status_codes import ( HTTP_200_OK, HTTP_201_CREATED, @@ -12,11 +16,7 @@ HTTP_404_NOT_FOUND, HTTP_409_CONFLICT, ) -from src import db from src.models import UserFavori - -from typing import Tuple - from src.models.formation import Formation favoris = Blueprint("favoris", __name__, url_prefix="/api/v1/favoris") @@ -71,11 +71,16 @@ def post_favori_by_user_id() -> Tuple[Response, int] | HTTPException: def get_favoris_by_user_id() -> Tuple[Response, int]: current_user = get_jwt_identity() favoris = ( - UserFavori.query.filter(UserFavori.request_user_id == current_user) - .order_by(UserFavori.created_at.asc()) + db.session.query(Formation) + .join(UserFavori, UserFavori.formation_id == Formation.id) + .filter(UserFavori.user_id == current_user) .all() ) - return jsonify({"size": len(favoris), "results": favoris}), HTTP_200_OK + + result = [favori.to_dict() for favori in favoris] + response_data = {"size": len(favoris), "results": result} + + return jsonify(response_data), HTTP_200_OK # Remove_favoris function need JWT token and delete favoris for this user @@ -85,22 +90,26 @@ def get_favoris_by_user_id() -> Tuple[Response, int]: @jwt_required() def get_favoris_ids() -> Tuple[Response, int]: current_user = get_jwt_identity() - result = ( - UserFavori.query.with_entities(UserFavori.url_et_id_onisep, UserFavori.id) - .filter(UserFavori.request_user_id == current_user) + favoris = ( + db.session.query(Formation.url, Formation.id) + .join(UserFavori, UserFavori.formation_id == Formation.id) + .filter(UserFavori.user_id == current_user) .all() ) - favori_data = [{"id": row.id, "url": row.url_et_id_onisep} for row in result] + favori_data = [{"id": row.id, "url": row.url} for row in favoris] return jsonify({"favori_ids": favori_data}), HTTP_200_OK -@favoris.delete("/") +@favoris.delete("/") @jwt_required() @swag_from("../docs/favoris/remove.yaml") -def remove_favori(id: int) -> Tuple[Response, int] | HTTPException: +def remove_favori(id: str) -> Tuple[Response, int] | HTTPException: current_user = get_jwt_identity() - favori = UserFavori.query.filter_by(request_user_id=current_user, id=id).first() + favori = UserFavori.query.filter( + UserFavori.user_id == current_user, + UserFavori.formation_id == UUID(id), + ).first() if not favori: abort(HTTP_404_NOT_FOUND, "Favoris not found") diff --git a/src/docs/favoris/remove.yaml b/src/docs/favoris/remove.yaml index 94ade26..8c32d6a 100644 --- a/src/docs/favoris/remove.yaml +++ b/src/docs/favoris/remove.yaml @@ -19,7 +19,7 @@ delete: description: Supprimer un favori. in: path required: true - type: integer + type: string responses: 204: diff --git a/src/models/formation.py b/src/models/formation.py index 4cbd478..7ded9d6 100644 --- a/src/models/formation.py +++ b/src/models/formation.py @@ -1,11 +1,5 @@ import uuid from dataclasses import dataclass -from email.policy import default -from enum import unique -from typing import Callable - -from cuid2 import Cuid, cuid_wrapper -from sqlalchemy import UUID from src import db from src.models.base_model import BaseModel diff --git a/src/models/user_favori.py b/src/models/user_favori.py index 8fcf705..0f9551e 100644 --- a/src/models/user_favori.py +++ b/src/models/user_favori.py @@ -1,17 +1,16 @@ from dataclasses import dataclass -from typing import Callable -from cuid2 import cuid_wrapper -from sqlalchemy import UUID from src import db from src.models.base_model import BaseModel from src.models.helpers.UUIDType import UUIDType +from sqlalchemy_serializer import SerializerMixin -@dataclass -class UserFavori(BaseModel): +class UserFavori(BaseModel, SerializerMixin): __tablename__ = "user_favori" + serialize_only = "formation_id" + formation_id = db.Column( UUIDType, db.ForeignKey("formation.id", ondelete="CASCADE"), From e350b3827b27217ce9057723f1adb6f3b7fc02a7 Mon Sep 17 00:00:00 2001 From: Angel-Dijoux Date: Mon, 30 Oct 2023 17:09:57 +0100 Subject: [PATCH 6/6] fix: flake issues. --- .../versions/ee56a7eaab4a_alter_formation_user_favori_tables.py | 1 - src/models/user.py | 1 - src/models/user_favori.py | 2 -- 3 files changed, 4 deletions(-) diff --git a/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py b/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py index a65a063..6501e03 100644 --- a/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py +++ b/migrations/versions/ee56a7eaab4a_alter_formation_user_favori_tables.py @@ -6,7 +6,6 @@ """ from alembic import op -import sqlalchemy as sa from sqlalchemy.dialects import mysql from src.models.helpers.UUIDType import UUIDType diff --git a/src/models/user.py b/src/models/user.py index 7604de6..7ab9051 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -1,7 +1,6 @@ from dataclasses import dataclass from src import db from src.models.base_model import BaseModel -from src.models.formation import Formation from src.models.user_favori import UserFavori diff --git a/src/models/user_favori.py b/src/models/user_favori.py index 0f9551e..fb89bcc 100644 --- a/src/models/user_favori.py +++ b/src/models/user_favori.py @@ -1,5 +1,3 @@ -from dataclasses import dataclass - from src import db from src.models.base_model import BaseModel from src.models.helpers.UUIDType import UUIDType