From d94ad9322202aff9dd2d6bcf5eb68c0cabeff117 Mon Sep 17 00:00:00 2001 From: David Bruno Vontor Date: Thu, 4 Dec 2025 17:35:47 +0100 Subject: [PATCH] Add choices API endpoint and OpenAPI client setup Introduces a new /api/choices/ endpoint for fetching model choices with multilingual labels. Updates Django models to use 'cz#' prefix for Czech labels. Adds OpenAPI client generation via orval, refactors frontend API structure, and provides documentation and helper scripts for dynamic choices and OpenAPI usage. --- .github/copilot-instructions.md | 7 ++ backend/account/models.py | 6 +- backend/account/tokens.py | 1 - backend/commerce/models.py | 36 ++++---- backend/configuration/models.py | 4 +- backend/thirdparty/stripe/models.py | 12 +-- backend/vontor_cz/urls.py | 2 + backend/vontor_cz/views.py | 85 +++++++++++++++++++ frontend/orval.config.js | 30 +++++++ frontend/package.json | 6 +- frontend/scripts/fetch-openapi.js | 25 ++++++ frontend/src/api/chocies.ts | 18 ++++ frontend/src/api/docs/choices.md | 9 ++ .../src/api/docs/example_of_getting_data.md | 7 ++ frontend/src/api/docs/login_logout.md | 12 +++ frontend/src/api/docs/soubory.md | 15 ++++ frontend/src/api/generated/filler | 1 + frontend/src/api/get_chocies.ts | 41 --------- frontend/src/api/{ => legacy}/Client.ts | 0 .../src/api/{apps => legacy}/Downloader.ts | 2 +- frontend/src/api/{ => legacy}/models/User.ts | 2 +- frontend/src/api/privateClient.ts | 27 ++++++ frontend/src/api/publicClient.ts | 7 ++ frontend/src/pages/downloader/Downloader.tsx | 2 +- 24 files changed, 281 insertions(+), 76 deletions(-) create mode 100644 backend/vontor_cz/views.py create mode 100644 frontend/orval.config.js create mode 100644 frontend/scripts/fetch-openapi.js create mode 100644 frontend/src/api/chocies.ts create mode 100644 frontend/src/api/docs/choices.md create mode 100644 frontend/src/api/docs/example_of_getting_data.md create mode 100644 frontend/src/api/docs/login_logout.md create mode 100644 frontend/src/api/docs/soubory.md create mode 100644 frontend/src/api/generated/filler delete mode 100644 frontend/src/api/get_chocies.ts rename frontend/src/api/{ => legacy}/Client.ts (100%) rename frontend/src/api/{apps => legacy}/Downloader.ts (99%) rename frontend/src/api/{ => legacy}/models/User.ts (98%) create mode 100644 frontend/src/api/privateClient.ts create mode 100644 frontend/src/api/publicClient.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index daaf368..29238f4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -107,6 +107,13 @@ Notes - **Task queue**: Celery + Redis for async/background jobs. - **API**: REST endpoints, JWT auth, API key support. +### OpenAPI Client Generation +- Schema: `config = { schemaUrl: "/api/schema/", baseUrl: "/api/" }` +- Commands: `npm run api:update` (fetch schema + generate client) +- Output: `frontend/src/api/generated/` (TypeScript Axios client) +- Axios instance: `frontend/src/api/api.ts` with `withCredentials` and JWT auto-refresh via existing `Client.ts`. +- Choices helper: `frontend/src/api/get_choices.ts` → `getChoices(requests, lang)` returns `{ "Model.field": [{ value, label }] }`. + ## References - [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions. - [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns. diff --git a/backend/account/models.py b/backend/account/models.py index 9228c5a..c4fcc24 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -42,9 +42,9 @@ class CustomUser(SoftDeleteModel, AbstractUser): ) class Role(models.TextChoices): - ADMIN = "admin", "Admin" - MANAGER = "mod", "Moderator" - CUSTOMER = "regular", "Regular" + ADMIN = "admin", "cz#Administrátor" + MANAGER = "mod", "cz#Moderator" + CUSTOMER = "regular", "cz#Regular" role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER) diff --git a/backend/account/tokens.py b/backend/account/tokens.py index 08e9bb2..4813243 100644 --- a/backend/account/tokens.py +++ b/backend/account/tokens.py @@ -22,7 +22,6 @@ from rest_framework_simplejwt.authentication import JWTAuthentication #NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU class CookieJWTAuthentication(JWTAuthentication): def authenticate(self, request): - raw_token = request.COOKIES.get('access_token') if not raw_token: diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 6d91de7..8d4eacc 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -103,12 +103,12 @@ class ProductImage(models.Model): class Order(models.Model): class Status(models.TextChoices): - CREATED = "created", "Vytvořeno" - CANCELLED = "cancelled", "Zrušeno" - COMPLETED = "completed", "Dokončeno" + CREATED = "created", "cz#Vytvořeno" + CANCELLED = "cancelled", "cz#Zrušeno" + COMPLETED = "completed", "cz#Dokončeno" - REFUNDING = "refunding", "Vrácení v procesu" - REFUNDED = "refunded", "Vráceno" + REFUNDING = "refunding", "cz#Vrácení v procesu" + REFUNDED = "refunded", "cz#Vráceno" status = models.CharField( max_length=20, choices=Status.choices, null=True, blank=True, default=Status.CREATED @@ -205,15 +205,15 @@ class Order(models.Model): class Carrier(models.Model): class SHIPPING(models.TextChoices): - ZASILKOVNA = "packeta", "Zásilkovna" - STORE = "store", "Osobní odběr" + ZASILKOVNA = "packeta", "cz#Zásilkovna" + STORE = "store", "cz#Osobní odběr" shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE) class STATE(models.TextChoices): - PREPARING = "ordered", "Objednávka se připravuje" - SHIPPED = "shipped", "Odesláno" - DELIVERED = "delivered", "Doručeno" - READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí" + PREPARING = "ordered", "cz#Objednávka se připravuje" + SHIPPED = "shipped", "cz#Odesláno" + DELIVERED = "delivered", "cz#Doručeno" + READY_TO_PICKUP = "ready_to_pickup", "cz#Připraveno k vyzvednutí" #RETURNING = "returning", "Vracení objednávky" state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING) @@ -276,9 +276,9 @@ class Carrier(models.Model): class Payment(models.Model): class PAYMENT(models.TextChoices): - SHOP = "shop", "Platba v obchodě" - STRIPE = "stripe", "Bankovní převod" - CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka" + SHOP = "shop", "cz#Platba v obchodě" + STRIPE = "stripe", "cz#Bankovní převod" + CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka" payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP) #FIXME: potvrdit že logika platby funguje správně @@ -472,10 +472,10 @@ class Refund(models.Model): order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE) class Reason(models.TextChoices): - RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty" - DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt" - WRONG_ITEM = "wrong_item", "Špatná položka" - OTHER = "other", "Jiný důvod" + RETUNING_PERIOD = "retuning_before_fourteen_day_period", "cz#Vrácení před uplynutím 14-ti denní lhůty" + DAMAGED_PRODUCT = "damaged_product", "cz#Poškozený produkt" + WRONG_ITEM = "wrong_item", "cz#Špatná položka" + OTHER = "other", "cz#Jiný důvod" reason_choice = models.CharField(max_length=30, choices=Reason.choices) reason_text = models.TextField(blank=True) diff --git a/backend/configuration/models.py b/backend/configuration/models.py index 61941fd..49c7222 100644 --- a/backend/configuration/models.py +++ b/backend/configuration/models.py @@ -30,8 +30,8 @@ class ShopConfiguration(models.Model): addition_of_coupons_amount = models.BooleanField(default=False, help_text="Sčítání slevových kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón") class CURRENCY(models.TextChoices): - CZK = "CZK", "Czech Koruna" - EUR = "EUR", "Euro" + CZK = "CZK", "cz#Czech Koruna" + EUR = "EUR", "cz#Euro" currency = models.CharField(max_length=10, default=CURRENCY.CZK, choices=CURRENCY.choices) class Meta: diff --git a/backend/thirdparty/stripe/models.py b/backend/thirdparty/stripe/models.py index 414316b..cf82a90 100644 --- a/backend/thirdparty/stripe/models.py +++ b/backend/thirdparty/stripe/models.py @@ -7,12 +7,12 @@ from .client import StripeClient class StripeModel(models.Model): class STATUS_CHOICES(models.TextChoices): - PENDING = "pending", "Čeká se na platbu" - PAID = "paid", "Zaplaceno" - FAILED = "failed", "Neúspěšné" - CANCELLED = "cancelled", "Zrušeno" - REFUNDING = "refunding", "Platba se vrací" - REFUNDED = "refunded", "Platba úspěšně vrácena" + PENDING = "pending", "cz#Čeká se na platbu" + PAID = "paid", "cz#Zaplaceno" + FAILED = "failed", "cz#Neúspěšné" + CANCELLED = "cancelled", "cz#Zrušeno" + REFUNDING = "refunding", "cz#Platba se vrací" + REFUNDED = "refunded", "cz#Platba úspěšně vrácena" status = models.CharField(max_length=20, choices=STATUS_CHOICES.choices, default=STATUS_CHOICES.PENDING) diff --git a/backend/vontor_cz/urls.py b/backend/vontor_cz/urls.py index 18e2e96..fdf069b 100644 --- a/backend/vontor_cz/urls.py +++ b/backend/vontor_cz/urls.py @@ -16,6 +16,7 @@ Including another URLconf """ from django.contrib import admin from django.urls import path, include +from .views import choices from drf_spectacular.views import ( SpectacularAPIView, @@ -31,6 +32,7 @@ urlpatterns = [ path('admin/', admin.site.urls), + path("api/choices/", choices, name="choices"), path('api/account/', include('account.urls')), path('api/commerce/', include('commerce.urls')), #path('api/advertisments/', include('advertisements.urls')), diff --git a/backend/vontor_cz/views.py b/backend/vontor_cz/views.py new file mode 100644 index 0000000..0ea7e0c --- /dev/null +++ b/backend/vontor_cz/views.py @@ -0,0 +1,85 @@ +import json +from django.apps import apps +from rest_framework.decorators import api_view +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiParameter +from rest_framework.permissions import AllowAny + +@extend_schema( + description="Vrátí všechny možné hodnoty pro ChoiceField s podporou vícejazyčných labelů. " + "Umožňuje načíst více modelů a polí najednou.", + parameters=[ + OpenApiParameter( + name="fields", + description=( + "JSON pole objektů {model: 'ModelName', field: 'field_name'} " + "např. '[{\"model\": \"User\", \"field\": \"role\"}, {\"model\": \"Carrier\", \"field\": \"shipping_method\"}]'" + ), + required=True, + type=str, + ), + OpenApiParameter( + name="lang", + description="Jazyk pro labely (např. 'cz', 'en')", + required=False, + type=str, + ), + ], + responses={ + 200: { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": {"type": "string", "description": "Logická hodnota pro backend"}, + "label": {"type": "string", "description": "Human-readable label podle zvoleného jazyka"} + } + } + } + } + } +) +@api_view(["GET"]) +def choices(request): + """ + Endpoint pro získání choices pro dropdowny s podporou vícejazyčných labelů. + Formát labelu v modelu: 'lang#Label', např. 'cz#Administrátor'. + Podporuje více modelů a polí najednou. + """ + permission_classes = [AllowAny] + + fields_param = request.query_params.get("fields") + lang = request.query_params.get("lang", None) + + try: + fields_list = json.loads(fields_param) + except Exception: + return Response({"error": "Invalid 'fields' parameter, must be valid JSON"}, status=400) + + result = {} + for item in fields_list: + model_name = item.get("model") + field_name = item.get("field") + if not model_name or not field_name: + continue + + model = apps.get_model("app_name", model_name) + field = model._meta.get_field(field_name) + + choices_data = [] + for value, label in field.choices: + if "#" in label and lang: + label_parts = label.split("#", 1) + if label_parts[0] == lang: + label = label_parts[1] + else: + label = label_parts[1] # fallback na defaultní text (po #) + elif "#" in label: + label = label.split("#", 1)[1] + choices_data.append({"value": value, "label": label}) + + result[f"{model_name}.{field_name}"] = choices_data + + return Response(result) diff --git a/frontend/orval.config.js b/frontend/orval.config.js new file mode 100644 index 0000000..1f60bfa --- /dev/null +++ b/frontend/orval.config.js @@ -0,0 +1,30 @@ +module.exports = { + public: { + input: { target: "http://localhost:8000/api/schema/" }, + output: { + target: "src/api/generated/public.ts", + schemas: "src/api/generated/models", + client: "axios", + override: { + mutator: { + path: "src/api/publicClient.ts", + name: "publicApi", + }, + }, + }, + }, + private: { + input: { target: "http://localhost:8000/api/schema/" }, + output: { + target: "src/api/generated/private.ts", + schemas: "src/api/generated/models", + client: "axios", + override: { + mutator: { + path: "src/api/privateClient.ts", + name: "privateApi", + }, + }, + }, + }, +}; diff --git a/frontend/package.json b/frontend/package.json index 0f9a632..c38d0bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "api:gen": "orval --config orval.config.js" }, "dependencies": { "@tailwindcss/vite": "^4.1.16", @@ -33,6 +34,7 @@ "globals": "^16.3.0", "typescript": "~5.8.3", "typescript-eslint": "^8.39.1", - "vite": "^7.1.2" + "vite": "^7.1.2", + "openapi-generator-cli": "^2.9.0" } } diff --git a/frontend/scripts/fetch-openapi.js b/frontend/scripts/fetch-openapi.js new file mode 100644 index 0000000..533dd59 --- /dev/null +++ b/frontend/scripts/fetch-openapi.js @@ -0,0 +1,25 @@ +import fs from "fs"; +import path from "path"; +import axios from "axios"; + +// Single config point +const config = { schemaUrl: "/api/schema/", baseUrl: "/api/" }; + +async function main() { + const outDir = path.resolve("./src/openapi"); + const outFile = path.join(outDir, "schema.json"); + const base = process.env.VITE_API_BASE_URL || "http://localhost:8000"; + const url = new URL(config.schemaUrl, base).toString(); + + console.log(`[openapi] Fetching schema from ${url}`); + const res = await axios.get(url, { headers: { Accept: "application/json" } }); + + await fs.promises.mkdir(outDir, { recursive: true }); + await fs.promises.writeFile(outFile, JSON.stringify(res.data, null, 2), "utf8"); + console.log(`[openapi] Wrote ${outFile}`); +} + +main().catch((err) => { + console.error("[openapi] Failed to fetch schema:", err?.message || err); + process.exit(1); +}); diff --git a/frontend/src/api/chocies.ts b/frontend/src/api/chocies.ts new file mode 100644 index 0000000..6be3019 --- /dev/null +++ b/frontend/src/api/chocies.ts @@ -0,0 +1,18 @@ +import { publicApi } from "./publicClient"; + +export async function getChoices(queries: { + model: string; + field: string; + lang?: string; +}[]) { + const params = new URLSearchParams(); + + queries.forEach((q) => { + params.append("model", q.model); + params.append("field", q.field); + if (q.lang) params.append("lang", q.lang); + }); + + const { data } = await publicApi.get(`/choices/?${params.toString()}`); + return data; // typ: Array<{ value: string; label: string }> +} \ No newline at end of file diff --git a/frontend/src/api/docs/choices.md b/frontend/src/api/docs/choices.md new file mode 100644 index 0000000..4e1978e --- /dev/null +++ b/frontend/src/api/docs/choices.md @@ -0,0 +1,9 @@ +# 🌈 Choices (dynamic enums) + +Získaní možných hodnot pro pole s výběrem (ChoiceField) z backendu s podporou vícejazyčných labelů, definované v modelech Django. + +```tsx +const roles = await getChoices([ + { model: "User", field: "role", lang: "cz" }, +]); +``` diff --git a/frontend/src/api/docs/example_of_getting_data.md b/frontend/src/api/docs/example_of_getting_data.md new file mode 100644 index 0000000..de00a67 --- /dev/null +++ b/frontend/src/api/docs/example_of_getting_data.md @@ -0,0 +1,7 @@ +# Získání seznamu objektů (např. Orders) + +```typescript +import { ordersList } from "@/api/generated/private"; + +const orders = await ordersList(); +``` \ No newline at end of file diff --git a/frontend/src/api/docs/login_logout.md b/frontend/src/api/docs/login_logout.md new file mode 100644 index 0000000..ca6620c --- /dev/null +++ b/frontend/src/api/docs/login_logout.md @@ -0,0 +1,12 @@ + +# 🔐 Přihlášení (public) + +```typescript + +import { authLoginCreate } from "@/api/generated/public"; + +await authLoginCreate({ + email: "test@test.com", + password: "secret", +}); +``` \ No newline at end of file diff --git a/frontend/src/api/docs/soubory.md b/frontend/src/api/docs/soubory.md new file mode 100644 index 0000000..0604249 --- /dev/null +++ b/frontend/src/api/docs/soubory.md @@ -0,0 +1,15 @@ +# 🖼️ Podpora FileField / ImageField + +Orval automaticky vytvoří endpointy s multipart/form-data. + +Použití: + +```typescript +import { productsUpdate } from "@/api/generated/private"; + +const form = new FormData(); +form.append("name", values.name); +form.append("image", fileInput.files[0]); + +await productsUpdate({ id: productId, data: form }); +``` \ No newline at end of file diff --git a/frontend/src/api/generated/filler b/frontend/src/api/generated/filler new file mode 100644 index 0000000..53375b9 --- /dev/null +++ b/frontend/src/api/generated/filler @@ -0,0 +1 @@ +v tehle složce se vygeneruje schema \ No newline at end of file diff --git a/frontend/src/api/get_chocies.ts b/frontend/src/api/get_chocies.ts deleted file mode 100644 index 5a7dd69..0000000 --- a/frontend/src/api/get_chocies.ts +++ /dev/null @@ -1,41 +0,0 @@ -import Client from "./Client"; - -/** - * Loads enum values from an OpenAPI schema for a given path, method, and field (e.g., category). - * - * @param path - API path, e.g., "/api/service-tickets/" - * @param method - HTTP method - * @param field - field name in parameters or request - * @param schemaUrl - URL of the JSON schema, default "/api/schema/?format=json" - * @returns Promise> - */ -export async function fetchEnumFromSchemaJson( - path: string, - method: "get" | "post" | "patch" | "put", - field: string, - schemaUrl: string = "/schema/?format=json" -): Promise> { - try { - const schema = await Client.public.get(schemaUrl); - - const methodDef = schema.paths?.[path]?.[method]; - if (!methodDef) { - throw new Error(`Method ${method.toUpperCase()} for ${path} not found in schema.`); - } - - // Search in "parameters" (e.g., GET query parameters) - const param = methodDef.parameters?.find((p: any) => p.name === field); - - if (param?.schema?.enum) { - return param.schema.enum.map((val: string) => ({ - value: val, - label: val, - })); - } - - throw new Error(`Field '${field}' does not contain enum`); - } catch (error) { - console.error("Error loading enum values:", error); - throw error; - } -} diff --git a/frontend/src/api/Client.ts b/frontend/src/api/legacy/Client.ts similarity index 100% rename from frontend/src/api/Client.ts rename to frontend/src/api/legacy/Client.ts diff --git a/frontend/src/api/apps/Downloader.ts b/frontend/src/api/legacy/Downloader.ts similarity index 99% rename from frontend/src/api/apps/Downloader.ts rename to frontend/src/api/legacy/Downloader.ts index 45aa23a..80dde6c 100644 --- a/frontend/src/api/apps/Downloader.ts +++ b/frontend/src/api/legacy/Downloader.ts @@ -1,4 +1,4 @@ -import Client from "../Client"; +import Client from "./Client"; // Available output containers (must match backend) export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const; diff --git a/frontend/src/api/models/User.ts b/frontend/src/api/legacy/models/User.ts similarity index 98% rename from frontend/src/api/models/User.ts rename to frontend/src/api/legacy/models/User.ts index 41adbba..061e0ef 100644 --- a/frontend/src/api/models/User.ts +++ b/frontend/src/api/legacy/models/User.ts @@ -2,7 +2,7 @@ // User API model for searching users by username // Structure matches other model files (see order.js for reference) -import Client from '../Client'; +import Client from '../legacy/Client'; const API_BASE_URL = "/account/users"; diff --git a/frontend/src/api/privateClient.ts b/frontend/src/api/privateClient.ts new file mode 100644 index 0000000..47818f4 --- /dev/null +++ b/frontend/src/api/privateClient.ts @@ -0,0 +1,27 @@ +import axios from "axios"; + +// použij tohle pro API vyžadující autentizaci +export const privateApi = axios.create({ + baseURL: "/api/", + withCredentials: true, // potřebuje HttpOnly cookies +}); + +privateApi.interceptors.response.use( + (res) => res, + async (error) => { + const original = error.config; + + if (error.response?.status === 401 && !original._retry) { + original._retry = true; + + try { + await privateApi.post("/auth/refresh/"); + return privateApi(original); + } catch { + // optional: logout + } + } + + return Promise.reject(error); + } +); diff --git a/frontend/src/api/publicClient.ts b/frontend/src/api/publicClient.ts new file mode 100644 index 0000000..dd8de13 --- /dev/null +++ b/frontend/src/api/publicClient.ts @@ -0,0 +1,7 @@ +import axios from "axios"; + +// použij tohle pro veřejné API nevyžadující autentizaci +export const publicApi = axios.create({ + baseURL: "/api/", + withCredentials: false, // veřejné API NEPOSÍLÁ cookies +}); diff --git a/frontend/src/pages/downloader/Downloader.tsx b/frontend/src/pages/downloader/Downloader.tsx index 4a50cc7..cb32433 100644 --- a/frontend/src/pages/downloader/Downloader.tsx +++ b/frontend/src/pages/downloader/Downloader.tsx @@ -5,7 +5,7 @@ import { FORMAT_EXTS, type InfoResponse, parseContentDispositionFilename, -} from "../../api/apps/Downloader"; +} from "../../api/legacy/Downloader"; export default function Downloader() { const [url, setUrl] = useState("");