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.
This commit is contained in:
David Bruno Vontor
2025-12-04 17:35:47 +01:00
parent ebab304b75
commit d94ad93222
24 changed files with 281 additions and 76 deletions

View File

@@ -107,6 +107,13 @@ Notes
- **Task queue**: Celery + Redis for async/background jobs. - **Task queue**: Celery + Redis for async/background jobs.
- **API**: REST endpoints, JWT auth, API key support. - **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 ## References
- [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions. - [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions.
- [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns. - [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns.

View File

@@ -42,9 +42,9 @@ class CustomUser(SoftDeleteModel, AbstractUser):
) )
class Role(models.TextChoices): class Role(models.TextChoices):
ADMIN = "admin", "Admin" ADMIN = "admin", "cz#Administrátor"
MANAGER = "mod", "Moderator" MANAGER = "mod", "cz#Moderator"
CUSTOMER = "regular", "Regular" CUSTOMER = "regular", "cz#Regular"
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER) role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)

View File

@@ -22,7 +22,6 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU #NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU
class CookieJWTAuthentication(JWTAuthentication): class CookieJWTAuthentication(JWTAuthentication):
def authenticate(self, request): def authenticate(self, request):
raw_token = request.COOKIES.get('access_token') raw_token = request.COOKIES.get('access_token')
if not raw_token: if not raw_token:

View File

@@ -103,12 +103,12 @@ class ProductImage(models.Model):
class Order(models.Model): class Order(models.Model):
class Status(models.TextChoices): class Status(models.TextChoices):
CREATED = "created", "Vytvořeno" CREATED = "created", "cz#Vytvořeno"
CANCELLED = "cancelled", "Zrušeno" CANCELLED = "cancelled", "cz#Zrušeno"
COMPLETED = "completed", "Dokončeno" COMPLETED = "completed", "cz#Dokončeno"
REFUNDING = "refunding", "Vrácení v procesu" REFUNDING = "refunding", "cz#Vrácení v procesu"
REFUNDED = "refunded", "Vráceno" REFUNDED = "refunded", "cz#Vráceno"
status = models.CharField( status = models.CharField(
max_length=20, choices=Status.choices, null=True, blank=True, default=Status.CREATED 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 Carrier(models.Model):
class SHIPPING(models.TextChoices): class SHIPPING(models.TextChoices):
ZASILKOVNA = "packeta", "Zásilkovna" ZASILKOVNA = "packeta", "cz#Zásilkovna"
STORE = "store", "Osobní odběr" STORE = "store", "cz#Osobní odběr"
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE) shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
class STATE(models.TextChoices): class STATE(models.TextChoices):
PREPARING = "ordered", "Objednávka se připravuje" PREPARING = "ordered", "cz#Objednávka se připravuje"
SHIPPED = "shipped", "Odesláno" SHIPPED = "shipped", "cz#Odesláno"
DELIVERED = "delivered", "Doručeno" DELIVERED = "delivered", "cz#Doručeno"
READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí" READY_TO_PICKUP = "ready_to_pickup", "cz#Připraveno k vyzvednutí"
#RETURNING = "returning", "Vracení objednávky" #RETURNING = "returning", "Vracení objednávky"
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING) 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.Model):
class PAYMENT(models.TextChoices): class PAYMENT(models.TextChoices):
SHOP = "shop", "Platba v obchodě" SHOP = "shop", "cz#Platba v obchodě"
STRIPE = "stripe", "Bankovní převod" STRIPE = "stripe", "cz#Bankovní převod"
CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka" CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP) payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
#FIXME: potvrdit že logika platby funguje správně #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) order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE)
class Reason(models.TextChoices): class Reason(models.TextChoices):
RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty" RETUNING_PERIOD = "retuning_before_fourteen_day_period", "cz#Vrácení před uplynutím 14-ti denní lhůty"
DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt" DAMAGED_PRODUCT = "damaged_product", "cz#Poškozený produkt"
WRONG_ITEM = "wrong_item", "Špatná položka" WRONG_ITEM = "wrong_item", "cz#Špatná položka"
OTHER = "other", "Jiný důvod" OTHER = "other", "cz#Jiný důvod"
reason_choice = models.CharField(max_length=30, choices=Reason.choices) reason_choice = models.CharField(max_length=30, choices=Reason.choices)
reason_text = models.TextField(blank=True) reason_text = models.TextField(blank=True)

View File

@@ -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") 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): class CURRENCY(models.TextChoices):
CZK = "CZK", "Czech Koruna" CZK = "CZK", "cz#Czech Koruna"
EUR = "EUR", "Euro" EUR = "EUR", "cz#Euro"
currency = models.CharField(max_length=10, default=CURRENCY.CZK, choices=CURRENCY.choices) currency = models.CharField(max_length=10, default=CURRENCY.CZK, choices=CURRENCY.choices)
class Meta: class Meta:

View File

@@ -7,12 +7,12 @@ from .client import StripeClient
class StripeModel(models.Model): class StripeModel(models.Model):
class STATUS_CHOICES(models.TextChoices): class STATUS_CHOICES(models.TextChoices):
PENDING = "pending", "Čeká se na platbu" PENDING = "pending", "cz#Čeká se na platbu"
PAID = "paid", "Zaplaceno" PAID = "paid", "cz#Zaplaceno"
FAILED = "failed", "Neúspěšné" FAILED = "failed", "cz#Neúspěšné"
CANCELLED = "cancelled", "Zrušeno" CANCELLED = "cancelled", "cz#Zrušeno"
REFUNDING = "refunding", "Platba se vrací" REFUNDING = "refunding", "cz#Platba se vrací"
REFUNDED = "refunded", "Platba úspěšně vrácena" REFUNDED = "refunded", "cz#Platba úspěšně vrácena"
status = models.CharField(max_length=20, choices=STATUS_CHOICES.choices, default=STATUS_CHOICES.PENDING) status = models.CharField(max_length=20, choices=STATUS_CHOICES.choices, default=STATUS_CHOICES.PENDING)

View File

@@ -16,6 +16,7 @@ Including another URLconf
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from .views import choices
from drf_spectacular.views import ( from drf_spectacular.views import (
SpectacularAPIView, SpectacularAPIView,
@@ -31,6 +32,7 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path("api/choices/", choices, name="choices"),
path('api/account/', include('account.urls')), path('api/account/', include('account.urls')),
path('api/commerce/', include('commerce.urls')), path('api/commerce/', include('commerce.urls')),
#path('api/advertisments/', include('advertisements.urls')), #path('api/advertisments/', include('advertisements.urls')),

View File

@@ -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)

30
frontend/orval.config.js Normal file
View File

@@ -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",
},
},
},
},
};

View File

@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"api:gen": "orval --config orval.config.js"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
@@ -33,6 +34,7 @@
"globals": "^16.3.0", "globals": "^16.3.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.39.1", "typescript-eslint": "^8.39.1",
"vite": "^7.1.2" "vite": "^7.1.2",
"openapi-generator-cli": "^2.9.0"
} }
} }

View File

@@ -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);
});

View File

@@ -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 }>
}

View File

@@ -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" },
]);
```

View File

@@ -0,0 +1,7 @@
# Získání seznamu objektů (např. Orders)
```typescript
import { ordersList } from "@/api/generated/private";
const orders = await ordersList();
```

View File

@@ -0,0 +1,12 @@
# 🔐 Přihlášení (public)
```typescript
import { authLoginCreate } from "@/api/generated/public";
await authLoginCreate({
email: "test@test.com",
password: "secret",
});
```

View File

@@ -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 });
```

View File

@@ -0,0 +1 @@
v tehle složce se vygeneruje schema

View File

@@ -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<Array<{ value: string; label: string }>>
*/
export async function fetchEnumFromSchemaJson(
path: string,
method: "get" | "post" | "patch" | "put",
field: string,
schemaUrl: string = "/schema/?format=json"
): Promise<Array<{ value: string; label: string }>> {
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;
}
}

View File

@@ -1,4 +1,4 @@
import Client from "../Client"; import Client from "./Client";
// Available output containers (must match backend) // Available output containers (must match backend)
export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const; export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const;

View File

@@ -2,7 +2,7 @@
// User API model for searching users by username // User API model for searching users by username
// Structure matches other model files (see order.js for reference) // 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"; const API_BASE_URL = "/account/users";

View File

@@ -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);
}
);

View File

@@ -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
});

View File

@@ -5,7 +5,7 @@ import {
FORMAT_EXTS, FORMAT_EXTS,
type InfoResponse, type InfoResponse,
parseContentDispositionFilename, parseContentDispositionFilename,
} from "../../api/apps/Downloader"; } from "../../api/legacy/Downloader";
export default function Downloader() { export default function Downloader() {
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");