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:
David Bruno Vontor
2025-12-05 18:22:35 +01:00
parent d94ad93222
commit 4cbebff43b
20 changed files with 3752 additions and 144 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
frontend/dist
/frontend/node_modules
/venv
/backups
/.github
/.vscode
/.git

View File

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

View File

@@ -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={

View File

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

View File

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

View File

View 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

View File

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

View File

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

View File

@@ -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."})

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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