Add production Docker setup and update backend/frontend configs
Introduces .dockerignore, production Dockerfile and nginx config for frontend, and refactors docker-compose.yml for multi-service deployment. Updates backend and frontend code to support public API tagging, improves refund handling, adds test email endpoint, and migrates Orval config to TypeScript. Removes unused frontend Dockerfile and updates dependencies for React Query and Orval.
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
frontend/dist
|
||||
/frontend/node_modules
|
||||
/venv
|
||||
/backups
|
||||
/.github
|
||||
/.vscode
|
||||
/.git
|
||||
@@ -8,7 +8,8 @@ RUN apt update && apt install -y \
|
||||
pango1.0-tools \
|
||||
libpango-1.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
ffmpeg
|
||||
ffmpeg \
|
||||
ca-certificates
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
@@ -38,7 +38,7 @@ from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
|
||||
# Custom Token obtaining view
|
||||
@extend_schema(
|
||||
tags=["Authentication"],
|
||||
tags=["Authentication", "public"],
|
||||
summary="Obtain JWT access and refresh tokens (cookie-based)",
|
||||
description="Authenticate user and obtain JWT access and refresh tokens. You can use either email or username.",
|
||||
request=CustomTokenObtainPairSerializer,
|
||||
@@ -107,7 +107,7 @@ class CookieTokenObtainPairView(TokenObtainPairView):
|
||||
return super().validate(attrs)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Authentication"],
|
||||
tags=["Authentication", "public"],
|
||||
summary="Refresh JWT token using cookie",
|
||||
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
||||
responses={
|
||||
@@ -163,7 +163,7 @@ class CookieTokenRefreshView(APIView):
|
||||
#---------------------------------------------LOGOUT------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["Authentication"],
|
||||
tags=["Authentication", "public"],
|
||||
summary="Logout user (delete access and refresh token cookies)",
|
||||
description="Logs out the user by deleting access and refresh token cookies.",
|
||||
responses={
|
||||
@@ -186,6 +186,7 @@ class LogoutView(APIView):
|
||||
|
||||
@extend_schema(
|
||||
tags=["User"],
|
||||
get=extend_schema(tags=["public"]),
|
||||
summary="List, retrieve, update, and delete users.",
|
||||
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
||||
responses={
|
||||
|
||||
@@ -10,11 +10,13 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from weasyprint import HTML
|
||||
import os
|
||||
|
||||
from backend.commerce.tasks import notify_order_sended
|
||||
from configuration.models import ShopConfiguration
|
||||
|
||||
from thirdparty.zasilkovna.models import ZasilkovnaPacket
|
||||
from thirdparty.stripe.models import StripeModel
|
||||
|
||||
from .tasks import notify_refund_accepted, notify_order_sended
|
||||
|
||||
#FIXME: přidat soft delete pro všchny modely !!!!
|
||||
|
||||
class Category(models.Model):
|
||||
@@ -477,6 +479,7 @@ class Refund(models.Model):
|
||||
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)
|
||||
|
||||
verified = models.BooleanField(default=False)
|
||||
@@ -507,12 +510,26 @@ class Refund(models.Model):
|
||||
|
||||
# super().save(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Automaticky aktualizovat stav objednávky na "vráceno"
|
||||
if self.pk is None:
|
||||
if self.order.status != Order.Status.REFUNDING:
|
||||
self.order.save(update_fields=["status", "updated_at"])
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def refund_completed(self):
|
||||
# Aktualizovat stav objednávky na "vráceno"
|
||||
self.order.payment.stripe.refund()
|
||||
if self.order.payment and self.order.payment.payment_method == Payment.PAYMENT.STRIPE:
|
||||
self.order.payment.stripe.refund() # Vrácení pěnez přes stripe
|
||||
|
||||
self.order.status = Order.Status.REFUNDED
|
||||
self.order.save(update_fields=["status", "updated_at"])
|
||||
|
||||
|
||||
notify_refund_accepted.delay(order=self.order, user=self.order.user)
|
||||
|
||||
|
||||
def generate_refund_pdf_for_customer(self):
|
||||
"""Vygeneruje PDF formulář k vrácení zboží pro zákazníka.
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from .models import Order
|
||||
from account.models import User
|
||||
from account.tasks import send_email_with_context
|
||||
from celery import shared_task
|
||||
|
||||
from django.apps import apps
|
||||
from django.utils import timezone
|
||||
|
||||
Order = apps.get_model('commerce', 'Order')
|
||||
|
||||
|
||||
def delete_expired_orders():
|
||||
expired_orders = Order.objects.filter(status=Order.STATUS_CHOICES.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24))
|
||||
count = expired_orders.count()
|
||||
|
||||
0
backend/configuration/serializers.py
Normal file
0
backend/configuration/serializers.py
Normal file
@@ -1,3 +1,5 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
||||
#TODO: dej public tag pro view, aby to mohl react číst pomocí public axiosu
|
||||
6
backend/thirdparty/downloader/views.py
vendored
6
backend/thirdparty/downloader/views.py
vendored
@@ -48,7 +48,7 @@ class Downloader(APIView):
|
||||
authentication_classes = []
|
||||
|
||||
@extend_schema(
|
||||
tags=["downloader"],
|
||||
tags=["downloader", "public"],
|
||||
summary="Get video info from URL",
|
||||
parameters=[
|
||||
inline_serializer(
|
||||
@@ -129,7 +129,7 @@ class Downloader(APIView):
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["downloader"],
|
||||
tags=["downloader", "public"],
|
||||
summary="Download video from URL",
|
||||
request=inline_serializer(
|
||||
name="DownloadRequest",
|
||||
@@ -277,7 +277,7 @@ class DownloaderStats(APIView):
|
||||
authentication_classes = []
|
||||
permission_classes = [AllowAny]
|
||||
@extend_schema(
|
||||
tags=["downloader"],
|
||||
tags=["downloader", "public"],
|
||||
summary="Get aggregated downloader statistics",
|
||||
responses={200: DownloaderStatsSerializer},
|
||||
)
|
||||
|
||||
7
backend/thirdparty/zasilkovna/models.py
vendored
7
backend/thirdparty/zasilkovna/models.py
vendored
@@ -44,9 +44,10 @@ class ZasilkovnaPacket(models.Model):
|
||||
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PENDING)
|
||||
|
||||
# ------- API -------
|
||||
class BUISSNESS_ADDRESS_ID(models.IntegerChoices):
|
||||
SHOP = 1, "address of buissnes"
|
||||
addressId = models.IntegerField(help_text="ID adresy, v API rozhraní", choices=BUISSNESS_ADDRESS_ID.choices, default=BUISSNESS_ADDRESS_ID.SHOP)
|
||||
#TODO: změnit na nastavení adresy eshopu/obchodu z modelu konfigurace
|
||||
# https://client.packeta.com/cs/senders (admin rozhraní)
|
||||
|
||||
addressId = models.IntegerField(help_text="ID adresy, v Widgetu zásilkovny který si vybere uživatel.")
|
||||
|
||||
packet_id = models.IntegerField(help_text="Číslo zásilky v Packetě (api)")
|
||||
barcode = models.CharField(max_length=64, help_text="Čárový kód zásilky v Packetě")
|
||||
|
||||
@@ -5,7 +5,11 @@ from rest_framework.response import Response
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
from account.tasks import send_email_test_task
|
||||
|
||||
@extend_schema(
|
||||
tags=["public"],
|
||||
|
||||
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=[
|
||||
@@ -83,3 +87,24 @@ def choices(request):
|
||||
result[f"{model_name}.{field_name}"] = choices_data
|
||||
|
||||
return Response(result)
|
||||
|
||||
|
||||
|
||||
@extend_schema(
|
||||
tags=["Testing"],
|
||||
description="Testovací endpoint pro odeslání testovacího emailu.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="email",
|
||||
description="Emailová adresa příjemce testovacího emailu.",
|
||||
required=True,
|
||||
type=str,
|
||||
),
|
||||
],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
def test_email(request):
|
||||
email = request.query_params.get("email")
|
||||
send_email_test_task.delay(email)
|
||||
|
||||
return Response({"status": "Test task (celery) email sent, await delivery."})
|
||||
@@ -1,44 +1,118 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
backend:
|
||||
container_name: backend-vontor-cz
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: dockerfile
|
||||
restart: always
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
build: ./backend
|
||||
networks:
|
||||
- app_network
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
volumes:
|
||||
- static-data:/app/collectedstaticfiles
|
||||
- media-data:/app/media
|
||||
command: sh -c "
|
||||
python manage.py migrate --verbosity 3 --noinput &&
|
||||
python manage.py check &&
|
||||
python manage.py collectstatic --clear --noinput --verbosity 3 &&
|
||||
python manage.py seed_app_config &&
|
||||
gunicorn -k uvicorn.workers.UvicornWorker vontor_cz.asgi:application --bind 0.0.0.0:8000"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
- redis
|
||||
command: daphne -b 0.0.0.0 -p 8000 vontor_cz.asgi:application
|
||||
|
||||
frontend:
|
||||
env_file:
|
||||
- ./frontend/.env
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
command: npm run dev
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: postgres-vontor-cz
|
||||
restart: always
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: mydb
|
||||
POSTGRES_USER: myuser
|
||||
POSTGRES_PASSWORD: mypassword
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
|
||||
redis: #extremly fast db, stores data in RAM memory
|
||||
container_name: redis-vontor-cz
|
||||
image: redis:alpine
|
||||
restart: always
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- redis-data:/data
|
||||
expose:
|
||||
- "6379"
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
celery: #task queue for handling asynchronous/hard tasks
|
||||
container_name: celery-vontor-cz
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: dockerfile
|
||||
command: sh -c "python manage.py migrate --noinput && celery -A vontor_cz worker --loglevel=info"
|
||||
restart: always
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
- backend
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
celery-beat: #periodic tasks scheduler
|
||||
container_name: celery-beat-vontor-cz
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: dockerfile
|
||||
command: sh -c "python manage.py migrate --noinput && celery -A vontor_cz beat --loglevel=info"
|
||||
restart: always
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
- backend
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
#end of backend services -----------------------
|
||||
|
||||
nginx: #web server, reverse proxy, serves static files
|
||||
container_name: nginx-vontor-cz
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
env_file:
|
||||
- ./frontend/.env
|
||||
ports:
|
||||
- 80:80
|
||||
# - 9000:80
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- app_network
|
||||
volumes:
|
||||
- static-data:/app/collectedstaticfiles # static (Django)
|
||||
- media-data:/app/media # media (Django)
|
||||
- ./frontend/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
|
||||
|
||||
networks:
|
||||
app_network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis-data:
|
||||
db-data:
|
||||
static-data:
|
||||
media-data:
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
16
frontend/Dockerfile.prod
Normal file
16
frontend/Dockerfile.prod
Normal file
@@ -0,0 +1,16 @@
|
||||
# Step 1: Build React (Vite) app
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
# If package-lock.json exists, npm ci is faster and reproducible
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
ENV NODE_ENV=production
|
||||
RUN npm run build
|
||||
|
||||
# Step 2: Nginx runtime
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
75
frontend/nginx/nginx.conf
Normal file
75
frontend/nginx/nginx.conf
Normal file
@@ -0,0 +1,75 @@
|
||||
# nginx.conf
|
||||
worker_processes auto;
|
||||
user nginx;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
client_max_body_size 50m;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# -------------------------
|
||||
# React frontend
|
||||
# -------------------------
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
# Ensure CSP is present on SPA document responses too
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
# Django backend API
|
||||
# -------------------------
|
||||
|
||||
# Serve Django static and media volumes mounted into the container
|
||||
location /static/ {
|
||||
alias /app/collectedstaticfiles/;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /app/media/;
|
||||
}
|
||||
|
||||
# Same-origin proxy for API -> avoids CORS and allows cookies
|
||||
location /api {
|
||||
return 301 /api/;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
client_max_body_size 50m;
|
||||
|
||||
# Ensure CSP is also present on proxied responses
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
|
||||
}
|
||||
|
||||
# -------------------------
|
||||
# Security headers
|
||||
# -------------------------
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Minimal, valid CSP for development (apply on all responses)
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.paylibo.com; connect-src 'self' http://127.0.0.1:8000 http://localhost:8000 ws: wss: https://api.paylibo.com; font-src 'self' data:" always;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
59
frontend/orval.config.ts
Normal file
59
frontend/orval.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { defineConfig } from "orval";
|
||||
import "dotenv/config";
|
||||
|
||||
const backendUrl = process.env.VITE_API_BASE_URL || "http://localhost:8000";
|
||||
|
||||
// může se hodit pokud nechceme při buildu generovat klienta (nechat false pro produkci nebo vynechat)
|
||||
const SKIP_ORVAL = process.env.SKIP_ORVAL === "true";
|
||||
|
||||
if (SKIP_ORVAL){
|
||||
|
||||
}
|
||||
export default defineConfig({
|
||||
public: {
|
||||
input: {
|
||||
target: `${backendUrl}/api/schema/`,
|
||||
|
||||
filters: {
|
||||
mode: "include",
|
||||
tags: ["public"],
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: "src/api/generated/public.ts",
|
||||
schemas: "src/api/generated/public/models",
|
||||
|
||||
client: "react-query",
|
||||
httpClient: "axios",
|
||||
|
||||
override: {
|
||||
mutator: {
|
||||
path: "src/api/publicClient.ts", //IMPORTANTE
|
||||
name: "publicApi",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
private: {
|
||||
input: {
|
||||
target: `${backendUrl}/api/schema/`
|
||||
|
||||
// No filters, include all endpoints
|
||||
},
|
||||
output: {
|
||||
|
||||
target: "src/api/generated/private.ts", //IMPORTANTE
|
||||
schemas: "src/api/generated/private/models",
|
||||
|
||||
client: "react-query",
|
||||
httpClient: "axios",
|
||||
|
||||
override: {
|
||||
mutator: {
|
||||
path: "src/api/privateClient.ts",
|
||||
name: "privateApi",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
3473
frontend/package-lock.json
generated
3473
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,15 +5,19 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build": "tsc -b && tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"api:gen": "orval --config orval.config.js"
|
||||
|
||||
"api:gen": "orval --config orval.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"axios": "^1.13.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"orval": "^7.13.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -34,7 +38,6 @@
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2",
|
||||
"openapi-generator-cli": "^2.9.0"
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '../legacy/Client';
|
||||
import Client from '../Client';
|
||||
|
||||
const API_BASE_URL = "/account/users";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useState, useEffect } from 'react';
|
||||
|
||||
import userAPI from '../api/models/User';
|
||||
import userAPI from '../api/legacy/models/User';
|
||||
|
||||
// definice uživatele
|
||||
export interface User {
|
||||
|
||||
Reference in New Issue
Block a user