Compare commits
61 Commits
main
...
8f6d864b4b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f6d864b4b | |||
| 3a7044d551 | |||
| 27b346c1f6 | |||
| 963ba6b824 | |||
| c0bd24ee5e | |||
|
|
b38d126b6c | ||
| 2a26edac80 | |||
| e78baf746c | |||
| b279ac36d5 | |||
| 98426f8b05 | |||
| 2213e115c6 | |||
|
|
7ebc83dd8c | ||
|
|
c6ca9e2741 | ||
| 4f56f4bbc5 | |||
| f7605812c1 | |||
| deb853b564 | |||
| 00271e59e4 | |||
| 264f0116ae | |||
| cf615c5279 | |||
| 1cec6be6d7 | |||
| abc6207296 | |||
| 9c48aee522 | |||
| 0346180d01 | |||
| 713c94d7e9 | |||
|
|
2498386477 | ||
| 72155d4560 | |||
| 1751badb90 | |||
| 564418501c | |||
|
|
df83288591 | ||
| b4e50eda30 | |||
| a2bc1e68ee | |||
|
|
ada74c84a6 | ||
|
|
946f86db7e | ||
| 5b066e2770 | |||
|
|
4cbebff43b | ||
|
|
d94ad93222 | ||
|
|
ebab304b75 | ||
|
|
37f36b3466 | ||
|
|
102855f812 | ||
| e86839f2da | |||
| b8a1a594b2 | |||
|
|
7a715efeda | ||
| f14c09bf7a | |||
|
|
052f7ab533 | ||
| 5c3a02d282 | |||
| c39467dc7d | |||
| a645c87020 | |||
| c3f837b90f | |||
| 2118f002d1 | |||
| 602c5a40f1 | |||
| 05055415de | |||
| de5f54f4bc | |||
| a324a9cf49 | |||
| 47b9770a70 | |||
|
|
4791bbc92c | ||
| 8dd4f6e731 | |||
| dd9d076bd2 | |||
| 73da41b514 | |||
| 10796dcb31 | |||
| f5cf8bbaa7 | |||
| d0227e4539 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
frontend/dist
|
||||||
|
/frontend/node_modules
|
||||||
|
/venv
|
||||||
|
/backups
|
||||||
|
/.github
|
||||||
|
/.vscode
|
||||||
|
/.git
|
||||||
226
.github/copilot-instructions.md
vendored
Normal file
226
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Copilot Instructions for Vontor CZ
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This monorepo contains a Django backend and a Vite/React frontend, orchestrated via Docker Compose. The project is designed for a Czech e-marketplace, with custom payment integrations and real-time features.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- **backend/**: Django project (`vontor_cz`), custom `account` app, and third-party payment integrations (`thirdparty/`).
|
||||||
|
- Uses Django REST Framework, Channels (ASGI), Celery, and S3/static/media via `django-storages`.
|
||||||
|
- Custom user model: `account.CustomUser`.
|
||||||
|
- API docs: DRF Spectacular (`/api/schema/`).
|
||||||
|
- **frontend/**: Vite + React + TypeScript app.
|
||||||
|
- Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`.
|
||||||
|
- Uses React Router layouts and nested routes (see `src/layouts/`, `src/routes/`).
|
||||||
|
- Uses Tailwind CSS for styling (configured via `src/index.css` with `@import "tailwindcss";`). Prefer utility classes over custom CSS.
|
||||||
|
|
||||||
|
## Developer Workflows
|
||||||
|
- **Backend**
|
||||||
|
- Local dev: `python manage.py runserver` (or use Docker Compose)
|
||||||
|
- Migrations: `python manage.py makemigrations && python manage.py migrate`
|
||||||
|
- Celery: `celery -A vontor_cz worker -l info`
|
||||||
|
- Channels: Daphne/ASGI (see Docker Compose command)
|
||||||
|
- Env config: `.env` files in `backend/` (see `.gitignore` for secrets)
|
||||||
|
- **Frontend**
|
||||||
|
- Install: `npm install`
|
||||||
|
- Dev server: `npm run dev`
|
||||||
|
- Build: `npm run build`
|
||||||
|
- Preview: `npm run preview`
|
||||||
|
- Static assets: `src/assets/` (import in JS/CSS), `public/` (referenced in HTML)
|
||||||
|
|
||||||
|
## Conventions & Patterns
|
||||||
|
- **Backend**
|
||||||
|
- Use environment variables for secrets and config (see `settings.py`).
|
||||||
|
- Static/media files: S3 in production, local in dev (see `settings.py`).
|
||||||
|
- API versioning and docs: DRF Spectacular config in `settings.py`.
|
||||||
|
- Custom permissions, filters, and serializers in each app.
|
||||||
|
- **Serializer Best Practices**:
|
||||||
|
- **Prevent Duplicate Schemas**: When the same `ChoiceField` or complex field appears in multiple serializers, define it once as a reusable field class and use it everywhere instead of repeated definitions.
|
||||||
|
- Example: Create `OrderStatusField(serializers.ChoiceField)` with `choices=Order.OrderStatus.choices` and reuse it in all serializers that need order status.
|
||||||
|
- This ensures consistent OpenAPI schema generation and reduces maintenance overhead.
|
||||||
|
- **Frontend**
|
||||||
|
- Use React Router layouts for shared UI (see `src/layouts/`, `LAYOUTS.md`).
|
||||||
|
- API calls and JWT handling in `src/api/`.
|
||||||
|
- Route definitions and guards in `src/routes/` (`ROUTES.md`).
|
||||||
|
- Use TypeScript strict mode (see `tsconfig.*.json`).
|
||||||
|
- Linting: ESLint config in `eslint.config.js`.
|
||||||
|
- Styling: Tailwind CSS is present. Prefer utility classes; keep minimal component-scoped CSS. Global/base styles live in `src/index.css`. Avoid inline styles and CSS-in-JS unless necessary.
|
||||||
|
|
||||||
|
### Frontend API Client (required)
|
||||||
|
All frontend API calls must use the shared client at frontend/src/api/Client.ts.
|
||||||
|
|
||||||
|
- Client.public: no cookies, no Authorization header (for public Django endpoints).
|
||||||
|
- Client.auth: sends cookies and includes Bearer token; auto-refreshes on 401 (retries up to 2x).
|
||||||
|
- Centralized error handling: subscribe via Client.onError to show toasts/snackbars.
|
||||||
|
- Tokens are stored in cookies by Client.setTokens and cleared by Client.clearTokens.
|
||||||
|
|
||||||
|
Example usage (TypeScript)
|
||||||
|
```ts
|
||||||
|
import Client from "@/api/Client";
|
||||||
|
|
||||||
|
// Public request (no credentials)
|
||||||
|
async function listPublicItems() {
|
||||||
|
const res = await Client.public.get("/api/public/items/");
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login (obtain tokens and persist to cookies)
|
||||||
|
async function login(username: string, password: string) {
|
||||||
|
// Default SimpleJWT endpoint (adjust if your backend differs)
|
||||||
|
const res = await Client.public.post("/api/token/", { username, password });
|
||||||
|
const { access, refresh } = res.data;
|
||||||
|
Client.setTokens(access, refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated requests (auto Bearer + refresh on 401)
|
||||||
|
async function fetchProfile() {
|
||||||
|
const res = await Client.auth.get("/api/users/me/");
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
Client.clearTokens();
|
||||||
|
window.location.assign("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global error toasts
|
||||||
|
import { useEffect } from "react";
|
||||||
|
function useApiErrors(showToast: (msg: string) => void) {
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = Client.onError((e) => {
|
||||||
|
const { message, status } = e.detail;
|
||||||
|
showToast(status ? `${status}: ${message}` : message);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
}, [showToast]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite env used by the client:
|
||||||
|
- VITE_API_BASE_URL (default: http://localhost:8000)
|
||||||
|
- VITE_API_REFRESH_URL (default: /api/token/refresh/)
|
||||||
|
- VITE_LOGIN_PATH (default: /login)
|
||||||
|
|
||||||
|
Notes
|
||||||
|
- Public client never sends cookies or Authorization.
|
||||||
|
- Ensure Django CORS settings allow your frontend origin. See backend/vontor_cz/settings.py.
|
||||||
|
- Use React Router layouts and guards as documented in frontend/src/routes/ROUTES.md and frontend/src/layouts/LAYOUTS.md.
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
- **Payments**: `thirdparty/` contains custom integrations for Stripe, GoPay, Trading212.
|
||||||
|
- **Real-time**: Django Channels (ASGI, Redis) for websockets.
|
||||||
|
- **Task queue**: Celery + Redis for async/background jobs.
|
||||||
|
- **API**: REST endpoints, JWT auth, API key support.
|
||||||
|
|
||||||
|
### OpenAPI Client Generation (Orval)
|
||||||
|
This project uses **Orval** to auto-generate TypeScript API clients from the Django OpenAPI schema.
|
||||||
|
|
||||||
|
#### Configuration
|
||||||
|
- **Orval config**: `frontend/src/orval.config.ts`
|
||||||
|
- **Schema URL**: `/api/schema/` (DRF Spectacular endpoint)
|
||||||
|
- **Fetch script**: `frontend/scripts/fetch-openapi.js`
|
||||||
|
- **Commands**:
|
||||||
|
- `npm run api:update` — fetches schema + generates client
|
||||||
|
- Runs: `node scripts/fetch-openapi.js && npx orval`
|
||||||
|
|
||||||
|
#### Generated Output
|
||||||
|
- **Location**: `frontend/src/api/generated/`
|
||||||
|
- **Files**: TypeScript interfaces, Axios-based API hooks
|
||||||
|
- Uses custom mutators: `publicMutator` and `privateMutator`
|
||||||
|
|
||||||
|
#### Custom Mutators
|
||||||
|
Two Axios clients handle public/private API requests:
|
||||||
|
|
||||||
|
**Public Client** (`frontend/src/api/publicClient.ts`):
|
||||||
|
```ts
|
||||||
|
import axios, { type AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
const backendUrl = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
export const publicApi = axios.create({
|
||||||
|
baseURL: backendUrl + "/api/",
|
||||||
|
withCredentials: false, // no cookies for public endpoints
|
||||||
|
});
|
||||||
|
|
||||||
|
export const publicMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
|
||||||
|
const response = await publicApi.request<T>(config);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Private Client** (`frontend/src/api/privateClient.ts`):
|
||||||
|
```ts
|
||||||
|
import axios, { type AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
const backendUrl = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
export const privateApi = axios.create({
|
||||||
|
baseURL: backendUrl + "/api/",
|
||||||
|
withCredentials: true, // sends HttpOnly cookies (access/refresh tokens)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-refresh on 401
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const privateMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
|
||||||
|
const response = await privateApi.request<T>(config);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment Variables (Vite)
|
||||||
|
- **IMPORTANT**: Use `import.meta.env.VITE_*` instead of `process.env` in browser code
|
||||||
|
- **NEVER** import `dotenv/config` in frontend files (causes "process is not defined" error)
|
||||||
|
- **Available vars**:
|
||||||
|
- `VITE_BACKEND_URL` (default: `http://localhost:8000`)
|
||||||
|
- `VITE_API_BASE_URL` (if using Client.ts wrapper)
|
||||||
|
- `VITE_API_REFRESH_URL` (default: `/api/token/refresh/`)
|
||||||
|
- `VITE_LOGIN_PATH` (default: `/login`)
|
||||||
|
|
||||||
|
#### Usage Example
|
||||||
|
```ts
|
||||||
|
import { useGetOrders } from "@/api/generated/orders";
|
||||||
|
|
||||||
|
function OrdersList() {
|
||||||
|
const { data, isLoading, error } = useGetOrders();
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>;
|
||||||
|
if (error) return <div>Error: {error.message}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{data?.map(order => <li key={order.id}>{order.status}</li>)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Helpers
|
||||||
|
- **Choices helper**: `frontend/src/api/get_choices.ts`
|
||||||
|
- Function: `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.
|
||||||
|
- [frontend/src/routes/ROUTES.md](../frontend/src/routes/ROUTES.md): Routing conventions.
|
||||||
|
- [backend/vontor_cz/settings.py](../backend/vontor_cz/settings.py): All backend config, env, and integration details.
|
||||||
|
- [docker-compose.yml](../docker-compose.yml): Service orchestration and dev workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**When in doubt, check the referenced markdown files and `settings.py` for project-specific logic and patterns.**
|
||||||
15
.vscode/launch.json
vendored
Normal file
15
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Launch Chrome against localhost",
|
||||||
|
"url": "http://localhost:8080",
|
||||||
|
"webRoot": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"files.autoSave": "afterDelay",
|
||||||
|
"files.autoSaveDelay": 1000,
|
||||||
|
"python.analysis.autoImportCompletions": true
|
||||||
|
}
|
||||||
@@ -2,6 +2,22 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies including Node.js for yt-dlp JavaScript runtime
|
||||||
|
RUN apt update && apt install -y \
|
||||||
|
weasyprint \
|
||||||
|
libcairo2 \
|
||||||
|
pango1.0-tools \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libgobject-2.0-0 \
|
||||||
|
ffmpeg \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt install -y nodejs \
|
||||||
|
&& update-ca-certificates \
|
||||||
|
&& apt clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
|
||||||
class AccountConfig(AppConfig):
|
class AccountConfig(AppConfig):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.2.5 on 2025-08-13 23:19
|
# Generated by Django 5.2.7 on 2025-12-18 15:11
|
||||||
|
|
||||||
import account.models
|
import account.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
@@ -30,16 +30,23 @@ class Migration(migrations.Migration):
|
|||||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
('is_deleted', models.BooleanField(default=False)),
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
('role', models.CharField(blank=True, choices=[('admin', 'Administrátor'), ('user', 'Uživatel')], max_length=32, null=True)),
|
('role', models.CharField(choices=[('admin', 'cz#Administrátor'), ('mod', 'cz#Moderator'), ('regular', 'cz#Regular')], default='regular', max_length=20)),
|
||||||
|
('phone_number', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
|
||||||
('email_verified', models.BooleanField(default=False)),
|
('email_verified', models.BooleanField(default=False)),
|
||||||
('phone_number', models.CharField(blank=True, max_length=16, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
|
|
||||||
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
||||||
|
('email_verification_token', models.CharField(blank=True, db_index=True, max_length=128, null=True)),
|
||||||
|
('email_verification_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('gdpr', models.BooleanField(default=False)),
|
||||||
|
('is_active', models.BooleanField(default=False)),
|
||||||
('create_time', models.DateTimeField(auto_now_add=True)),
|
('create_time', models.DateTimeField(auto_now_add=True)),
|
||||||
('city', models.CharField(blank=True, max_length=100, null=True)),
|
('city', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
('street', models.CharField(blank=True, max_length=200, null=True)),
|
('street', models.CharField(blank=True, max_length=200, null=True)),
|
||||||
|
('street_number', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('country', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('company_name', models.CharField(blank=True, max_length=255)),
|
||||||
|
('ico', models.CharField(blank=True, max_length=20)),
|
||||||
|
('dic', models.CharField(blank=True, max_length=20)),
|
||||||
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
|
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
|
||||||
('gdpr', models.BooleanField(default=False)),
|
|
||||||
('is_active', models.BooleanField(default=False)),
|
|
||||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
|
||||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
|
||||||
],
|
],
|
||||||
@@ -47,8 +54,8 @@ class Migration(migrations.Migration):
|
|||||||
'abstract': False,
|
'abstract': False,
|
||||||
},
|
},
|
||||||
managers=[
|
managers=[
|
||||||
('objects', account.models.CustomUserActiveManager()),
|
('objects', account.models.CustomUserManager()),
|
||||||
('all_objects', account.models.CustomUserAllManager()),
|
('active', account.models.ActiveUserManager()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import AbstractUser, Group, Permission
|
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
|
||||||
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator
|
from django.core.validators import RegexValidator
|
||||||
|
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -16,91 +17,77 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Custom User Manager to handle soft deletion
|
class CustomUserManager(UserManager):
|
||||||
class CustomUserActiveManager(UserManager):
|
# Inherit get_by_natural_key and all auth behaviors
|
||||||
def get_queryset(self):
|
use_in_migrations = True
|
||||||
return super().get_queryset().filter(is_deleted=False)
|
|
||||||
|
|
||||||
# Custom User Manager to handle all users, including soft deleted
|
class ActiveUserManager(CustomUserManager):
|
||||||
class CustomUserAllManager(UserManager):
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return super().get_queryset()
|
return super().get_queryset().filter(is_active=True)
|
||||||
|
|
||||||
|
|
||||||
class CustomUser(SoftDeleteModel, AbstractUser):
|
class CustomUser(SoftDeleteModel, AbstractUser):
|
||||||
groups = models.ManyToManyField(
|
groups = models.ManyToManyField(
|
||||||
Group,
|
Group,
|
||||||
related_name="customuser_set", # <- přidáš related_name
|
related_name="customuser_set",
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="The groups this user belongs to.",
|
help_text="The groups this user belongs to.",
|
||||||
related_query_name="customuser",
|
related_query_name="customuser",
|
||||||
)
|
)
|
||||||
user_permissions = models.ManyToManyField(
|
user_permissions = models.ManyToManyField(
|
||||||
Permission,
|
Permission,
|
||||||
related_name="customuser_set", # <- přidáš related_name
|
related_name="customuser_set",
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Specific permissions for this user.",
|
help_text="Specific permissions for this user.",
|
||||||
related_query_name="customuser",
|
related_query_name="customuser",
|
||||||
)
|
)
|
||||||
|
|
||||||
ROLE_CHOICES = (
|
class Role(models.TextChoices):
|
||||||
('admin', 'Administrátor'),
|
ADMIN = "admin", "cz#Administrátor"
|
||||||
('user', 'Uživatel'),
|
MANAGER = "mod", "cz#Moderator"
|
||||||
)
|
CUSTOMER = "regular", "cz#Regular"
|
||||||
role = models.CharField(max_length=32, choices=ROLE_CHOICES, null=True, blank=True)
|
|
||||||
|
|
||||||
"""ACCOUNT_TYPES = (
|
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
|
||||||
('company', 'Firma'),
|
|
||||||
('individual', 'Fyzická osoba')
|
|
||||||
)
|
|
||||||
account_type = models.CharField(max_length=32, choices=ACCOUNT_TYPES, null=True, blank=True)"""
|
|
||||||
|
|
||||||
email_verified = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
phone_number = models.CharField(
|
phone_number = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
max_length=16,
|
max_length=16,
|
||||||
blank=True,
|
|
||||||
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
|
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
email_verified = models.BooleanField(default=False)
|
||||||
email = models.EmailField(unique=True, db_index=True)
|
email = models.EmailField(unique=True, db_index=True)
|
||||||
|
|
||||||
|
# + fields for email verification flow
|
||||||
|
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
|
||||||
|
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
newsletter = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
#misc
|
||||||
|
gdpr = models.BooleanField(default=False)
|
||||||
|
is_active = models.BooleanField(default=False)
|
||||||
create_time = models.DateTimeField(auto_now_add=True)
|
create_time = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
"""company_id = models.CharField(
|
#adresa
|
||||||
max_length=8,
|
postal_code = models.CharField(max_length=20, blank=True)
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
validators=[
|
|
||||||
RegexValidator(
|
|
||||||
regex=r'^\d{8}$',
|
|
||||||
message="Company ID must contain exactly 8 digits.",
|
|
||||||
code='invalid_company_id'
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)"""
|
|
||||||
|
|
||||||
"""personal_id = models.CharField(
|
|
||||||
max_length=11,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
validators=[
|
|
||||||
RegexValidator(
|
|
||||||
regex=r'^\d{6}/\d{3,4}$',
|
|
||||||
message="Personal ID must be in the format 123456/7890.",
|
|
||||||
code='invalid_personal_id'
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)"""
|
|
||||||
|
|
||||||
city = models.CharField(null=True, blank=True, max_length=100)
|
city = models.CharField(null=True, blank=True, max_length=100)
|
||||||
street = models.CharField(null=True, blank=True, max_length=200)
|
street = models.CharField(null=True, blank=True, max_length=200)
|
||||||
|
street_number = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
country = models.CharField(null=True, blank=True, max_length=100)
|
||||||
|
|
||||||
|
# firemní fakturační údaje
|
||||||
|
company_name = models.CharField(max_length=255, blank=True)
|
||||||
|
ico = models.CharField(max_length=20, blank=True)
|
||||||
|
dic = models.CharField(max_length=20, blank=True)
|
||||||
|
|
||||||
postal_code = models.CharField(
|
postal_code = models.CharField(
|
||||||
max_length=5,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
|
|
||||||
|
max_length=5,
|
||||||
validators=[
|
validators=[
|
||||||
RegexValidator(
|
RegexValidator(
|
||||||
regex=r'^\d{5}$',
|
regex=r'^\d{5}$',
|
||||||
@@ -109,44 +96,80 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
gdpr = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
is_active = models.BooleanField(default=False)
|
USERNAME_FIELD = "username"
|
||||||
|
REQUIRED_FIELDS = [
|
||||||
objects = CustomUserActiveManager()
|
"email"
|
||||||
all_objects = CustomUserAllManager()
|
]
|
||||||
|
|
||||||
REQUIRED_FIELDS = ['email', "username", "password"]
|
|
||||||
|
|
||||||
|
|
||||||
def __str__(self):
|
# Ensure default manager has get_by_natural_key
|
||||||
return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}"
|
objects = CustomUserManager()
|
||||||
|
# Optional convenience manager for active users only
|
||||||
|
active = ActiveUserManager()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self.is_active = False
|
self.is_active = False
|
||||||
|
|
||||||
#self.orders.all().update(is_deleted=True, deleted_at=timezone.now())
|
|
||||||
|
|
||||||
return super().delete(*args, **kwargs)
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
is_new = self.pk is None # check BEFORE saving
|
is_new = self._state.adding # True if object hasn't been saved yet
|
||||||
|
|
||||||
|
# Pre-save flags for new users
|
||||||
if is_new:
|
if is_new:
|
||||||
|
|
||||||
if self.is_superuser or self.role == "admin":
|
if self.is_superuser or self.role == "admin":
|
||||||
|
# ensure admin flags are consistent
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
|
|
||||||
if self.role == 'admin':
|
|
||||||
self.is_staff = True
|
self.is_staff = True
|
||||||
self.is_superuser = True
|
self.is_superuser = True
|
||||||
|
self.role = "admin"
|
||||||
if self.is_superuser:
|
|
||||||
self.role = 'admin'
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.is_staff = False
|
self.is_staff = False
|
||||||
|
|
||||||
|
# First save to obtain a primary key
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Assign group after we have a PK
|
||||||
|
if is_new:
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
group, _ = Group.objects.get_or_create(name=self.role)
|
||||||
|
# Use add/set now that PK exists
|
||||||
|
self.groups.set([group])
|
||||||
|
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str:
|
||||||
|
token = get_random_string(length=length)
|
||||||
|
self.email_verification_token = token
|
||||||
|
self.email_verification_sent_at = timezone.now()
|
||||||
|
if save:
|
||||||
|
self.save(update_fields=["email_verification_token", "email_verification_sent_at"])
|
||||||
|
return token
|
||||||
|
|
||||||
|
def verify_email_token(self, token: str, max_age_hours: int = 48, save: bool = True) -> bool:
|
||||||
|
if not token or not self.email_verification_token:
|
||||||
|
return False
|
||||||
|
# optional expiry check
|
||||||
|
if self.email_verification_sent_at:
|
||||||
|
age = timezone.now() - self.email_verification_sent_at
|
||||||
|
if age > timedelta(hours=max_age_hours):
|
||||||
|
return False
|
||||||
|
if token != self.email_verification_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.email_verified:
|
||||||
|
self.email_verified = True
|
||||||
|
# clear token after success
|
||||||
|
self.email_verification_token = None
|
||||||
|
self.email_verification_sent_at = None
|
||||||
|
if save:
|
||||||
|
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_anonymous_user():
|
||||||
|
"""Return the singleton anonymous user."""
|
||||||
|
User = CustomUser
|
||||||
|
return User.objects.get(username="anonymous")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,25 @@
|
|||||||
|
from urllib import request
|
||||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework_api_key.permissions import HasAPIKey
|
from rest_framework_api_key.permissions import HasAPIKey
|
||||||
|
|
||||||
|
|
||||||
#Podle svého uvážení (NEPOUŽÍVAT!!!)
|
|
||||||
class RolePermission(BasePermission):
|
|
||||||
allowed_roles = []
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
# Je uživatel přihlášený a má roli z povolených?
|
|
||||||
user_has_role = (
|
|
||||||
request.user and
|
|
||||||
request.user.is_authenticated and
|
|
||||||
getattr(request.user, "role", None) in self.allowed_roles
|
|
||||||
)
|
|
||||||
|
|
||||||
# Má API klíč?
|
|
||||||
has_api_key = HasAPIKey().has_permission(request, view)
|
|
||||||
|
|
||||||
|
|
||||||
return user_has_role or has_api_key
|
|
||||||
|
|
||||||
|
|
||||||
#TOHLE POUŽÍT!!!
|
#TOHLE POUŽÍT!!!
|
||||||
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
|
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
|
||||||
def RoleAllowed(*roles):
|
def RoleAllowed(*roles):
|
||||||
"""
|
"""
|
||||||
Allows safe methods for any authenticated user.
|
Allows safe methods for any authenticated user.
|
||||||
Allows unsafe methods only for users with specific roles.
|
Allows unsafe methods only for users with specific roles.
|
||||||
|
Allows access if a valid API key is provided.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
RolerAllowed('admin', 'user')
|
RoleAllowed('admin', 'user')
|
||||||
"""
|
"""
|
||||||
class SafeOrRolePermission(BasePermission):
|
class SafeOrRolePermission(BasePermission):
|
||||||
|
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
|
# Má API klíč?
|
||||||
|
has_api_key = HasAPIKey().has_permission(request, view)
|
||||||
|
|
||||||
# Allow safe methods for any authenticated user
|
# Allow safe methods for any authenticated user
|
||||||
if request.method in SAFE_METHODS:
|
if request.method in SAFE_METHODS:
|
||||||
return IsAuthenticated().has_permission(request, view)
|
return IsAuthenticated().has_permission(request, view)
|
||||||
@@ -71,3 +55,23 @@ class AdminOnly(BasePermission):
|
|||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
|
||||||
|
|
||||||
|
# Commerce-specific permissions
|
||||||
|
class AdminWriteOnlyOrReadOnly(BasePermission):
|
||||||
|
"""Allow read for anyone, write only for admins"""
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return True
|
||||||
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
|
||||||
|
|
||||||
|
class AdminOnlyForPatchOtherwisePublic(BasePermission):
|
||||||
|
"""Allow GET/POST for anyone, PATCH/PUT/DELETE only for admins"""
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if request.method in SAFE_METHODS or request.method == "POST":
|
||||||
|
return True
|
||||||
|
if request.method in ["PATCH", "PUT", "DELETE"]:
|
||||||
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
# Default to admin for other unsafe methods
|
||||||
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
|
||||||
|
|||||||
@@ -27,21 +27,16 @@ class CustomUserSerializer(serializers.ModelSerializer):
|
|||||||
"last_name",
|
"last_name",
|
||||||
"email",
|
"email",
|
||||||
"role",
|
"role",
|
||||||
"account_type",
|
|
||||||
"email_verified",
|
"email_verified",
|
||||||
"phone_number",
|
"phone_number",
|
||||||
"create_time",
|
"create_time",
|
||||||
"var_symbol",
|
|
||||||
"bank_account",
|
|
||||||
"ICO",
|
|
||||||
"RC",
|
|
||||||
"city",
|
"city",
|
||||||
"street",
|
"street",
|
||||||
"PSC",
|
"postal_code",
|
||||||
"GDPR",
|
"gdpr",
|
||||||
"is_active",
|
"is_active",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "create_time", "GDPR", "username"] # <-- removed "account_type"
|
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
user = self.context["request"].user
|
user = self.context["request"].user
|
||||||
|
|||||||
@@ -10,76 +10,112 @@ from .models import CustomUser
|
|||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
@shared_task
|
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
|
||||||
def send_password_reset_email_task(user_id):
|
"""
|
||||||
try:
|
Send emails rendering a single HTML template.
|
||||||
user = CustomUser.objects.get(pk=user_id)
|
- `template_name` is a simple base name without extension, e.g. "email/test".
|
||||||
except CustomUser.DoesNotExist:
|
- Renders only HTML (".html"), no ".txt" support.
|
||||||
error_msg = f"Task send_password_reset_email has failed. Invalid User ID was sent."
|
- Converts `user` in context to a plain dict to avoid passing models to templates.
|
||||||
logger.error(error_msg)
|
"""
|
||||||
raise Exception(error_msg)
|
if isinstance(recipients, str):
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
recipients = [recipients]
|
||||||
token = password_reset_token.make_token(user)
|
|
||||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
html_message = None
|
||||||
|
if template_path:
|
||||||
|
ctx = dict(context or {})
|
||||||
|
# Render base layout and include the provided template as the main content.
|
||||||
|
# The included template receives the same context as the base.
|
||||||
html_message = render_to_string(
|
html_message = render_to_string(
|
||||||
'emails/password_reset.html',
|
"email/components/base.html",
|
||||||
{'reset_url': reset_url}
|
{"content_template": template_path, **ctx},
|
||||||
)
|
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
|
||||||
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
|
|
||||||
send_email_with_context(
|
|
||||||
recipients=user.email,
|
|
||||||
subject="Obnova hesla",
|
|
||||||
message=None,
|
|
||||||
html_message=html_message
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only email verification for user registration
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message or "",
|
||||||
|
from_email=None,
|
||||||
|
recipient_list=recipients,
|
||||||
|
fail_silently=False,
|
||||||
|
html_message=html_message,
|
||||||
|
)
|
||||||
|
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend' and message:
|
||||||
|
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"E-mail se neodeslal: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
#----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# This function sends an email to the user for email verification after registration.
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_email_verification_task(user_id):
|
def send_email_verification_task(user_id):
|
||||||
try:
|
try:
|
||||||
user = CustomUser.objects.get(pk=user_id)
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
except CustomUser.DoesNotExist:
|
except CustomUser.DoesNotExist:
|
||||||
error_msg = f"Task send_email_verification_task has failed. Invalid User ID was sent."
|
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
|
||||||
logger.error(error_msg)
|
return 0
|
||||||
raise Exception(error_msg)
|
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
token = account_activation_token.make_token(user)
|
# {changed} generate and store a per-user token
|
||||||
verification_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
token = user.generate_email_verification_token()
|
||||||
html_message = render_to_string(
|
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||||
'emails/email_verification.html',
|
|
||||||
{'verification_url': verification_url}
|
context = {
|
||||||
)
|
"user": user,
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
"action_url": verify_url,
|
||||||
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Ověřit e‑mail",
|
||||||
|
}
|
||||||
|
|
||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=user.email,
|
recipients=user.email,
|
||||||
subject="Ověření e-mailu",
|
subject="Ověření e‑mailu",
|
||||||
message=None,
|
template_path="email/email_verification.html",
|
||||||
html_message=html_message
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def send_email_with_context(recipients, subject, message=None, html_message=None):
|
@shared_task
|
||||||
"""
|
def send_email_test_task(email):
|
||||||
General function to send emails with a specific context.
|
context = {
|
||||||
"""
|
"action_url": settings.FRONTEND_URL,
|
||||||
if isinstance(recipients, str):
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
recipients = [recipients]
|
"cta_label": "Otevřít aplikaci",
|
||||||
|
}
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=email,
|
||||||
|
subject="Testovací e‑mail",
|
||||||
|
template_path="email/test.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_password_reset_email_task(user_id):
|
||||||
try:
|
try:
|
||||||
send_mail(
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
subject=subject,
|
except CustomUser.DoesNotExist:
|
||||||
message=message if message else '',
|
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
|
||||||
from_email=None,
|
return 0
|
||||||
recipient_list=recipients,
|
|
||||||
fail_silently=False,
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
html_message=html_message
|
token = password_reset_token.make_token(user)
|
||||||
|
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": user,
|
||||||
|
"action_url": reset_url,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Obnovit heslo",
|
||||||
|
}
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Obnova hesla",
|
||||||
|
template_path="email/password_reset.html",
|
||||||
|
context=context,
|
||||||
)
|
)
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
|
||||||
logger.debug("\nEMAIL OBSAH:\n", html_message if html_message else message, "\nKONEC OBSAHU")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"E-mail se neodeslal: {e}")
|
|
||||||
return False
|
|
||||||
|
|||||||
21
backend/account/templates/email/email_verification.html
Normal file
21
backend/account/templates/email/email_verification.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Ověření e‑mailu</h1>
|
||||||
|
|
||||||
|
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||||
|
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||||
|
{% endwith %}
|
||||||
|
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na tlačítko níže.</p>
|
||||||
|
|
||||||
|
{% if action_url and cta_label %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
|
{{ cta_label }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
21
backend/account/templates/email/password_reset.html
Normal file
21
backend/account/templates/email/password_reset.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Obnova hesla</h1>
|
||||||
|
|
||||||
|
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||||
|
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||||
|
{% endwith %}
|
||||||
|
<p style="margin:0 0 12px 0;">Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento e‑mail ignorujte.</p>
|
||||||
|
|
||||||
|
{% if action_url and cta_label %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
|
{{ cta_label }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
19
backend/account/templates/email/test.html
Normal file
19
backend/account/templates/email/test.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<h1 style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px; margin:0;">Testovací e‑mail</h1>
|
||||||
|
|
||||||
|
<div style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
|
<p style="margin:0 0 12px 0;">Dobrý den,</p>
|
||||||
|
<p style="margin:0 0 16px 0;">Toto je testovací e‑mail z aplikace e‑tržnice.</p>
|
||||||
|
|
||||||
|
{% if action_url and cta_label %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
|
{{ cta_label }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Ověření e-mailu</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container mt-5">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">Ověření e-mailu</h2>
|
|
||||||
<p class="card-text">Ověřte svůj e-mail kliknutím na odkaz níže:</p>
|
|
||||||
<a href="{{ verification_url }}" class="btn btn-success">Ověřit e-mail</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Obnova hesla</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container mt-5">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">Obnova hesla</h2>
|
|
||||||
<p class="card-text">Pro obnovu hesla klikněte na následující odkaz:</p>
|
|
||||||
<a href="{{ reset_url }}" class="btn btn-primary">Obnovit heslo</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,3 +1,28 @@
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
|
class UserViewAnonymousTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
User = get_user_model()
|
||||||
|
self.target_user = User.objects.create_user(
|
||||||
|
username="target",
|
||||||
|
email="target@example.com",
|
||||||
|
password="pass1234",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_anonymous_update_user_is_forbidden_and_does_not_crash(self):
|
||||||
|
url = f"/api/account/users/{self.target_user.id}/"
|
||||||
|
payload = {"username": "newname", "email": self.target_user.email}
|
||||||
|
resp = self.client.put(url, data=payload, format="json")
|
||||||
|
# Expect 403 Forbidden (permission denied), but most importantly no 500 error
|
||||||
|
self.assertEqual(resp.status_code, 403, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")
|
||||||
|
|
||||||
|
def test_anonymous_retrieve_user_is_unauthorized(self):
|
||||||
|
url = f"/api/account/users/{self.target_user.id}/"
|
||||||
|
resp = self.client.get(url)
|
||||||
|
# Retrieve requires authentication per view; expect 401 Unauthorized
|
||||||
|
self.assertEqual(resp.status_code, 401, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")
|
||||||
|
|||||||
@@ -18,16 +18,28 @@ password_reset_token = PasswordResetTokenGenerator()
|
|||||||
|
|
||||||
|
|
||||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||||
|
|
||||||
#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU
|
#COOKIE + AUTHORIZATION HEADER JWT AUTHENTICATION FOR AXIOS COMPATIBILITY
|
||||||
class CookieJWTAuthentication(JWTAuthentication):
|
class CookieJWTAuthentication(JWTAuthentication):
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
|
# First try Authorization header (standard axios pattern)
|
||||||
|
header_token = self.get_header(request)
|
||||||
|
if header_token is not None:
|
||||||
|
validated_token = self.get_validated_token(header_token)
|
||||||
|
return self.get_user(validated_token), validated_token
|
||||||
|
|
||||||
|
# Fallback to cookie-based authentication
|
||||||
raw_token = request.COOKIES.get('access_token')
|
raw_token = request.COOKIES.get('access_token')
|
||||||
|
|
||||||
if not raw_token:
|
if not raw_token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
validated_token = self.get_validated_token(raw_token)
|
validated_token = self.get_validated_token(raw_token)
|
||||||
return self.get_user(validated_token), validated_token
|
return self.get_user(validated_token), validated_token
|
||||||
|
except (InvalidToken, TokenError):
|
||||||
|
# Invalid/expired token - return None instead of raising exception
|
||||||
|
# This allows AllowAny endpoints to work even with bad cookies!!
|
||||||
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ urlpatterns = [
|
|||||||
# Registration & email endpoints
|
# Registration & email endpoints
|
||||||
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
||||||
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
|
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
|
||||||
path('activate/', views.UserActivationViewSet.as_view(), name='activate-user'),
|
|
||||||
|
|
||||||
# Password reset endpoints
|
# Password reset endpoints
|
||||||
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from .serializers import *
|
|||||||
from .permissions import *
|
from .permissions import *
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
from .tokens import *
|
from .tokens import *
|
||||||
from .tasks import send_password_reset_email_task
|
from .tasks import send_password_reset_email_task, send_email_verification_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,7 +38,7 @@ from rest_framework_simplejwt.views import TokenObtainPairView
|
|||||||
|
|
||||||
# Custom Token obtaining view
|
# Custom Token obtaining view
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Authentication"],
|
tags=["account", "public"],
|
||||||
summary="Obtain JWT access and refresh tokens (cookie-based)",
|
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.",
|
description="Authenticate user and obtain JWT access and refresh tokens. You can use either email or username.",
|
||||||
request=CustomTokenObtainPairSerializer,
|
request=CustomTokenObtainPairSerializer,
|
||||||
@@ -107,7 +107,7 @@ class CookieTokenObtainPairView(TokenObtainPairView):
|
|||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Authentication"],
|
tags=["account", "public"],
|
||||||
summary="Refresh JWT token using cookie",
|
summary="Refresh JWT token using cookie",
|
||||||
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
||||||
responses={
|
responses={
|
||||||
@@ -160,10 +160,10 @@ class CookieTokenRefreshView(APIView):
|
|||||||
except TokenError:
|
except TokenError:
|
||||||
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
|
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
#---------------------------------------------LOGIN/LOGOUT------------------------------------------------
|
#---------------------------------------------LOGOUT------------------------------------------------
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Authentication"],
|
tags=["account", "public"],
|
||||||
summary="Logout user (delete access and refresh token cookies)",
|
summary="Logout user (delete access and refresh token cookies)",
|
||||||
description="Logs out the user by deleting access and refresh token cookies.",
|
description="Logs out the user by deleting access and refresh token cookies.",
|
||||||
responses={
|
responses={
|
||||||
@@ -185,7 +185,7 @@ class LogoutView(APIView):
|
|||||||
#--------------------------------------------------------------------------------------------------------------
|
#--------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User"],
|
tags=["account"],
|
||||||
summary="List, retrieve, update, and delete users.",
|
summary="List, retrieve, update, and delete users.",
|
||||||
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
||||||
responses={
|
responses={
|
||||||
@@ -222,6 +222,15 @@ class UserView(viewsets.ModelViewSet):
|
|||||||
"is_active": {"help_text": "Stav aktivace uživatele."},
|
"is_active": {"help_text": "Stav aktivace uživatele."},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account"],
|
||||||
|
summary="Get permissions based on user role and action.",
|
||||||
|
description="Determines permissions for various actions based on user role and ownership.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Permissions determined successfully."),
|
||||||
|
403: OpenApiResponse(description="Permission denied."),
|
||||||
|
},
|
||||||
|
)
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
# Only admin can list or create users
|
# Only admin can list or create users
|
||||||
if self.action in ['list', 'create']:
|
if self.action in ['list', 'create']:
|
||||||
@@ -229,15 +238,20 @@ class UserView(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
# Only admin or the user themselves can update or delete
|
# Only admin or the user themselves can update or delete
|
||||||
elif self.action in ['update', 'partial_update', 'destroy']:
|
elif self.action in ['update', 'partial_update', 'destroy']:
|
||||||
if self.request.user.role == 'admin':
|
user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
|
||||||
|
# Admins can modify any user
|
||||||
|
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
|
||||||
return [OnlyRolesAllowed("admin")()]
|
return [OnlyRolesAllowed("admin")()]
|
||||||
elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']:
|
|
||||||
|
# Users can modify their own record
|
||||||
|
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
|
||||||
return [IsAuthenticated()]
|
return [IsAuthenticated()]
|
||||||
else:
|
|
||||||
# fallback - deny access
|
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
||||||
return [OnlyRolesAllowed("admin")()]
|
return [OnlyRolesAllowed("admin")()]
|
||||||
|
|
||||||
# Any authenticated user can retrieve (view) any user's profile
|
# Any authenticated user can retrieve (view) any user's profile
|
||||||
|
#FIXME: popřemýšlet co vše může získat
|
||||||
elif self.action == 'retrieve':
|
elif self.action == 'retrieve':
|
||||||
return [IsAuthenticated()]
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
@@ -247,7 +261,7 @@ class UserView(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
# Get current user data
|
# Get current user data
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User"],
|
tags=["account"],
|
||||||
summary="Get current authenticated user",
|
summary="Get current authenticated user",
|
||||||
description="Returns details of the currently authenticated user based on JWT token or session.",
|
description="Returns details of the currently authenticated user based on JWT token or session.",
|
||||||
responses={
|
responses={
|
||||||
@@ -267,7 +281,7 @@ class CurrentUserView(APIView):
|
|||||||
|
|
||||||
#1. registration API
|
#1. registration API
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User Registration"],
|
tags=["account", "public"],
|
||||||
summary="Register a new user (company or individual)",
|
summary="Register a new user (company or individual)",
|
||||||
description="Register a new user (company or individual). The user will receive an email with a verification link.",
|
description="Register a new user (company or individual). The user will receive an email with a verification link.",
|
||||||
request=UserRegistrationSerializer,
|
request=UserRegistrationSerializer,
|
||||||
@@ -299,7 +313,7 @@ class UserRegistrationViewSet(ModelViewSet):
|
|||||||
|
|
||||||
#2. confirming email
|
#2. confirming email
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User Registration"],
|
tags=["account", "public"],
|
||||||
summary="Verify user email via link",
|
summary="Verify user email via link",
|
||||||
description="Verify user email using the link with uid and token.",
|
description="Verify user email using the link with uid and token.",
|
||||||
parameters=[
|
parameters=[
|
||||||
@@ -321,45 +335,18 @@ class EmailVerificationView(APIView):
|
|||||||
|
|
||||||
if account_activation_token.check_token(user, token):
|
if account_activation_token.check_token(user, token):
|
||||||
user.email_verified = True
|
user.email_verified = True
|
||||||
|
user.is_active = True # Aktivace uživatele po ověření e-mailu
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
return Response({"detail": "E-mail byl úspěšně ověřen. Účet čeká na schválení."})
|
return Response({"detail": "E-mail byl úspěšně ověřen. Účet je aktivován."})
|
||||||
else:
|
else:
|
||||||
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
||||||
|
|
||||||
#3. seller activation API (var_symbol)
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User Registration"],
|
|
||||||
summary="Activate user and set variable symbol (admin/cityClerk only)",
|
|
||||||
description="Activate user and set variable symbol. Only accessible by admin or cityClerk.",
|
|
||||||
request=UserActivationSerializer,
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(response=UserActivationSerializer, description="User activated successfully."),
|
|
||||||
400: OpenApiResponse(description="Invalid activation data."),
|
|
||||||
404: OpenApiResponse(description="User not found."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class UserActivationViewSet(APIView):
|
|
||||||
permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')]
|
|
||||||
|
|
||||||
def patch(self, request, *args, **kwargs):
|
|
||||||
serializer = UserActivationSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
user = serializer.save()
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Celery not available, using fallback. Error: {e}")
|
|
||||||
send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol
|
|
||||||
|
|
||||||
return Response(serializer.to_representation(user), status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
|
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
|
||||||
|
|
||||||
#1. PasswordReset + send Email
|
#1. PasswordReset + send Email
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User password reset"],
|
tags=["account", "public"],
|
||||||
summary="Request password reset (send email)",
|
summary="Request password reset (send email)",
|
||||||
description="Request password reset by providing registered email. An email with instructions will be sent.",
|
description="Request password reset by providing registered email. An email with instructions will be sent.",
|
||||||
request=PasswordResetRequestSerializer,
|
request=PasswordResetRequestSerializer,
|
||||||
@@ -389,7 +376,7 @@ class PasswordResetRequestView(APIView):
|
|||||||
|
|
||||||
#2. Confirming reset
|
#2. Confirming reset
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User password reset"],
|
tags=["account", "public"],
|
||||||
summary="Confirm password reset via token",
|
summary="Confirm password reset via token",
|
||||||
description="Confirm password reset using token from email.",
|
description="Confirm password reset using token from email.",
|
||||||
request=PasswordResetConfirmSerializer,
|
request=PasswordResetConfirmSerializer,
|
||||||
@@ -419,3 +406,6 @@ class PasswordResetConfirmView(APIView):
|
|||||||
user.save()
|
user.save()
|
||||||
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
||||||
return Response(serializer.errors, status=400)
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
3
backend/advertisement/admin.py
Normal file
3
backend/advertisement/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/advertisement/apps.py
Normal file
6
backend/advertisement/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AdvertisementConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'advertisement'
|
||||||
23
backend/advertisement/migrations/0001_initial.py
Normal file
23
backend/advertisement/migrations/0001_initial.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2025-12-14 02:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContactMe',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('client_email', models.EmailField(max_length=254)),
|
||||||
|
('content', models.TextField()),
|
||||||
|
('sent_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
14
backend/advertisement/models.py
Normal file
14
backend/advertisement/models.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
|
||||||
|
class ContactMe(models.Model):
|
||||||
|
client_email = models.EmailField()
|
||||||
|
content = models.TextField()
|
||||||
|
|
||||||
|
sent_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Email to {self.client_email} sent at {self.sent_at}"
|
||||||
|
|
||||||
|
|
||||||
9
backend/advertisement/serializer.py
Normal file
9
backend/advertisement/serializer.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import ContactMe
|
||||||
|
|
||||||
|
|
||||||
|
class ContactMeSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactMe
|
||||||
|
fields = ["id", "client_email", "content", "sent_at"]
|
||||||
|
read_only_fields = ["id", "sent_at"]
|
||||||
49
backend/advertisement/tasks.py
Normal file
49
backend/advertisement/tasks.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from venv import create
|
||||||
|
from account.tasks import send_email_with_context
|
||||||
|
from configuration.models import SiteConfiguration
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from commerce.models import Product
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_contact_me_email_task(client_email, message_content):
|
||||||
|
context = {
|
||||||
|
"client_email": client_email,
|
||||||
|
"message_content": message_content
|
||||||
|
}
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=SiteConfiguration.get_solo().contact_email,
|
||||||
|
subject="Poptávka z kontaktního formuláře!!!",
|
||||||
|
template_path="email/contact_me.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_newly_added_items_to_store_email_task_last_week():
|
||||||
|
last_week_date = datetime.datetime.now() - datetime.timedelta(days=7)
|
||||||
|
|
||||||
|
"""
|
||||||
|
__lte -> Less than or equal
|
||||||
|
__gte -> Greater than or equal
|
||||||
|
__lt -> Less than
|
||||||
|
__gt -> Greater than
|
||||||
|
"""
|
||||||
|
|
||||||
|
products_of_week = Product.objects.filter(
|
||||||
|
include_in_week_summary_email=True,
|
||||||
|
created_at__gte=last_week_date
|
||||||
|
)
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=SiteConfiguration.get_solo().contact_email,
|
||||||
|
subject="Nový produkt přidán do obchodu",
|
||||||
|
template_path="email/advertisement/commerce/new_items_added_this_week.html",
|
||||||
|
context={
|
||||||
|
"products_of_week": products_of_week,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<style>
|
||||||
|
.summary {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.product-item {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding: 15px 0;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.product-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.product-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.product-price {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.product-description {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.product-date {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.no-products {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 30px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h2 style="color: #007bff; margin: 0 0 20px 0;">🆕 Nové produkty v obchodě</h2>
|
||||||
|
<p style="margin: 0 0 20px 0;">Týdenní přehled nově přidaných produktů</p>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<h3 style="margin: 0 0 10px 0;">📊 Celkem nových produktů: {{ products_of_week|length }}</h3>
|
||||||
|
<p style="margin: 0;">Přehled produktů přidaných za posledních 7 dní</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if products_of_week %}
|
||||||
|
{% for product in products_of_week %}
|
||||||
|
<div class="product-item">
|
||||||
|
<div class="product-name">{{ product.name }}</div>
|
||||||
|
|
||||||
|
{% if product.price %}
|
||||||
|
<div class="product-price">
|
||||||
|
{{ product.price|floatformat:0 }} {{ product.currency|default:"Kč" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if product.short_description %}
|
||||||
|
<div class="product-description">
|
||||||
|
{{ product.short_description|truncatewords:20 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="product-date">
|
||||||
|
Přidáno: {{ product.created_at|date:"d.m.Y H:i" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="no-products">
|
||||||
|
<h3 style="margin: 0 0 15px 0;">🤷♂️ Žádné nové produkty</h3>
|
||||||
|
<p style="margin: 0;">Za posledních 7 dní nebyly přidány žádné nové produkty, které by měly být zahrnuty do týdenního přehledu.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
6
backend/advertisement/templates/email/contact_me.html
Normal file
6
backend/advertisement/templates/email/contact_me.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<h2 style="margin:0 0 12px 0; font-family:Arial, Helvetica, sans-serif;">Nová zpráva z kontaktního formuláře</h2>
|
||||||
|
<div style="border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; font-family:Arial, Helvetica, sans-serif;">
|
||||||
|
<p><span style="font-weight:600;">Email odesílatele:</span> {{ client_email }}</p>
|
||||||
|
<p style="font-weight:600;">Zpráva:</p>
|
||||||
|
<pre style="white-space: pre-wrap; word-wrap: break-word;">{{ message_content }}</pre>
|
||||||
|
</div>
|
||||||
3
backend/advertisement/tests.py
Normal file
3
backend/advertisement/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
16
backend/advertisement/urls.py
Normal file
16
backend/advertisement/urls.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from .views import ContactMePublicView, ContactMeAdminViewSet, trigger_weekly_email
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"contact-messages", ContactMeAdminViewSet, basename="contactme")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Public endpoint
|
||||||
|
path("contact-me/", ContactMePublicView.as_view(), name="contact-me"),
|
||||||
|
|
||||||
|
# Admin endpoints
|
||||||
|
path("", include(router.urls)),
|
||||||
|
path("trigger-weekly-email/", trigger_weekly_email, name="trigger-weekly-email"),
|
||||||
|
]
|
||||||
86
backend/advertisement/views.py
Normal file
86
backend/advertisement/views.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||||
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
|
||||||
|
from .models import ContactMe
|
||||||
|
from .serializer import ContactMeSerializer
|
||||||
|
from .tasks import send_contact_me_email_task, send_newly_added_items_to_store_email_task_last_week
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(tags=["advertisement", "public"])
|
||||||
|
class ContactMePublicView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
# Avoid CSRF for public endpoint by disabling SessionAuthentication
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
email = request.data.get("email")
|
||||||
|
message = request.data.get("message")
|
||||||
|
honeypot = request.data.get("hp") # hidden honeypot field
|
||||||
|
|
||||||
|
# If honeypot is filled, pretend success without processing
|
||||||
|
if honeypot:
|
||||||
|
return Response({"status": "ok"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
if not email or not message:
|
||||||
|
return Response({"detail": "Missing email or message."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Save to DB
|
||||||
|
cm = ContactMe.objects.create(client_email=email, content=message)
|
||||||
|
|
||||||
|
# Send email via Celery task
|
||||||
|
try:
|
||||||
|
send_contact_me_email_task.delay(email, message)
|
||||||
|
except Exception:
|
||||||
|
# Fallback to direct call if Celery is not running in DEV
|
||||||
|
send_contact_me_email_task(email, message)
|
||||||
|
|
||||||
|
return Response({"id": cm.id, "status": "queued"}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=["advertisement"], summary="List contact messages (admin)"),
|
||||||
|
retrieve=extend_schema(tags=["advertisement"], summary="Retrieve contact message (admin)"),
|
||||||
|
create=extend_schema(tags=["advertisement"], summary="Create contact message (admin)"),
|
||||||
|
partial_update=extend_schema(tags=["advertisement"], summary="Update contact message (admin)"),
|
||||||
|
update=extend_schema(tags=["advertisement"], summary="Replace contact message (admin)"),
|
||||||
|
destroy=extend_schema(tags=["advertisement"], summary="Delete contact message (admin)"),
|
||||||
|
)
|
||||||
|
class ContactMeAdminViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = ContactMe.objects.all().order_by("-sent_at")
|
||||||
|
serializer_class = ContactMeSerializer
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["advertisement"],
|
||||||
|
summary="Manually trigger weekly new items email",
|
||||||
|
description="Triggers the weekly email task that sends a summary of newly added products from the last week. Only accessible by admin users.",
|
||||||
|
methods=["POST"]
|
||||||
|
)
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAdminUser])
|
||||||
|
def trigger_weekly_email(request):
|
||||||
|
"""
|
||||||
|
Manually trigger the weekly new items email task.
|
||||||
|
Only accessible by admin users.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Trigger the task asynchronously
|
||||||
|
task = send_newly_added_items_to_store_email_task_last_week.delay()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Weekly email task triggered successfully',
|
||||||
|
'task_id': task.id
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Failed to trigger weekly email task: {str(e)}'
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
117
backend/commerce/admin.py
Normal file
117
backend/commerce/admin.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import (
|
||||||
|
Category, Product, ProductImage, Order, OrderItem,
|
||||||
|
Carrier, Payment, DiscountCode, Refund, Invoice, Cart, CartItem, Wishlist
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Category)
|
||||||
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "url", "parent")
|
||||||
|
search_fields = ("name", "description")
|
||||||
|
prepopulated_fields = {"url": ("name",)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Product)
|
||||||
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "price", "stock", "is_active", "category", "created_at")
|
||||||
|
search_fields = ("name", "description", "code")
|
||||||
|
list_filter = ("is_active", "category", "created_at")
|
||||||
|
prepopulated_fields = {"url": ("name",)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProductImage)
|
||||||
|
class ProductImageAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("product", "is_main", "alt_text")
|
||||||
|
list_filter = ("is_main",)
|
||||||
|
search_fields = ("product__name", "alt_text")
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItemInline(admin.TabularInline):
|
||||||
|
model = OrderItem
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ("product", "quantity")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Order)
|
||||||
|
class OrderAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "user", "email", "status", "total_price", "currency", "created_at")
|
||||||
|
list_filter = ("status", "created_at", "country")
|
||||||
|
search_fields = ("email", "first_name", "last_name", "phone")
|
||||||
|
readonly_fields = ("created_at", "updated_at", "total_price")
|
||||||
|
inlines = [OrderItemInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(OrderItem)
|
||||||
|
class OrderItemAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("order", "product", "quantity")
|
||||||
|
search_fields = ("order__id", "product__name")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Carrier)
|
||||||
|
class CarrierAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "shipping_method", "state", "shipping_price", "weight")
|
||||||
|
list_filter = ("shipping_method", "state", "returning")
|
||||||
|
search_fields = ("id",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Payment)
|
||||||
|
class PaymentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "payment_method", "created_at")
|
||||||
|
list_filter = ("payment_method", "created_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DiscountCode)
|
||||||
|
class DiscountCodeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("code", "percent", "amount", "active", "valid_from", "valid_to", "used_count", "usage_limit")
|
||||||
|
list_filter = ("active", "valid_from", "valid_to")
|
||||||
|
search_fields = ("code", "description")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Refund)
|
||||||
|
class RefundAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("order", "reason_choice", "verified", "created_at")
|
||||||
|
list_filter = ("verified", "reason_choice", "created_at")
|
||||||
|
search_fields = ("order__id", "order__email", "reason_text")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Invoice)
|
||||||
|
class InvoiceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("invoice_number", "issued_at", "due_date")
|
||||||
|
search_fields = ("invoice_number",)
|
||||||
|
readonly_fields = ("issued_at",)
|
||||||
|
|
||||||
|
|
||||||
|
class CartItemInline(admin.TabularInline):
|
||||||
|
model = CartItem
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ("product", "quantity", "added_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Cart)
|
||||||
|
class CartAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "user", "session_key", "created_at", "updated_at")
|
||||||
|
list_filter = ("created_at", "updated_at")
|
||||||
|
search_fields = ("user__email", "session_key")
|
||||||
|
readonly_fields = ("created_at", "updated_at")
|
||||||
|
inlines = [CartItemInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CartItem)
|
||||||
|
class CartItemAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("cart", "product", "quantity", "added_at")
|
||||||
|
list_filter = ("added_at",)
|
||||||
|
search_fields = ("cart__id", "product__name")
|
||||||
|
readonly_fields = ("added_at",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Wishlist)
|
||||||
|
class WishlistAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "product_count", "created_at", "updated_at")
|
||||||
|
search_fields = ("user__email", "user__username")
|
||||||
|
readonly_fields = ("created_at", "updated_at")
|
||||||
|
filter_horizontal = ("products",)
|
||||||
|
|
||||||
|
def product_count(self, obj):
|
||||||
|
return obj.products.count()
|
||||||
|
product_count.short_description = "Products Count"
|
||||||
506
backend/commerce/analytics.py
Normal file
506
backend/commerce/analytics.py
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
"""
|
||||||
|
E-commerce Analytics Module
|
||||||
|
|
||||||
|
Provides comprehensive business intelligence for the e-commerce platform.
|
||||||
|
All analytics functions return data structures suitable for frontend charts/graphs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db.models import Sum, Count, Avg, Q, F
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek
|
||||||
|
|
||||||
|
from .models import Order, Product, OrderItem, Payment, Carrier, Review, Cart, CartItem
|
||||||
|
from configuration.models import SiteConfiguration
|
||||||
|
|
||||||
|
|
||||||
|
class SalesAnalytics:
|
||||||
|
"""Sales and revenue analytics"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def revenue_overview(
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None,
|
||||||
|
period: str = "daily"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get revenue overview with configurable date range and period
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: Start date for analysis (default: last 30 days)
|
||||||
|
end_date: End date for analysis (default: today)
|
||||||
|
period: "daily", "weekly", "monthly" (default: daily)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with total_revenue, order_count, avg_order_value, and time_series data
|
||||||
|
"""
|
||||||
|
if not start_date:
|
||||||
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now()
|
||||||
|
|
||||||
|
# Base queryset for completed orders
|
||||||
|
orders = Order.objects.filter(
|
||||||
|
status=Order.OrderStatus.COMPLETED,
|
||||||
|
created_at__range=(start_date, end_date)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate totals
|
||||||
|
totals = orders.aggregate(
|
||||||
|
total_revenue=Sum('total_price'),
|
||||||
|
order_count=Count('id'),
|
||||||
|
avg_order_value=Avg('total_price')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Time series data based on period
|
||||||
|
trunc_function = {
|
||||||
|
'daily': TruncDate,
|
||||||
|
'weekly': TruncWeek,
|
||||||
|
'monthly': TruncMonth,
|
||||||
|
}.get(period, TruncDate)
|
||||||
|
|
||||||
|
time_series = (
|
||||||
|
orders
|
||||||
|
.annotate(period=trunc_function('created_at'))
|
||||||
|
.values('period')
|
||||||
|
.annotate(
|
||||||
|
revenue=Sum('total_price'),
|
||||||
|
orders=Count('id')
|
||||||
|
)
|
||||||
|
.order_by('period')
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_revenue': totals['total_revenue'] or Decimal('0'),
|
||||||
|
'order_count': totals['order_count'] or 0,
|
||||||
|
'avg_order_value': totals['avg_order_value'] or Decimal('0'),
|
||||||
|
'time_series': list(time_series),
|
||||||
|
'period': period,
|
||||||
|
'date_range': {
|
||||||
|
'start': start_date.isoformat(),
|
||||||
|
'end': end_date.isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def payment_methods_breakdown(
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get breakdown of payment methods usage"""
|
||||||
|
if not start_date:
|
||||||
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now()
|
||||||
|
|
||||||
|
payment_stats = (
|
||||||
|
Payment.objects
|
||||||
|
.filter(order__created_at__range=(start_date, end_date))
|
||||||
|
.values('payment_method')
|
||||||
|
.annotate(
|
||||||
|
count=Count('id'),
|
||||||
|
revenue=Sum('order__total_price')
|
||||||
|
)
|
||||||
|
.order_by('-revenue')
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'method': item['payment_method'],
|
||||||
|
'method_display': dict(Payment.PAYMENT.choices).get(item['payment_method'], item['payment_method']),
|
||||||
|
'count': item['count'],
|
||||||
|
'revenue': item['revenue'] or Decimal('0'),
|
||||||
|
'percentage': 0 # Will be calculated in the view
|
||||||
|
}
|
||||||
|
for item in payment_stats
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductAnalytics:
|
||||||
|
"""Product performance analytics"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def top_selling_products(
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None,
|
||||||
|
limit: int = 10
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get top selling products by quantity and revenue"""
|
||||||
|
if not start_date:
|
||||||
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now()
|
||||||
|
|
||||||
|
top_products = (
|
||||||
|
OrderItem.objects
|
||||||
|
.filter(order__created_at__range=(start_date, end_date))
|
||||||
|
.select_related('product')
|
||||||
|
.values('product__id', 'product__name', 'product__price')
|
||||||
|
.annotate(
|
||||||
|
total_quantity=Sum('quantity'),
|
||||||
|
total_revenue=Sum(F('quantity') * F('product__price')),
|
||||||
|
order_count=Count('order', distinct=True)
|
||||||
|
)
|
||||||
|
.order_by('-total_revenue')[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'product_id': item['product__id'],
|
||||||
|
'product_name': item['product__name'],
|
||||||
|
'unit_price': item['product__price'],
|
||||||
|
'total_quantity': item['total_quantity'],
|
||||||
|
'total_revenue': item['total_revenue'],
|
||||||
|
'order_count': item['order_count'],
|
||||||
|
'avg_quantity_per_order': round(item['total_quantity'] / item['order_count'], 2)
|
||||||
|
}
|
||||||
|
for item in top_products
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def category_performance(
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get category performance breakdown"""
|
||||||
|
if not start_date:
|
||||||
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now()
|
||||||
|
|
||||||
|
category_stats = (
|
||||||
|
OrderItem.objects
|
||||||
|
.filter(order__created_at__range=(start_date, end_date))
|
||||||
|
.select_related('product__category')
|
||||||
|
.values('product__category__id', 'product__category__name')
|
||||||
|
.annotate(
|
||||||
|
total_quantity=Sum('quantity'),
|
||||||
|
total_revenue=Sum(F('quantity') * F('product__price')),
|
||||||
|
product_count=Count('product', distinct=True),
|
||||||
|
order_count=Count('order', distinct=True)
|
||||||
|
)
|
||||||
|
.order_by('-total_revenue')
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'category_id': item['product__category__id'],
|
||||||
|
'category_name': item['product__category__name'],
|
||||||
|
'total_quantity': item['total_quantity'],
|
||||||
|
'total_revenue': item['total_revenue'],
|
||||||
|
'product_count': item['product_count'],
|
||||||
|
'order_count': item['order_count']
|
||||||
|
}
|
||||||
|
for item in category_stats
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def inventory_analysis() -> Dict[str, Any]:
|
||||||
|
"""Get inventory status and low stock alerts"""
|
||||||
|
total_products = Product.objects.filter(is_active=True).count()
|
||||||
|
out_of_stock = Product.objects.filter(is_active=True, stock=0).count()
|
||||||
|
low_stock = Product.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
stock__gt=0,
|
||||||
|
stock__lte=10 # Consider configurable threshold
|
||||||
|
).count()
|
||||||
|
|
||||||
|
low_stock_products = (
|
||||||
|
Product.objects
|
||||||
|
.filter(is_active=True, stock__lte=10)
|
||||||
|
.select_related('category')
|
||||||
|
.values('id', 'name', 'stock', 'category__name')
|
||||||
|
.order_by('stock')[:20]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_products': total_products,
|
||||||
|
'out_of_stock_count': out_of_stock,
|
||||||
|
'low_stock_count': low_stock,
|
||||||
|
'in_stock_count': total_products - out_of_stock,
|
||||||
|
'low_stock_products': list(low_stock_products),
|
||||||
|
'stock_distribution': {
|
||||||
|
'out_of_stock': out_of_stock,
|
||||||
|
'low_stock': low_stock,
|
||||||
|
'in_stock': total_products - out_of_stock - low_stock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerAnalytics:
|
||||||
|
"""Customer behavior and demographics analytics"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def customer_overview(
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get customer acquisition and behavior overview"""
|
||||||
|
if not start_date:
|
||||||
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now()
|
||||||
|
|
||||||
|
# New vs returning customers
|
||||||
|
period_orders = Order.objects.filter(created_at__range=(start_date, end_date))
|
||||||
|
|
||||||
|
# First-time customers (users with their first order in this period)
|
||||||
|
first_time_customers = period_orders.filter(
|
||||||
|
user__orders__created_at__lt=start_date
|
||||||
|
).values('user').distinct().count()
|
||||||
|
|
||||||
|
# Returning customers
|
||||||
|
total_customers = period_orders.values('user').distinct().count()
|
||||||
|
returning_customers = total_customers - first_time_customers
|
||||||
|
|
||||||
|
# Customer lifetime value (simplified)
|
||||||
|
customer_stats = (
|
||||||
|
Order.objects
|
||||||
|
.filter(user__isnull=False)
|
||||||
|
.values('user')
|
||||||
|
.annotate(
|
||||||
|
total_orders=Count('id'),
|
||||||
|
total_spent=Sum('total_price'),
|
||||||
|
avg_order_value=Avg('total_price')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
avg_customer_ltv = customer_stats.aggregate(
|
||||||
|
avg_ltv=Avg('total_spent')
|
||||||
|
)['avg_ltv'] or Decimal('0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_customers': total_customers,
|
||||||
|
'new_customers': first_time_customers,
|
||||||
|
'returning_customers': returning_customers,
|
||||||
|
'avg_customer_lifetime_value': avg_customer_ltv,
|
||||||
|
'date_range': {
|
||||||
|
'start': start_date.isoformat(),
|
||||||
|
'end': end_date.isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cart_abandonment_analysis() -> Dict[str, Any]:
|
||||||
|
"""Analyze cart abandonment rates"""
|
||||||
|
# Active carts (updated in last 7 days)
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
active_carts = Cart.objects.filter(updated_at__gte=week_ago)
|
||||||
|
|
||||||
|
# Completed orders from carts
|
||||||
|
completed_orders = Order.objects.filter(
|
||||||
|
user__cart__in=active_carts,
|
||||||
|
created_at__gte=week_ago
|
||||||
|
).count()
|
||||||
|
|
||||||
|
total_carts = active_carts.count()
|
||||||
|
abandoned_carts = max(0, total_carts - completed_orders)
|
||||||
|
abandonment_rate = (abandoned_carts / total_carts * 100) if total_carts > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_active_carts': total_carts,
|
||||||
|
'completed_orders': completed_orders,
|
||||||
|
'abandoned_carts': abandoned_carts,
|
||||||
|
'abandonment_rate': round(abandonment_rate, 2),
|
||||||
|
'analysis_period': '7 days'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ShippingAnalytics:
|
||||||
|
"""Shipping and logistics analytics"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def shipping_methods_breakdown(
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get breakdown of shipping methods usage"""
|
||||||
|
if not start_date:
|
||||||
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now()
|
||||||
|
|
||||||
|
shipping_stats = (
|
||||||
|
Carrier.objects
|
||||||
|
.filter(order__created_at__range=(start_date, end_date))
|
||||||
|
.values('shipping_method', 'state')
|
||||||
|
.annotate(
|
||||||
|
count=Count('id'),
|
||||||
|
total_shipping_cost=Sum('shipping_price')
|
||||||
|
)
|
||||||
|
.order_by('-count')
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'shipping_method': item['shipping_method'],
|
||||||
|
'method_display': dict(Carrier.SHIPPING.choices).get(item['shipping_method'], item['shipping_method']),
|
||||||
|
'state': item['state'],
|
||||||
|
'state_display': dict(Carrier.STATE.choices).get(item['state'], item['state']),
|
||||||
|
'count': item['count'],
|
||||||
|
'total_cost': item['total_shipping_cost'] or Decimal('0')
|
||||||
|
}
|
||||||
|
for item in shipping_stats
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def deutsche_post_analytics() -> Dict[str, Any]:
|
||||||
|
"""Get Deutsche Post shipping analytics and pricing info"""
|
||||||
|
try:
|
||||||
|
# Import Deutsche Post models
|
||||||
|
from thirdparty.deutschepost.models import DeutschePostOrder
|
||||||
|
|
||||||
|
# Get Deutsche Post orders statistics
|
||||||
|
dp_orders = DeutschePostOrder.objects.all()
|
||||||
|
total_dp_orders = dp_orders.count()
|
||||||
|
|
||||||
|
# Get configuration for pricing
|
||||||
|
config = SiteConfiguration.get_solo()
|
||||||
|
dp_default_price = config.deutschepost_shipping_price
|
||||||
|
|
||||||
|
# Status breakdown (if available in the model)
|
||||||
|
# Note: This depends on actual DeutschePostOrder model structure
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_deutsche_post_orders': total_dp_orders,
|
||||||
|
'default_shipping_price': dp_default_price,
|
||||||
|
'api_configured': bool(config.deutschepost_client_id and config.deutschepost_client_secret),
|
||||||
|
'api_endpoint': config.deutschepost_api_url,
|
||||||
|
'analysis_note': 'Detailed Deutsche Post analytics require API integration'
|
||||||
|
}
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
return {
|
||||||
|
'error': 'Deutsche Post module not available',
|
||||||
|
'total_deutsche_post_orders': 0,
|
||||||
|
'default_shipping_price': Decimal('0')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewAnalytics:
|
||||||
|
"""Product review and rating analytics"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def review_overview(
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get review statistics and sentiment overview"""
|
||||||
|
if not start_date:
|
||||||
|
start_date = timezone.now() - timedelta(days=30)
|
||||||
|
if not end_date:
|
||||||
|
end_date = timezone.now()
|
||||||
|
|
||||||
|
reviews = Review.objects.filter(created_at__range=(start_date, end_date))
|
||||||
|
|
||||||
|
rating_distribution = (
|
||||||
|
reviews
|
||||||
|
.values('rating')
|
||||||
|
.annotate(count=Count('id'))
|
||||||
|
.order_by('rating')
|
||||||
|
)
|
||||||
|
|
||||||
|
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg'] or 0
|
||||||
|
total_reviews = reviews.count()
|
||||||
|
|
||||||
|
# Top rated products
|
||||||
|
top_rated_products = (
|
||||||
|
Review.objects
|
||||||
|
.filter(created_at__range=(start_date, end_date))
|
||||||
|
.select_related('product')
|
||||||
|
.values('product__id', 'product__name')
|
||||||
|
.annotate(
|
||||||
|
avg_rating=Avg('rating'),
|
||||||
|
review_count=Count('id')
|
||||||
|
)
|
||||||
|
.filter(review_count__gte=3) # At least 3 reviews
|
||||||
|
.order_by('-avg_rating')[:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_reviews': total_reviews,
|
||||||
|
'average_rating': round(avg_rating, 2),
|
||||||
|
'rating_distribution': [
|
||||||
|
{
|
||||||
|
'rating': item['rating'],
|
||||||
|
'count': item['count'],
|
||||||
|
'percentage': round(item['count'] / total_reviews * 100, 1) if total_reviews > 0 else 0
|
||||||
|
}
|
||||||
|
for item in rating_distribution
|
||||||
|
],
|
||||||
|
'top_rated_products': list(top_rated_products),
|
||||||
|
'date_range': {
|
||||||
|
'start': start_date.isoformat(),
|
||||||
|
'end': end_date.isoformat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsAggregator:
|
||||||
|
"""Main analytics aggregator for dashboard views"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dashboard_overview(
|
||||||
|
start_date: Optional[datetime] = None,
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get comprehensive dashboard data"""
|
||||||
|
return {
|
||||||
|
'sales': SalesAnalytics.revenue_overview(start_date, end_date),
|
||||||
|
'products': {
|
||||||
|
'top_selling': ProductAnalytics.top_selling_products(start_date, end_date, limit=5),
|
||||||
|
'inventory': ProductAnalytics.inventory_analysis()
|
||||||
|
},
|
||||||
|
'customers': CustomerAnalytics.customer_overview(start_date, end_date),
|
||||||
|
'shipping': {
|
||||||
|
'methods': ShippingAnalytics.shipping_methods_breakdown(start_date, end_date),
|
||||||
|
'deutsche_post': ShippingAnalytics.deutsche_post_analytics()
|
||||||
|
},
|
||||||
|
'reviews': ReviewAnalytics.review_overview(start_date, end_date),
|
||||||
|
'generated_at': timezone.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_predefined_date_ranges() -> Dict[str, Dict[str, datetime]]:
|
||||||
|
"""Get predefined date ranges for easy frontend integration"""
|
||||||
|
now = timezone.now()
|
||||||
|
return {
|
||||||
|
'today': {
|
||||||
|
'start': now.replace(hour=0, minute=0, second=0, microsecond=0),
|
||||||
|
'end': now
|
||||||
|
},
|
||||||
|
'yesterday': {
|
||||||
|
'start': (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0),
|
||||||
|
'end': (now - timedelta(days=1)).replace(hour=23, minute=59, second=59)
|
||||||
|
},
|
||||||
|
'last_7_days': {
|
||||||
|
'start': now - timedelta(days=7),
|
||||||
|
'end': now
|
||||||
|
},
|
||||||
|
'last_30_days': {
|
||||||
|
'start': now - timedelta(days=30),
|
||||||
|
'end': now
|
||||||
|
},
|
||||||
|
'last_90_days': {
|
||||||
|
'start': now - timedelta(days=90),
|
||||||
|
'end': now
|
||||||
|
},
|
||||||
|
'this_month': {
|
||||||
|
'start': now.replace(day=1, hour=0, minute=0, second=0, microsecond=0),
|
||||||
|
'end': now
|
||||||
|
},
|
||||||
|
'last_month': {
|
||||||
|
'start': (now.replace(day=1) - timedelta(days=1)).replace(day=1, hour=0, minute=0, second=0, microsecond=0),
|
||||||
|
'end': (now.replace(day=1) - timedelta(days=1)).replace(hour=23, minute=59, second=59)
|
||||||
|
},
|
||||||
|
'this_year': {
|
||||||
|
'start': now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0),
|
||||||
|
'end': now
|
||||||
|
},
|
||||||
|
'last_year': {
|
||||||
|
'start': (now.replace(month=1, day=1) - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0),
|
||||||
|
'end': (now.replace(month=1, day=1) - timedelta(days=1)).replace(hour=23, minute=59, second=59)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
backend/commerce/apps.py
Normal file
6
backend/commerce/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CommerceConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'commerce'
|
||||||
162
backend/commerce/migrations/0001_initial.py
Normal file
162
backend/commerce/migrations/0001_initial.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-19 08:55
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stripe', '0001_initial'),
|
||||||
|
('zasilkovna', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Invoice',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('invoice_number', models.CharField(max_length=50, unique=True)),
|
||||||
|
('issued_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('due_date', models.DateTimeField()),
|
||||||
|
('pdf_file', models.FileField(upload_to='invoices/')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Carrier',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('shipping_method', models.CharField(choices=[('packeta', 'cz#Zásilkovna'), ('store', 'cz#Osobní odběr')], default='store', max_length=20)),
|
||||||
|
('state', models.CharField(choices=[('ordered', 'cz#Objednávka se připravuje'), ('shipped', 'cz#Odesláno'), ('delivered', 'cz#Doručeno'), ('ready_to_pickup', 'cz#Připraveno k vyzvednutí')], default='ordered', max_length=20)),
|
||||||
|
('weight', models.DecimalField(blank=True, decimal_places=2, help_text='Hmotnost zásilky v kg', max_digits=10, null=True)),
|
||||||
|
('returning', models.BooleanField(default=False, help_text='Zda je tato zásilka na vrácení')),
|
||||||
|
('shipping_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||||
|
('zasilkovna', models.ManyToManyField(blank=True, related_name='carriers', to='zasilkovna.zasilkovnapacket')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Category',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('url', models.SlugField(unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('image', models.ImageField(blank=True, upload_to='categories/')),
|
||||||
|
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='commerce.category')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'Categories',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DiscountCode',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('code', models.CharField(max_length=50, unique=True)),
|
||||||
|
('description', models.CharField(blank=True, max_length=255)),
|
||||||
|
('percent', models.PositiveIntegerField(blank=True, help_text='Procento sleva 0-100', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
|
||||||
|
('amount', models.DecimalField(blank=True, decimal_places=2, help_text='Fixní sleva v CZK', max_digits=10, null=True)),
|
||||||
|
('valid_from', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('valid_to', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('active', models.BooleanField(default=True)),
|
||||||
|
('usage_limit', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('used_count', models.PositiveIntegerField(default=0)),
|
||||||
|
('specific_categories', models.ManyToManyField(blank=True, related_name='discount_codes', to='commerce.category')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Payment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('payment_method', models.CharField(choices=[('Site', 'cz#Platba v obchodě'), ('stripe', 'cz#Bankovní převod'), ('cash_on_delivery', 'cz#Dobírka')], default='Site', max_length=30)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('stripe', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payment', to='stripe.stripemodel')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Order',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('status', models.CharField(blank=True, choices=[('created', 'cz#Vytvořeno'), ('cancelled', 'cz#Zrušeno'), ('completed', 'cz#Dokončeno'), ('refunding', 'cz#Vrácení v procesu'), ('refunded', 'cz#Vráceno')], default='created', max_length=20, null=True)),
|
||||||
|
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||||
|
('currency', models.CharField(default='CZK', max_length=10)),
|
||||||
|
('first_name', models.CharField(max_length=100)),
|
||||||
|
('last_name', models.CharField(max_length=100)),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('phone', models.CharField(blank=True, max_length=20)),
|
||||||
|
('address', models.CharField(max_length=255)),
|
||||||
|
('city', models.CharField(max_length=100)),
|
||||||
|
('postal_code', models.CharField(max_length=20)),
|
||||||
|
('country', models.CharField(default='Czech Republic', max_length=100)),
|
||||||
|
('note', models.TextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('carrier', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='commerce.carrier')),
|
||||||
|
('discount', models.ManyToManyField(blank=True, related_name='orders', to='commerce.discountcode')),
|
||||||
|
('invoice', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='commerce.invoice')),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='orders', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('payment', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='commerce.payment')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Product',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('code', models.CharField(blank=True, max_length=100, null=True, unique=True)),
|
||||||
|
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('url', models.SlugField(unique=True)),
|
||||||
|
('stock', models.PositiveIntegerField(default=0)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('limited_to', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='commerce.category')),
|
||||||
|
('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')),
|
||||||
|
('variants', models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', related_name='variant_of', to='commerce.product')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OrderItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', models.PositiveIntegerField(default=1)),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.order')),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.product')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='discountcode',
|
||||||
|
name='specific_products',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='discount_codes', to='commerce.product'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProductImage',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('image', models.ImageField(upload_to='products/')),
|
||||||
|
('alt_text', models.CharField(blank=True, max_length=150)),
|
||||||
|
('is_main', models.BooleanField(default=False)),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='commerce.product')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Refund',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('reason_choice', models.CharField(choices=[('retuning_before_fourteen_day_period', 'cz#Vrácení před uplynutím 14-ti denní lhůty'), ('damaged_product', 'cz#Poškozený produkt'), ('wrong_item', 'cz#Špatná položka'), ('other', 'cz#Jiný důvod')], max_length=40)),
|
||||||
|
('reason_text', models.TextField(blank=True)),
|
||||||
|
('verified', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refunds', to='commerce.order')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-17 01:37
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('commerce', '0001_initial'),
|
||||||
|
('deutschepost', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='productimage',
|
||||||
|
options={'ordering': ['order', '-is_main', 'id']},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='carrier',
|
||||||
|
name='deutschepost',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='carriers', to='deutschepost.deutschepostorder'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='productimage',
|
||||||
|
name='order',
|
||||||
|
field=models.PositiveIntegerField(default=0, help_text='Display order (lower numbers first)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='carrier',
|
||||||
|
name='shipping_method',
|
||||||
|
field=models.CharField(choices=[('packeta', 'cz#Zásilkovna'), ('deutschepost', 'cz#Deutsche Post'), ('store', 'cz#Osobní odběr')], default='store', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='product',
|
||||||
|
name='variants',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', to='commerce.product'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Cart',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('session_key', models.CharField(blank=True, help_text='Session key for anonymous users', max_length=40, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Cart',
|
||||||
|
'verbose_name_plural': 'Carts',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Review',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('rating', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
|
||||||
|
('comment', models.TextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='commerce.product')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CartItem',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('quantity', models.PositiveIntegerField(default=1)),
|
||||||
|
('added_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.cart')),
|
||||||
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='commerce.product')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Cart Item',
|
||||||
|
'verbose_name_plural': 'Cart Items',
|
||||||
|
'unique_together': {('cart', 'product')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
905
backend/commerce/models.py
Normal file
905
backend/commerce/models.py
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
from ast import Or
|
||||||
|
import dis
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.validators import MaxValueValidator, MinValueValidator, validate_email
|
||||||
|
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
except ImportError:
|
||||||
|
HTML = None
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
from configuration.models import SiteConfiguration
|
||||||
|
|
||||||
|
from thirdparty.zasilkovna.models import ZasilkovnaPacket
|
||||||
|
from thirdparty.stripe.models import StripeModel
|
||||||
|
|
||||||
|
from .tasks import notify_refund_accepted, notify_Ready_to_pickup, notify_zasilkovna_sended
|
||||||
|
|
||||||
|
#FIXME: přidat soft delete pro všchny modely !!!!
|
||||||
|
|
||||||
|
class Category(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
|
||||||
|
#adresa kategorie např: /category/elektronika/mobily/
|
||||||
|
url = models.SlugField(unique=True)
|
||||||
|
|
||||||
|
#kategorie se můžou skládat pod sebe
|
||||||
|
parent = models.ForeignKey(
|
||||||
|
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='subcategories'
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
#ikona
|
||||||
|
image = models.ImageField(upload_to='categories/', blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "Categories"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
#TODO: přidate brand model pro produkty (značky)
|
||||||
|
|
||||||
|
class Product(models.Model):
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
code = models.CharField(max_length=100, unique=True, blank=True, null=True)
|
||||||
|
|
||||||
|
variants = models.ManyToManyField(
|
||||||
|
"self",
|
||||||
|
symmetrical=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=(
|
||||||
|
"Symetrické varianty produktu: pokud přidáte variantu A → B, "
|
||||||
|
"Django automaticky přidá i variantu B → A. "
|
||||||
|
"Všechny varianty jsou rovnocenné a zobrazí se vzájemně."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
# -- CENA --
|
||||||
|
price = models.DecimalField(max_digits=10, decimal_places=2, help_text="Net price (without VAT)")
|
||||||
|
currency = models.CharField(max_length=3, default="CZK")
|
||||||
|
|
||||||
|
# VAT rate - configured by business owner in configuration app!!!
|
||||||
|
vat_rate = models.ForeignKey(
|
||||||
|
'configuration.VATRate',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="VAT rate for this product. Leave empty to use default rate."
|
||||||
|
)
|
||||||
|
|
||||||
|
url = models.SlugField(unique=True)
|
||||||
|
|
||||||
|
stock = models.PositiveIntegerField(default=0)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
#časový limit (volitelné)
|
||||||
|
limited_to = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
#TODO: delete
|
||||||
|
default_carrier = models.ForeignKey(
|
||||||
|
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
|
||||||
|
)
|
||||||
|
|
||||||
|
include_in_week_summary_email = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
return self.is_active and self.stock > 0
|
||||||
|
|
||||||
|
def get_vat_rate(self):
|
||||||
|
"""Get the VAT rate for this product (from configuration or default)"""
|
||||||
|
if self.vat_rate:
|
||||||
|
return self.vat_rate
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from configuration.models import VATRate
|
||||||
|
return VATRate.get_default()
|
||||||
|
|
||||||
|
def get_price_with_vat(self):
|
||||||
|
"""Get price including VAT"""
|
||||||
|
vat_rate = self.get_vat_rate()
|
||||||
|
if not vat_rate:
|
||||||
|
return self.price # No VAT configured
|
||||||
|
return self.price * (Decimal('1') + vat_rate.rate_decimal)
|
||||||
|
|
||||||
|
def get_vat_amount(self):
|
||||||
|
"""Get the VAT amount for this product"""
|
||||||
|
vat_rate = self.get_vat_rate()
|
||||||
|
if not vat_rate:
|
||||||
|
return Decimal('0')
|
||||||
|
return self.price * vat_rate.rate_decimal
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_price_with_vat()} {self.currency.upper()} inkl. MwSt)"
|
||||||
|
|
||||||
|
#obrázek pro produkty
|
||||||
|
class ProductImage(models.Model):
|
||||||
|
product = models.ForeignKey(Product, related_name='images', on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
image = models.ImageField(upload_to='products/')
|
||||||
|
|
||||||
|
alt_text = models.CharField(max_length=150, blank=True)
|
||||||
|
is_main = models.BooleanField(default=False)
|
||||||
|
order = models.PositiveIntegerField(default=0, help_text="Display order (lower numbers first)")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['order', '-is_main', 'id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.product.name} image"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ OBJEDNÁVKY ------------------
|
||||||
|
|
||||||
|
class Order(models.Model):
|
||||||
|
class OrderStatus(models.TextChoices):
|
||||||
|
CREATED = "created", "Vytvořeno"
|
||||||
|
CANCELLED = "cancelled", "Zrušeno"
|
||||||
|
COMPLETED = "completed", "Dokončeno"
|
||||||
|
|
||||||
|
REFUNDING = "refunding", "Vrácení v procesu"
|
||||||
|
REFUNDED = "refunded", "Vráceno"
|
||||||
|
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20, choices=OrderStatus.choices, null=True, blank=True, default=OrderStatus.CREATED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stored order grand total; recalculated on save
|
||||||
|
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
currency = models.CharField(max_length=10, default="CZK")
|
||||||
|
|
||||||
|
# fakturační údaje (zkopírované z user profilu při objednávce)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
first_name = models.CharField(max_length=100)
|
||||||
|
last_name = models.CharField(max_length=100)
|
||||||
|
email = models.EmailField()
|
||||||
|
phone = models.CharField(max_length=20, blank=True)
|
||||||
|
address = models.CharField(max_length=255)
|
||||||
|
city = models.CharField(max_length=100)
|
||||||
|
postal_code = models.CharField(max_length=20)
|
||||||
|
country = models.CharField(max_length=100, default="Czech Republic")
|
||||||
|
|
||||||
|
note = models.TextField(blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
carrier = models.OneToOneField(
|
||||||
|
"Carrier",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="order",
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = models.OneToOneField(
|
||||||
|
"Payment",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="order",
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", null=True, blank=True)
|
||||||
|
|
||||||
|
#FIXME: změnnit název na discount_code
|
||||||
|
discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders")
|
||||||
|
|
||||||
|
def calculate_total_price(self):
|
||||||
|
carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0")
|
||||||
|
|
||||||
|
if self.discount.exists():
|
||||||
|
discounts = list(self.discount.all())
|
||||||
|
|
||||||
|
total = Decimal('0.0')
|
||||||
|
|
||||||
|
# getting all prices from order items (with discount applied if valid)
|
||||||
|
for item in self.items.all():
|
||||||
|
total = total + item.get_total_price(discounts)
|
||||||
|
|
||||||
|
return total + carrier_price
|
||||||
|
|
||||||
|
else:
|
||||||
|
total = Decimal('0.0')
|
||||||
|
# getting all prices from order items (without discount) - using VAT-inclusive prices
|
||||||
|
|
||||||
|
for item in self.items.all():
|
||||||
|
total = total + (item.product.get_price_with_vat() * item.quantity)
|
||||||
|
|
||||||
|
return total + carrier_price
|
||||||
|
|
||||||
|
def import_data_from_user(self):
|
||||||
|
"""Import user data into order for billing purposes."""
|
||||||
|
self.first_name = self.user.first_name
|
||||||
|
self.last_name = self.user.last_name
|
||||||
|
self.email = self.user.email
|
||||||
|
self.phone = self.user.phone
|
||||||
|
self.address = f"{self.user.street} {self.user.street_number}"
|
||||||
|
self.city = self.user.city
|
||||||
|
self.postal_code = self.user.postal_code
|
||||||
|
self.country = self.user.country
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Validate order data"""
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ['first_name', 'last_name', 'email', 'address', 'city', 'postal_code']
|
||||||
|
for field in required_fields:
|
||||||
|
if not getattr(self, field):
|
||||||
|
raise ValidationError(f"{field.replace('_', ' ').title()} is required.")
|
||||||
|
|
||||||
|
# Validate email format
|
||||||
|
try:
|
||||||
|
validate_email(self.email)
|
||||||
|
except ValidationError:
|
||||||
|
raise ValidationError("Invalid email format.")
|
||||||
|
|
||||||
|
# Validate order has items
|
||||||
|
if self.pk and not self.items.exists():
|
||||||
|
raise ValidationError("Order must have at least one item.")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Keep total_price always in sync with items and discount
|
||||||
|
self.total_price = self.calculate_total_price()
|
||||||
|
|
||||||
|
is_new = self.pk is None
|
||||||
|
|
||||||
|
if self.user and is_new:
|
||||||
|
self.import_data_from_user()
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Send email notification for new orders
|
||||||
|
if is_new and self.user:
|
||||||
|
from .tasks import notify_order_successfuly_created
|
||||||
|
notify_order_successfuly_created.delay(order=self, user=self.user)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
|
||||||
|
|
||||||
|
class Carrier(models.Model):
|
||||||
|
class SHIPPING(models.TextChoices):
|
||||||
|
ZASILKOVNA = "packeta", "Zásilkovna"
|
||||||
|
DEUTSCHEPOST = "deutschepost", "Deutsche Post"
|
||||||
|
STORE = "store", "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í"
|
||||||
|
#RETURNING = "returning", "Vracení objednávky"
|
||||||
|
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING)
|
||||||
|
|
||||||
|
# prodejce to přidá později
|
||||||
|
zasilkovna = models.ManyToManyField(
|
||||||
|
ZasilkovnaPacket, blank=True, related_name="carriers"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deutsche Post integration (same pattern as zasilkovna)
|
||||||
|
deutschepost = models.ManyToManyField(
|
||||||
|
"deutschepost.DeutschePostOrder", blank=True, related_name="carriers"
|
||||||
|
)
|
||||||
|
|
||||||
|
weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Hmotnost zásilky v kg")
|
||||||
|
|
||||||
|
returning = models.BooleanField(default=False, help_text="Zda je tato zásilka na vrácení")
|
||||||
|
|
||||||
|
shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Set shipping price for new carriers
|
||||||
|
if self.pk is None and self.shipping_price is None:
|
||||||
|
# For new carriers, we might not have an order yet
|
||||||
|
self.shipping_price = self.get_price(order=None)
|
||||||
|
|
||||||
|
# Check if state changed to ready for pickup
|
||||||
|
old_state = None
|
||||||
|
if self.pk:
|
||||||
|
old_carrier = Carrier.objects.filter(pk=self.pk).first()
|
||||||
|
old_state = old_carrier.state if old_carrier else None
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Send notification if state changed to ready for pickup
|
||||||
|
if (old_state != self.STATE.READY_TO_PICKUP and
|
||||||
|
self.state == self.STATE.READY_TO_PICKUP and
|
||||||
|
self.shipping_method == self.SHIPPING.STORE):
|
||||||
|
|
||||||
|
if hasattr(self, 'order') and self.order:
|
||||||
|
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
|
||||||
|
|
||||||
|
def get_price(self, order=None):
|
||||||
|
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||||||
|
return SiteConfiguration.get_solo().zasilkovna_shipping_price
|
||||||
|
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
|
||||||
|
return SiteConfiguration.get_solo().deutschepost_shipping_price
|
||||||
|
elif self.shipping_method == self.SHIPPING.STORE:
|
||||||
|
# Store pickup is always free
|
||||||
|
return Decimal('0.0')
|
||||||
|
else:
|
||||||
|
# Check for free shipping based on order total
|
||||||
|
if order is None:
|
||||||
|
order = Order.objects.filter(carrier=self).first()
|
||||||
|
|
||||||
|
if order and order.total_price >= SiteConfiguration.get_solo().free_shipping_over:
|
||||||
|
return Decimal('0.0')
|
||||||
|
else:
|
||||||
|
return SiteConfiguration.get_solo().default_shipping_price or Decimal('50.0') # fallback price
|
||||||
|
|
||||||
|
|
||||||
|
#tohle bude vyvoláno pomocí admina přes api!!!
|
||||||
|
def start_ordering_shipping(self):
|
||||||
|
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||||||
|
# Uživatel může objednat více zásilek pokud potřeba
|
||||||
|
self.zasilkovna.add(ZasilkovnaPacket.objects.create())
|
||||||
|
self.returning = False
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
notify_zasilkovna_sended.delay(order=self.order, user=self.order.user)
|
||||||
|
|
||||||
|
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from thirdparty.deutschepost.models import DeutschePostOrder
|
||||||
|
|
||||||
|
# Create new Deutsche Post order and add to carrier (same pattern as zasilkovna)
|
||||||
|
dp_order = DeutschePostOrder.objects.create()
|
||||||
|
self.deutschepost.add(dp_order)
|
||||||
|
self.returning = False
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# Order shipping through Deutsche Post API
|
||||||
|
dp_order.order_shippment()
|
||||||
|
|
||||||
|
elif self.shipping_method == self.SHIPPING.STORE:
|
||||||
|
self.state = self.STATE.READY_TO_PICKUP
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.")
|
||||||
|
|
||||||
|
|
||||||
|
#... další logika pro jiné způsoby dopravy (do budoucna!)
|
||||||
|
|
||||||
|
|
||||||
|
def ready_to_pickup(self):
|
||||||
|
if self.shipping_method == self.SHIPPING.STORE:
|
||||||
|
self.state = self.STATE.READY_TO_PICKUP
|
||||||
|
self.save()
|
||||||
|
else:
|
||||||
|
raise ValidationError("Tato metoda dopravy nepodporuje připravení k vyzvednutí.")
|
||||||
|
|
||||||
|
# def returning_shipping(self, int:id):
|
||||||
|
# self.returning = True
|
||||||
|
|
||||||
|
# if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||||||
|
# #volá se na api Zásilkovny
|
||||||
|
# self.zasilkovna.get(id=id).returning_packet()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ PLATEBNÍ MODELY ------------------
|
||||||
|
|
||||||
|
class Payment(models.Model):
|
||||||
|
class PAYMENT(models.TextChoices):
|
||||||
|
SHOP = "shop", "Platba v obchodě"
|
||||||
|
STRIPE = "stripe", "Platební Brána"
|
||||||
|
CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka"
|
||||||
|
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
|
||||||
|
|
||||||
|
#FIXME: potvrdit že logika platby funguje správně
|
||||||
|
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
|
||||||
|
stripe = models.OneToOneField(
|
||||||
|
StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
|
||||||
|
)
|
||||||
|
payed_at_shop = models.BooleanField(default=False, null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Validate payment and shipping method combinations"""
|
||||||
|
# Validate payment method consistency
|
||||||
|
if self.payment_method == self.PAYMENT.STRIPE and not self.stripe:
|
||||||
|
raise ValidationError("Stripe payment method requires a linked StripeModel instance.")
|
||||||
|
|
||||||
|
elif self.payment_method == self.PAYMENT.SHOP and self.stripe:
|
||||||
|
raise ValidationError("Shop payment method should not have a linked StripeModel instance.")
|
||||||
|
|
||||||
|
# Validate payment and shipping compatibility
|
||||||
|
if self.payment_method == self.PAYMENT.SHOP:
|
||||||
|
# SHOP payment only works with STORE pickup - customer pays at physical store
|
||||||
|
if Order.objects.filter(payment=self).exists():
|
||||||
|
order = Order.objects.get(payment=self)
|
||||||
|
|
||||||
|
if order.carrier and order.carrier.shipping_method != Carrier.SHIPPING.STORE:
|
||||||
|
raise ValidationError(
|
||||||
|
"Shop payment is only compatible with store pickup. "
|
||||||
|
"For shipping orders, use Stripe or Cash on Delivery payment methods."
|
||||||
|
)
|
||||||
|
|
||||||
|
elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY:
|
||||||
|
# Cash on delivery only works with shipping methods (not store pickup)
|
||||||
|
if Order.objects.filter(payment=self).exists():
|
||||||
|
order = Order.objects.get(payment=self)
|
||||||
|
|
||||||
|
if order.carrier and order.carrier.shipping_method == Carrier.SHIPPING.STORE:
|
||||||
|
raise ValidationError(
|
||||||
|
"Cash on delivery is not compatible with store pickup. "
|
||||||
|
"For store pickup, use shop payment method."
|
||||||
|
)
|
||||||
|
|
||||||
|
# STRIPE payment works with all shipping methods - no additional validation needed
|
||||||
|
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
def payed_manually(self):
|
||||||
|
"""Mark payment as completed"""
|
||||||
|
if self.payment_method == self.PAYMENT.SHOP:
|
||||||
|
self.payed_at_shop = True
|
||||||
|
self.save()
|
||||||
|
else:
|
||||||
|
raise ValidationError("Manuální platba je povolena pouze pro platbu v obchodě.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ SLEVOVÉ KÓDY ------------------
|
||||||
|
|
||||||
|
class DiscountCode(models.Model):
|
||||||
|
code = models.CharField(max_length=50, unique=True)
|
||||||
|
description = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
# sleva v procentech (0–100)
|
||||||
|
percent = models.PositiveIntegerField(
|
||||||
|
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
||||||
|
help_text="Procento sleva 0-100",
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# nebo fixní částka
|
||||||
|
amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK")
|
||||||
|
|
||||||
|
valid_from = models.DateTimeField(default=timezone.now)
|
||||||
|
valid_to = models.DateTimeField(null=True, blank=True)
|
||||||
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
#max počet použití
|
||||||
|
usage_limit = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
used_count = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
specific_products = models.ManyToManyField(
|
||||||
|
Product, blank=True, related_name="discount_codes"
|
||||||
|
)
|
||||||
|
specific_categories = models.ManyToManyField(
|
||||||
|
Category, blank=True, related_name="discount_codes"
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
now = timezone.now()
|
||||||
|
if not self.active:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.valid_to and self.valid_to < now:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.usage_limit and self.used_count >= self.usage_limit:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.code} ({self.percent}% or {self.amount} CZK)"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ OBJEDNANÉ POLOŽKY ------------------
|
||||||
|
|
||||||
|
class OrderItem(models.Model):
|
||||||
|
order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
|
||||||
|
product = models.ForeignKey("commerce.Product", on_delete=models.PROTECT)
|
||||||
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
|
def get_total_price(self, discounts: list[DiscountCode] = list()):
|
||||||
|
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
|
||||||
|
|
||||||
|
Logika dle SiteConfiguration:
|
||||||
|
- multiplying_coupons=True: procentuální slevy se násobí (sekvenčně)
|
||||||
|
P * (1 - p1) -> výsledné * (1 - p2) ...
|
||||||
|
jinak se použije pouze nejlepší (nejvyšší procento).
|
||||||
|
- addition_of_coupons_amount=True: fixní částky (amount) se sčítají,
|
||||||
|
jinak se použije pouze nejvyšší částka.
|
||||||
|
- Kombinace: nejprve procentuální část, poté odečtení fixní částky.
|
||||||
|
- Sleva se nikdy nesmí dostat pod 0.
|
||||||
|
"""
|
||||||
|
# Use VAT-inclusive price for customer-facing calculations
|
||||||
|
base_price = self.product.get_price_with_vat() * self.quantity
|
||||||
|
|
||||||
|
if not discounts or discounts == []:
|
||||||
|
return base_price
|
||||||
|
|
||||||
|
config = SiteConfiguration.get_solo()
|
||||||
|
|
||||||
|
#seznám slev
|
||||||
|
applicable_percent_discounts: list[int] = []
|
||||||
|
applicable_amount_discounts: list[Decimal] = []
|
||||||
|
|
||||||
|
#procházení kupónů a určení, které se aplikují
|
||||||
|
for discount in set(discounts):
|
||||||
|
if not discount:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not discount.is_valid():
|
||||||
|
raise ValueError("Invalid discount code.")
|
||||||
|
|
||||||
|
#defaulting
|
||||||
|
applies = False
|
||||||
|
|
||||||
|
# Určení, zda kupon platí pro produkt/kategorii
|
||||||
|
# prázdný produkt a kategorie = globální kupon
|
||||||
|
if discount.specific_products.exists() or discount.specific_categories.exists():
|
||||||
|
if (self.product in discount.specific_products.all() or self.product.category in discount.specific_categories.all()):
|
||||||
|
applies = True
|
||||||
|
|
||||||
|
else:
|
||||||
|
applies = True #global
|
||||||
|
|
||||||
|
if not applies:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if discount.percent is not None:
|
||||||
|
applicable_percent_discounts.append(discount.percent)
|
||||||
|
elif discount.amount is not None:
|
||||||
|
applicable_amount_discounts.append(discount.amount)
|
||||||
|
|
||||||
|
final_price = base_price
|
||||||
|
|
||||||
|
# Procentuální slevy
|
||||||
|
if applicable_percent_discounts:
|
||||||
|
|
||||||
|
if config.multiplying_coupons:
|
||||||
|
for pct in applicable_percent_discounts:
|
||||||
|
factor = (Decimal('1') - (Decimal(pct) / Decimal('100')))
|
||||||
|
final_price = final_price * factor
|
||||||
|
else:
|
||||||
|
best_pct = max(applicable_percent_discounts)
|
||||||
|
factor = (Decimal('1') - (Decimal(best_pct) / Decimal('100')))
|
||||||
|
final_price = final_price * factor
|
||||||
|
|
||||||
|
# Fixní částky
|
||||||
|
if applicable_amount_discounts:
|
||||||
|
if config.addition_of_coupons_amount:
|
||||||
|
total_amount = sum(applicable_amount_discounts)
|
||||||
|
|
||||||
|
else:
|
||||||
|
total_amount = max(applicable_amount_discounts)
|
||||||
|
|
||||||
|
final_price = final_price - total_amount
|
||||||
|
|
||||||
|
if final_price < Decimal('0'):
|
||||||
|
final_price = Decimal('0')
|
||||||
|
|
||||||
|
return final_price.quantize(Decimal('0.01'))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.product.name} x{self.quantity}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.pk is None:
|
||||||
|
# Check if order already has a processed payment
|
||||||
|
if (self.order.payment and
|
||||||
|
self.order.payment.payment_method and
|
||||||
|
self.order.payment.payment_method != Payment.PAYMENT.SHOP):
|
||||||
|
raise ValueError("Cannot modify items from order with processed payment method.")
|
||||||
|
|
||||||
|
# Validate stock availability
|
||||||
|
if self.product.stock < self.quantity:
|
||||||
|
raise ValueError(f"Insufficient stock for product {self.product.name}. Available: {self.product.stock}")
|
||||||
|
|
||||||
|
# Reduce stock
|
||||||
|
self.product.stock -= self.quantity
|
||||||
|
self.product.save(update_fields=["stock"])
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
reason_choice = models.CharField(max_length=40, choices=Reason.choices)
|
||||||
|
|
||||||
|
reason_text = models.TextField(blank=True)
|
||||||
|
|
||||||
|
verified = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
#VRACENÍ ZÁSILKY, LOGIKA (DISABLED FOR NOW)
|
||||||
|
# def save(self, *args, **kwargs):
|
||||||
|
# # Automaticky aktualizovat stav objednávky na "vráceno"
|
||||||
|
# if self.pk is None:
|
||||||
|
# self.order.status = Order.Status.REFUNDING
|
||||||
|
# self.order.save(update_fields=["status", "updated_at"])
|
||||||
|
|
||||||
|
# shipping_method = self.order.carrier.shipping_method
|
||||||
|
|
||||||
|
# if shipping_method == Carrier.SHIPPING.ZASILKOVNA:
|
||||||
|
|
||||||
|
# carrier = self.order.carrier;
|
||||||
|
|
||||||
|
# # poslední odeslána/vytvořená zásilka
|
||||||
|
# # Iniciovat vrácení přes Zásilkovnu
|
||||||
|
# carrier.zasilkovna.latest('created_at').returning_packet()
|
||||||
|
# carrier.save()
|
||||||
|
|
||||||
|
# else:
|
||||||
|
# # Logika pro jiné způsoby dopravy
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# 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.OrderStatus.REFUNDING:
|
||||||
|
self.order.status = Order.OrderStatus.REFUNDING
|
||||||
|
self.order.save(update_fields=["status", "updated_at"])
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def refund_completed(self):
|
||||||
|
# Aktualizovat stav objednávky na "vráceno"
|
||||||
|
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.OrderStatus.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.
|
||||||
|
|
||||||
|
Šablona refund/customer_in_package_returning_form.html očekává:
|
||||||
|
- order: objekt objednávky
|
||||||
|
- items: seznam položek (dict) s klíči product_name, sku, quantity, variant, options, reason
|
||||||
|
- return_reason: textový důvod vrácení (kombinace reason_text / reason_choice)
|
||||||
|
|
||||||
|
Návratová hodnota: bytes (PDF obsah). Uložení necháváme na volající logice.
|
||||||
|
"""
|
||||||
|
order = self.order
|
||||||
|
|
||||||
|
# Připravíme položky pro šablonu (důvody per položku zatím None – lze rozšířit)
|
||||||
|
prepared_items: list[dict] = []
|
||||||
|
for item in order.items.select_related('product'):
|
||||||
|
prepared_items.append({
|
||||||
|
"product_name": getattr(item.product, "name", "Item"),
|
||||||
|
"name": getattr(item.product, "name", "Item"), # fallbacky pro různé názvy v šabloně
|
||||||
|
"sku": getattr(item.product, "code", None),
|
||||||
|
"quantity": item.quantity,
|
||||||
|
"variant": None, # lze doplnit pokud existují varianty
|
||||||
|
"options": None, # lze doplnit pokud existují volby
|
||||||
|
"reason": None, # per-item reason (zatím nepodporováno)
|
||||||
|
})
|
||||||
|
|
||||||
|
return_reason = self.reason_text or self.get_reason_choice_display()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"order": order,
|
||||||
|
"items": prepared_items,
|
||||||
|
"return_reason": return_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_string = render_to_string("refund/customer_in_package_returning_form.html", context)
|
||||||
|
|
||||||
|
# Import WeasyPrint lazily to avoid startup failures when system
|
||||||
|
# libraries (Pango/GObject) are not present on Windows.
|
||||||
|
if HTML is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker."
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_bytes = HTML(string=html_string).write_pdf()
|
||||||
|
return pdf_bytes
|
||||||
|
|
||||||
|
|
||||||
|
class Invoice(models.Model):
|
||||||
|
invoice_number = models.CharField(max_length=50, unique=True)
|
||||||
|
|
||||||
|
issued_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
due_date = models.DateTimeField()
|
||||||
|
|
||||||
|
pdf_file = models.FileField(upload_to='invoices/')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Invoice {self.invoice_number} for Order {self.order.id}"
|
||||||
|
|
||||||
|
def generate_invoice_pdf(self):
|
||||||
|
order = Order.objects.get(invoice=self)
|
||||||
|
# Render HTML
|
||||||
|
html_string = render_to_string("invoice/Order.html", {"invoice": self, "order": order})
|
||||||
|
|
||||||
|
# Import WeasyPrint lazily to avoid startup failures when system
|
||||||
|
# libraries (Pango/GObject) are not present on Windows.
|
||||||
|
if HTML is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker."
|
||||||
|
)
|
||||||
|
|
||||||
|
pdf_bytes = HTML(string=html_string).write_pdf()
|
||||||
|
|
||||||
|
# Save directly to FileField
|
||||||
|
self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes))
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Review(models.Model):
|
||||||
|
product = models.ForeignKey(Product, related_name="reviews", on_delete=models.CASCADE)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
|
||||||
|
)
|
||||||
|
|
||||||
|
rating = models.PositiveIntegerField(
|
||||||
|
validators=[MinValueValidator(1), MaxValueValidator(5)]
|
||||||
|
)
|
||||||
|
comment = models.TextField(blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('product', 'user') # Prevent multiple reviews per user per product
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['product', 'rating']),
|
||||||
|
models.Index(fields=['created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Validate that user hasn't already reviewed this product"""
|
||||||
|
if self.pk is None: # Only for new reviews
|
||||||
|
if Review.objects.filter(product=self.product, user=self.user).exists():
|
||||||
|
raise ValidationError("User has already reviewed this product.")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Review for {self.product.name} by {self.user.username}"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ SHOPPING CART ------------------
|
||||||
|
|
||||||
|
class Cart(models.Model):
|
||||||
|
"""Shopping cart for both authenticated and anonymous users"""
|
||||||
|
user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="cart"
|
||||||
|
)
|
||||||
|
session_key = models.CharField(
|
||||||
|
max_length=40,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Session key for anonymous users"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Cart"
|
||||||
|
verbose_name_plural = "Carts"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.user:
|
||||||
|
return f"Cart for {self.user.email}"
|
||||||
|
return f"Anonymous cart ({self.session_key})"
|
||||||
|
|
||||||
|
def get_total(self):
|
||||||
|
"""Calculate total price of all items in cart including VAT"""
|
||||||
|
total = Decimal('0.0')
|
||||||
|
for item in self.items.all():
|
||||||
|
total += item.get_subtotal()
|
||||||
|
return total
|
||||||
|
|
||||||
|
def get_items_count(self):
|
||||||
|
"""Get total number of items in cart"""
|
||||||
|
return sum(item.quantity for item in self.items.all())
|
||||||
|
|
||||||
|
|
||||||
|
class CartItem(models.Model):
|
||||||
|
"""Individual items in a shopping cart"""
|
||||||
|
cart = models.ForeignKey(Cart, related_name='items', on_delete=models.CASCADE)
|
||||||
|
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||||
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
added_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Cart Item"
|
||||||
|
verbose_name_plural = "Cart Items"
|
||||||
|
unique_together = ('cart', 'product') # Prevent duplicate products in same cart
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.quantity}x {self.product.name} in cart"
|
||||||
|
|
||||||
|
def get_subtotal(self):
|
||||||
|
"""Calculate subtotal for this cart item including VAT"""
|
||||||
|
return self.product.get_price_with_vat() * self.quantity
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Validate that product has enough stock"""
|
||||||
|
if self.product.stock < self.quantity:
|
||||||
|
raise ValidationError(f"Not enough stock for {self.product.name}. Available: {self.product.stock}")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
self.clean()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ WISHLIST ------------------
|
||||||
|
|
||||||
|
class Wishlist(models.Model):
|
||||||
|
"""User's wishlist for saving favorite products"""
|
||||||
|
user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="wishlist"
|
||||||
|
)
|
||||||
|
products = models.ManyToManyField(
|
||||||
|
Product,
|
||||||
|
blank=True,
|
||||||
|
related_name="wishlisted_by",
|
||||||
|
help_text="Products saved by the user"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Wishlist"
|
||||||
|
verbose_name_plural = "Wishlists"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Wishlist for {self.user.email}"
|
||||||
|
|
||||||
|
def add_product(self, product):
|
||||||
|
"""Add a product to wishlist"""
|
||||||
|
self.products.add(product)
|
||||||
|
|
||||||
|
def remove_product(self, product):
|
||||||
|
"""Remove a product from wishlist"""
|
||||||
|
self.products.remove(product)
|
||||||
|
|
||||||
|
def has_product(self, product):
|
||||||
|
"""Check if product is in wishlist"""
|
||||||
|
return self.products.filter(pk=product.pk).exists()
|
||||||
|
|
||||||
|
def get_products_count(self):
|
||||||
|
"""Get count of products in wishlist"""
|
||||||
|
return self.products.count()
|
||||||
561
backend/commerce/serializers.py
Normal file
561
backend/commerce/serializers.py
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from thirdparty.stripe.client import StripeClient
|
||||||
|
|
||||||
|
from .models import Refund, Order, Invoice, Review
|
||||||
|
|
||||||
|
|
||||||
|
class RefundCreatePublicSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField()
|
||||||
|
invoice_number = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
order_id = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
# Optional reason fields
|
||||||
|
reason_choice = serializers.ChoiceField(
|
||||||
|
choices=Refund.Reason.choices, required=False
|
||||||
|
)
|
||||||
|
reason_text = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
email = attrs.get("email")
|
||||||
|
invoice_number = (attrs.get("invoice_number") or "").strip()
|
||||||
|
order_id = attrs.get("order_id")
|
||||||
|
|
||||||
|
if not invoice_number and not order_id:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Provide either invoice_number or order_id."
|
||||||
|
)
|
||||||
|
|
||||||
|
order = None
|
||||||
|
if invoice_number:
|
||||||
|
try:
|
||||||
|
invoice = Invoice.objects.get(invoice_number=invoice_number)
|
||||||
|
order = invoice.order
|
||||||
|
except Invoice.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"invoice_number": "Invoice not found."})
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"invoice_number": "Order for invoice not found."})
|
||||||
|
|
||||||
|
if order_id and order is None:
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(id=order_id)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"order_id": "Order not found."})
|
||||||
|
|
||||||
|
# Verify email matches order's email or user's email
|
||||||
|
if not order:
|
||||||
|
raise serializers.ValidationError("Order could not be resolved.")
|
||||||
|
|
||||||
|
order_email = (order.email or "").strip().lower()
|
||||||
|
user_email = (getattr(order.user, "email", "") or "").strip().lower()
|
||||||
|
provided = email.strip().lower()
|
||||||
|
if provided not in {order_email, user_email}:
|
||||||
|
raise serializers.ValidationError({"email": "Email does not match the order."})
|
||||||
|
|
||||||
|
attrs["order"] = order
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
order = validated_data["order"]
|
||||||
|
reason_choice = validated_data.get("reason_choice") or Refund.Reason.OTHER
|
||||||
|
reason_text = validated_data.get("reason_text", "")
|
||||||
|
|
||||||
|
refund = Refund.objects.create(
|
||||||
|
order=order,
|
||||||
|
reason_choice=reason_choice,
|
||||||
|
reason_text=reason_text,
|
||||||
|
)
|
||||||
|
return refund
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from drf_spectacular.utils import extend_schema_field
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
Category,
|
||||||
|
Product,
|
||||||
|
ProductImage,
|
||||||
|
DiscountCode,
|
||||||
|
Refund,
|
||||||
|
Order,
|
||||||
|
OrderItem,
|
||||||
|
Carrier,
|
||||||
|
Payment,
|
||||||
|
Cart,
|
||||||
|
CartItem,
|
||||||
|
Wishlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
from thirdparty.stripe.models import StripeModel
|
||||||
|
|
||||||
|
from thirdparty.zasilkovna.serializers import ZasilkovnaPacketSerializer
|
||||||
|
from thirdparty.zasilkovna.models import ZasilkovnaPacket
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# ----------------- CREATING ORDER SERIALIZER -----------------
|
||||||
|
|
||||||
|
#correct
|
||||||
|
# -- CARRIER --
|
||||||
|
class OrderCarrierSerializer(serializers.ModelSerializer):
|
||||||
|
# vstup: jen ID adresy z widgetu (write-only)
|
||||||
|
packeta_address_id = serializers.IntegerField(required=False, write_only=True)
|
||||||
|
|
||||||
|
# výstup: serializovaný packet
|
||||||
|
zasilkovna = ZasilkovnaPacketSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Carrier
|
||||||
|
fields = ["shipping_method", "state", "zasilkovna", "shipping_price", "packeta_address_id"]
|
||||||
|
read_only_fields = ["state", "shipping_price", "zasilkovna"]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
packeta_address_id = validated_data.pop("packeta_address_id", None)
|
||||||
|
|
||||||
|
carrier = Carrier.objects.create(**validated_data)
|
||||||
|
|
||||||
|
if packeta_address_id is not None:
|
||||||
|
# vytvoříme nový packet s danou addressId
|
||||||
|
packet = ZasilkovnaPacket.objects.create(addressId=packeta_address_id)
|
||||||
|
carrier.zasilkovna.add(packet)
|
||||||
|
|
||||||
|
return carrier
|
||||||
|
|
||||||
|
|
||||||
|
#correct
|
||||||
|
# -- ORDER ITEMs --
|
||||||
|
class OrderItemCreateSerializer(serializers.Serializer):
|
||||||
|
product_id = serializers.IntegerField()
|
||||||
|
quantity = serializers.IntegerField(min_value=1, default=1)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
product_id = attrs.get("product_id")
|
||||||
|
try:
|
||||||
|
product = Product.objects.get(pk=product_id)
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"product_id": "Product not found."})
|
||||||
|
|
||||||
|
attrs["product"] = product
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- PAYMENT --
|
||||||
|
class PaymentSerializer(serializers.ModelSerializer):
|
||||||
|
stripe_session_id = serializers.CharField(source='stripe.stripe_session_id', read_only=True)
|
||||||
|
stripe_payment_intent = serializers.CharField(source='stripe.stripe_payment_intent', read_only=True)
|
||||||
|
stripe_session_url = serializers.URLField(source='stripe.stripe_session_url', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Payment
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"payment_method",
|
||||||
|
"stripe",
|
||||||
|
"stripe_session_id",
|
||||||
|
"stripe_payment_intent",
|
||||||
|
"stripe_session_url",
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
"id",
|
||||||
|
"stripe",
|
||||||
|
"stripe_session_id",
|
||||||
|
"stripe_payment_intent",
|
||||||
|
"stripe_session_url",
|
||||||
|
]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
order = self.context.get("order") # musíš ho předat při inicializaci serializeru
|
||||||
|
carrier = self.context.get("carrier")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
order=order,
|
||||||
|
carrier=carrier,
|
||||||
|
**validated_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# pokud je Stripe, vytvoříme checkout session
|
||||||
|
if payment.payment_method == Payment.PAYMENT.SHOP and carrier.shipping_method != Carrier.SHIPPING.STORE:
|
||||||
|
raise serializers.ValidationError("Platba v obchodě je možná pouze pro osobní odběr.")
|
||||||
|
|
||||||
|
elif payment.payment_method == Payment.PAYMENT.CASH_ON_DELIVERY and carrier.shipping_method == Carrier.SHIPPING.STORE:
|
||||||
|
raise ValidationError("Dobírka není možná pro osobní odběr.")
|
||||||
|
|
||||||
|
|
||||||
|
if payment.payment_method == Payment.PAYMENT.STRIPE:
|
||||||
|
session = StripeClient.create_checkout_session(order)
|
||||||
|
|
||||||
|
stripe_instance = StripeModel.objects.create(
|
||||||
|
stripe_session_id=session.id,
|
||||||
|
stripe_payment_intent=session.payment_intent,
|
||||||
|
stripe_session_url=session.url,
|
||||||
|
status=StripeModel.STATUS_CHOICES.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
payment.stripe = stripe_instance
|
||||||
|
payment.save(update_fields=["stripe"])
|
||||||
|
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
# -- ORDER CREATE SERIALIZER --
|
||||||
|
class OrderCreateSerializer(serializers.Serializer):
|
||||||
|
# Customer/billing data (optional when authenticated)
|
||||||
|
first_name = serializers.CharField(required=False)
|
||||||
|
last_name = serializers.CharField(required=False)
|
||||||
|
email = serializers.EmailField(required=False)
|
||||||
|
phone = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
address = serializers.CharField(required=False)
|
||||||
|
city = serializers.CharField(required=False)
|
||||||
|
postal_code = serializers.CharField(required=False)
|
||||||
|
country = serializers.CharField(required=False, default="Czech Republic")
|
||||||
|
note = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
# Nested structures
|
||||||
|
#produkty
|
||||||
|
items = OrderItemCreateSerializer(many=True)
|
||||||
|
|
||||||
|
#doprava/vyzvednutí + zasilkovna input (serializer)
|
||||||
|
carrier = OrderCarrierSerializer()
|
||||||
|
|
||||||
|
payment = PaymentSerializer()
|
||||||
|
|
||||||
|
#slevové kódy
|
||||||
|
discount_codes = serializers.ListField(
|
||||||
|
child=serializers.CharField(), required=False, allow_empty=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
request = self.context.get("request")
|
||||||
|
|
||||||
|
#kontrola jestli je uzivatel valid/prihlasen
|
||||||
|
is_auth = bool(getattr(getattr(request, "user", None), "is_authenticated", False))
|
||||||
|
|
||||||
|
# pokud není, tak se musí vyplnit povinné údaje
|
||||||
|
required_fields = [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"postal_code",
|
||||||
|
]
|
||||||
|
|
||||||
|
if not is_auth:
|
||||||
|
missing_fields = []
|
||||||
|
|
||||||
|
# přidame fieldy, které nejsou vyplněné
|
||||||
|
for field in required_fields:
|
||||||
|
if attrs.get(field) not in required_fields:
|
||||||
|
missing_fields.append(field)
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
raise serializers.ValidationError({"billing": f"Missing fields: {', '.join(missing_fields)}"})
|
||||||
|
|
||||||
|
# pokud chybí itemy:
|
||||||
|
if not attrs.get("items"):
|
||||||
|
raise serializers.ValidationError({"items": "At least one item is required."})
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
items_data = validated_data.pop("items", [])
|
||||||
|
carrier_data = validated_data.pop("carrier")
|
||||||
|
payment_data = validated_data.pop("payment")
|
||||||
|
codes = validated_data.pop("discount_codes", [])
|
||||||
|
|
||||||
|
request = self.context.get("request")
|
||||||
|
user = getattr(request, "user", None)
|
||||||
|
is_auth = bool(getattr(user, "is_authenticated", False))
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# Create Order (user data imported on save if user is set)
|
||||||
|
order = Order(
|
||||||
|
user=user if is_auth else None,
|
||||||
|
first_name=validated_data.get("first_name", ""),
|
||||||
|
last_name=validated_data.get("last_name", ""),
|
||||||
|
email=validated_data.get("email", ""),
|
||||||
|
phone=validated_data.get("phone", ""),
|
||||||
|
address=validated_data.get("address", ""),
|
||||||
|
city=validated_data.get("city", ""),
|
||||||
|
postal_code=validated_data.get("postal_code", ""),
|
||||||
|
country=validated_data.get("country", "Czech Republic"),
|
||||||
|
note=validated_data.get("note", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order.save se postara o to jestli má doplnit data z usera
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
# Vytvoření Carrier skrz serializer
|
||||||
|
carrier = OrderCarrierSerializer(data=carrier_data)
|
||||||
|
carrier.is_valid(raise_exception=True)
|
||||||
|
carrier = carrier.save()
|
||||||
|
order.carrier = carrier
|
||||||
|
order.save(update_fields=["carrier", "updated_at"]) # will recalc total later
|
||||||
|
|
||||||
|
|
||||||
|
# Vytvořit Order Items individualně, aby se spustila kontrola položek na skladu
|
||||||
|
for item in items_data:
|
||||||
|
product = item["product"] # OrderItemCreateSerializer.validate
|
||||||
|
quantity = int(item.get("quantity", 1))
|
||||||
|
OrderItem.objects.create(order=order, product=product, quantity=quantity)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Slevové kódy --
|
||||||
|
if codes:
|
||||||
|
discounts = list(DiscountCode.objects.filter(code__in=codes))
|
||||||
|
if discounts:
|
||||||
|
order.discount.add(*discounts)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- Payment --
|
||||||
|
payment_serializer = PaymentSerializer(
|
||||||
|
data=payment_data,
|
||||||
|
context={"order": order, "carrier": carrier}
|
||||||
|
)
|
||||||
|
payment_serializer.is_valid(raise_exception=True)
|
||||||
|
payment = payment_serializer.save()
|
||||||
|
|
||||||
|
# přiřadíme k orderu
|
||||||
|
order.payment = payment
|
||||||
|
order.save(update_fields=["payment"])
|
||||||
|
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- ADMIN/READ MODELS -----------------
|
||||||
|
|
||||||
|
class CategorySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Category
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"url",
|
||||||
|
"parent",
|
||||||
|
"description",
|
||||||
|
"image",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductImageSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ProductImage
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"product",
|
||||||
|
"image",
|
||||||
|
"alt_text",
|
||||||
|
"is_main",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
class DiscountCodeSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = DiscountCode
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = ["used_count"]
|
||||||
|
|
||||||
|
|
||||||
|
class RefundSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Refund
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = ["id", "verified", "created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- READ SERIALIZERS USED BY VIEWS -----------------
|
||||||
|
|
||||||
|
class ZasilkovnaPacketReadSerializer(ZasilkovnaPacketSerializer):
|
||||||
|
class Meta(ZasilkovnaPacketSerializer.Meta):
|
||||||
|
fields = getattr(ZasilkovnaPacketSerializer.Meta, "fields", None)
|
||||||
|
|
||||||
|
|
||||||
|
class CarrierReadSerializer(serializers.ModelSerializer):
|
||||||
|
zasilkovna = ZasilkovnaPacketReadSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Carrier
|
||||||
|
fields = ["shipping_method", "state", "zasilkovna", "shipping_price"]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class ProductMiniSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = ["id", "name", "price"]
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItemReadSerializer(serializers.ModelSerializer):
|
||||||
|
product = ProductMiniSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrderItem
|
||||||
|
fields = ["id", "product", "quantity"]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentReadSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Payment
|
||||||
|
fields = ["payment_method"]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class OrderMiniSerializer(serializers.ModelSerializer):
|
||||||
|
status = serializers.ChoiceField(
|
||||||
|
choices=Order.OrderStatus.choices,
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Order
|
||||||
|
fields = ["id", "status", "total_price", "created_at"]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class OrderReadSerializer(serializers.ModelSerializer):
|
||||||
|
status = serializers.ChoiceField(
|
||||||
|
choices=Order.OrderStatus.choices,
|
||||||
|
read_only=True
|
||||||
|
)
|
||||||
|
items = OrderItemReadSerializer(many=True, read_only=True)
|
||||||
|
carrier = CarrierReadSerializer(read_only=True)
|
||||||
|
payment = PaymentReadSerializer(read_only=True)
|
||||||
|
discount_codes = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Order
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
"total_price",
|
||||||
|
"currency",
|
||||||
|
"user",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"address",
|
||||||
|
"city",
|
||||||
|
"postal_code",
|
||||||
|
"country",
|
||||||
|
"note",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"items",
|
||||||
|
"carrier",
|
||||||
|
"payment",
|
||||||
|
"discount_codes",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def get_discount_codes(self, obj: Order):
|
||||||
|
return list(obj.discount.values_list("code", flat=True))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSerializerPublic(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Review
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = ['user', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- CART SERIALIZERS -----------------
|
||||||
|
|
||||||
|
class CartItemSerializer(serializers.ModelSerializer):
|
||||||
|
product_name = serializers.CharField(source='product.name', read_only=True)
|
||||||
|
product_price = serializers.DecimalField(source='product.price', max_digits=10, decimal_places=2, read_only=True)
|
||||||
|
subtotal = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CartItem
|
||||||
|
fields = ['id', 'product', 'product_name', 'product_price', 'quantity', 'subtotal', 'added_at']
|
||||||
|
read_only_fields = ['id', 'added_at']
|
||||||
|
|
||||||
|
def get_subtotal(self, obj):
|
||||||
|
return obj.get_subtotal()
|
||||||
|
|
||||||
|
def validate_quantity(self, value):
|
||||||
|
if value < 1:
|
||||||
|
raise serializers.ValidationError("Quantity must be at least 1")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CartItemCreateSerializer(serializers.Serializer):
|
||||||
|
product_id = serializers.IntegerField()
|
||||||
|
quantity = serializers.IntegerField(min_value=1, default=1)
|
||||||
|
|
||||||
|
def validate_product_id(self, value):
|
||||||
|
try:
|
||||||
|
Product.objects.get(pk=value, is_active=True)
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Product not found or inactive.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CartSerializer(serializers.ModelSerializer):
|
||||||
|
items = CartItemSerializer(many=True, read_only=True)
|
||||||
|
total = serializers.SerializerMethodField()
|
||||||
|
items_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Cart
|
||||||
|
fields = ['id', 'user', 'items', 'total', 'items_count', 'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_total(self, obj):
|
||||||
|
return obj.get_total()
|
||||||
|
|
||||||
|
def get_items_count(self, obj):
|
||||||
|
return obj.get_items_count()
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------- WISHLIST SERIALIZERS -----------------
|
||||||
|
|
||||||
|
class ProductMiniForWishlistSerializer(serializers.ModelSerializer):
|
||||||
|
"""Minimal product info for wishlist display"""
|
||||||
|
class Meta:
|
||||||
|
model = Product
|
||||||
|
fields = ['id', 'name', 'price', 'is_active', 'stock']
|
||||||
|
|
||||||
|
|
||||||
|
class WishlistSerializer(serializers.ModelSerializer):
|
||||||
|
products = ProductMiniForWishlistSerializer(many=True, read_only=True)
|
||||||
|
products_count = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Wishlist
|
||||||
|
fields = ['id', 'user', 'products', 'products_count', 'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_products_count(self, obj):
|
||||||
|
return obj.get_products_count()
|
||||||
|
|
||||||
|
|
||||||
|
class WishlistProductActionSerializer(serializers.Serializer):
|
||||||
|
"""For adding/removing products from wishlist"""
|
||||||
|
product_id = serializers.IntegerField()
|
||||||
|
|
||||||
|
def validate_product_id(self, value):
|
||||||
|
try:
|
||||||
|
Product.objects.get(pk=value, is_active=True)
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Product not found or inactive.")
|
||||||
|
return value
|
||||||
168
backend/commerce/tasks.py
Normal file
168
backend/commerce/tasks.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
from account.tasks import send_email_with_context
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
# -- CLEANUP TASKS --
|
||||||
|
|
||||||
|
# Delete expired/cancelled orders (older than 24 hours)
|
||||||
|
@shared_task
|
||||||
|
def delete_expired_orders():
|
||||||
|
Order = apps.get_model('commerce', 'Order')
|
||||||
|
|
||||||
|
expired_orders = Order.objects.filter(status=Order.OrderStatus.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24))
|
||||||
|
count = expired_orders.count()
|
||||||
|
expired_orders.delete()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# -- NOTIFICATIONS CARRIER --
|
||||||
|
|
||||||
|
# Zásilkovna
|
||||||
|
@shared_task
|
||||||
|
def notify_zasilkovna_sended(order = None, user = None, **kwargs):
|
||||||
|
if not order or not user:
|
||||||
|
raise ValueError("Order and User must be provided for notification.")
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
print("Additional kwargs received in notify_order_sended:", kwargs)
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Your order has been shipped",
|
||||||
|
template_path="email/order_sended.html",
|
||||||
|
context={
|
||||||
|
"user": user,
|
||||||
|
"order": order,
|
||||||
|
})
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Shop
|
||||||
|
@shared_task
|
||||||
|
def notify_Ready_to_pickup(order = None, user = None, **kwargs):
|
||||||
|
if not order or not user:
|
||||||
|
raise ValueError("Order and User must be provided for notification.")
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
print("Additional kwargs received in notify_order_sended:", kwargs)
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Your order is ready for pickup",
|
||||||
|
template_path="email/order_ready_pickup.html",
|
||||||
|
context={
|
||||||
|
"user": user,
|
||||||
|
"order": order,
|
||||||
|
})
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -- NOTIFICATIONS ORDER --
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def notify_order_successfuly_created(order = None, user = None, **kwargs):
|
||||||
|
if not order or not user:
|
||||||
|
raise ValueError("Order and User must be provided for notification.")
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
print("Additional kwargs received in notify_order_successfuly_created:", kwargs)
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Your order has been successfully created",
|
||||||
|
template_path="email/order_created.html",
|
||||||
|
context={
|
||||||
|
"user": user,
|
||||||
|
"order": order,
|
||||||
|
})
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def notify_order_payed(order = None, user = None, **kwargs):
|
||||||
|
if not order or not user:
|
||||||
|
raise ValueError("Order and User must be provided for notification.")
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
print("Additional kwargs received in notify_order_paid:", kwargs)
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Your order has been paid",
|
||||||
|
template_path="email/order_paid.html",
|
||||||
|
context={
|
||||||
|
"user": user,
|
||||||
|
"order": order,
|
||||||
|
})
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def notify_about_missing_payment(order = None, user = None, **kwargs):
|
||||||
|
if not order or not user:
|
||||||
|
raise ValueError("Order and User must be provided for notification.")
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
print("Additional kwargs received in notify_about_missing_payment:", kwargs)
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Payment missing for your order",
|
||||||
|
template_path="email/order_missing_payment.html",
|
||||||
|
context={
|
||||||
|
"user": user,
|
||||||
|
"order": order,
|
||||||
|
})
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# -- NOTIFICATIONS REFUND --
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def notify_refund_items_arrived(order = None, user = None, **kwargs):
|
||||||
|
if not order or not user:
|
||||||
|
raise ValueError("Order and User must be provided for notification.")
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
print("Additional kwargs received in notify_refund_items_arrived:", kwargs)
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Your refund items have arrived",
|
||||||
|
template_path="email/order_refund_items_arrived.html",
|
||||||
|
context={
|
||||||
|
"user": user,
|
||||||
|
"order": order,
|
||||||
|
})
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Refund accepted, retuning money
|
||||||
|
@shared_task
|
||||||
|
def notify_refund_accepted(order = None, user = None, **kwargs):
|
||||||
|
if not order or not user:
|
||||||
|
raise ValueError("Order and User must be provided for notification.")
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
print("Additional kwargs received in notify_refund_accepted:", kwargs)
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Your refund has been accepted",
|
||||||
|
template_path="email/order_refund_accepted.html",
|
||||||
|
context={
|
||||||
|
"user": user,
|
||||||
|
"order": order,
|
||||||
|
})
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Return/Refund Slip – Order {{ order.number|default:order.code|default:order.id }}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
:root { --fg:#111; --muted:#666; --border:#ddd; --accent:#0f172a; --bg:#fff; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg); font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Arial; }
|
||||||
|
.sheet { max-width: 800px; margin: 24px auto; padding: 24px; border:1px solid var(--border); border-radius: 8px; }
|
||||||
|
header { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; }
|
||||||
|
.title { font-size:20px; font-weight:700; letter-spacing:.2px; }
|
||||||
|
.sub { color:var(--muted); font-size:12px; }
|
||||||
|
.meta { display:grid; grid-template-columns: 1fr 1fr; gap: 8px 16px; padding:12px; border:1px solid var(--border); border-radius:8px; margin-bottom:16px; }
|
||||||
|
.meta div { display:flex; gap:8px; }
|
||||||
|
.label { width:140px; color:var(--muted); }
|
||||||
|
table { width:100%; border-collapse: collapse; margin: 12px 0 4px; }
|
||||||
|
th, td { border:1px solid var(--border); padding:8px; vertical-align: top; }
|
||||||
|
th { text-align:left; background:#f8fafc; font-weight:600; }
|
||||||
|
.muted { color:var(--muted); }
|
||||||
|
.section { margin-top:18px; }
|
||||||
|
.section h3 { margin:0 0 8px; font-size:14px; text-transform:uppercase; letter-spacing:.4px; color:var(--accent); }
|
||||||
|
.textarea { border:1px solid var(--border); border-radius:8px; min-height:90px; padding:10px; white-space:pre-wrap; }
|
||||||
|
.grid-2 { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||||
|
.line { height:1px; background:var(--border); margin: 8px 0; }
|
||||||
|
.sign { height:48px; border-bottom:1px solid var(--border); }
|
||||||
|
.print-tip { color:var(--muted); font-size:12px; margin-top:8px; }
|
||||||
|
.print-btn { display:inline-block; padding:8px 12px; border:1px solid var(--border); border-radius:6px; background:#f8fafc; cursor:pointer; font-size:13px; }
|
||||||
|
@media print {
|
||||||
|
.sheet { border:none; border-radius:0; margin:0; padding:0; }
|
||||||
|
.print-btn, .print-tip { display:none !important; }
|
||||||
|
body { font-size:12px; }
|
||||||
|
th, td { padding:6px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sheet">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<div class="title">Return / Refund Slip</div>
|
||||||
|
<div class="sub">Include this page inside the package for the shopkeeper to examine the return.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="print-btn" onclick="window.print()">Print</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div><div class="label">Order number</div><div><strong>{{ order.number|default:order.code|default:order.id }}</strong></div></div>
|
||||||
|
<div><div class="label">Order date</div><div>{% if order.created_at %}{{ order.created_at|date:"Y-m-d H:i" }}{% else %}{% now "Y-m-d" %}{% endif %}</div></div>
|
||||||
|
<div><div class="label">Customer name</div><div>{{ order.customer_name|default:order.user.get_full_name|default:order.user.username|default:"" }}</div></div>
|
||||||
|
<div><div class="label">Customer email</div><div>{{ order.customer_email|default:order.user.email|default:"" }}</div></div>
|
||||||
|
<div><div class="label">Phone</div><div>{{ order.customer_phone|default:"" }}</div></div>
|
||||||
|
<div><div class="label">Return created</div><div>{% now "Y-m-d H:i" %}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Returned items</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:44%">Item</th>
|
||||||
|
<th style="width:16%">SKU</th>
|
||||||
|
<th style="width:10%">Qty</th>
|
||||||
|
<th style="width:30%">Reason (per item)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div><strong>{{ it.product_name|default:it.product.title|default:it.name|default:"Item" }}</strong></div>
|
||||||
|
{% if it.variant or it.options %}
|
||||||
|
<div class="muted" style="font-size:12px;">
|
||||||
|
{% if it.variant %}Variant: {{ it.variant }}{% endif %}
|
||||||
|
{% if it.options %}{% if it.variant %} • {% endif %}Options: {{ it.options }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ it.sku|default:"—" }}</td>
|
||||||
|
<td>{{ it.quantity|default:1 }}</td>
|
||||||
|
<td>{% if it.reason %}{{ it.reason }}{% else %} {% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="muted">No items listed.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="print-tip">Tip: If the reason differs per item, write it in the last column above.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Return reason (customer)</h3>
|
||||||
|
<div class="textarea">
|
||||||
|
{% if return_reason %}{{ return_reason }}{% else %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Shopkeeper inspection</h3>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div>
|
||||||
|
<div class="row">
|
||||||
|
<strong>Package condition:</strong>
|
||||||
|
[ ] Intact
|
||||||
|
[ ] Opened
|
||||||
|
[ ] Damaged
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:6px;">
|
||||||
|
<strong>Items condition:</strong>
|
||||||
|
[ ] New
|
||||||
|
[ ] Light wear
|
||||||
|
[ ] Used
|
||||||
|
[ ] Damaged
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="row">
|
||||||
|
<strong>Resolution:</strong>
|
||||||
|
[ ] Accept refund
|
||||||
|
[ ] Deny
|
||||||
|
[ ] Exchange
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:6px;">
|
||||||
|
<strong>Restocking fee:</strong> ________ %
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section" style="margin-top:12px;">
|
||||||
|
<div class="row"><strong>Notes:</strong></div>
|
||||||
|
<div class="textarea" style="min-height:70px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2" style="margin-top:16px;">
|
||||||
|
<div>
|
||||||
|
<div class="muted" style="font-size:12px;">Processed by (name/signature)</div>
|
||||||
|
<div class="sign"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="muted" style="font-size:12px;">Date</div>
|
||||||
|
<div class="sign"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="line"></div>
|
||||||
|
<div class="muted" style="font-size:12px; margin-top:8px;">
|
||||||
|
Attach this slip inside the package. Keep a copy for your records.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
backend/commerce/templates/invoice/Order.html
Normal file
41
backend/commerce/templates/invoice/Order.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="cs">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Faktura {{ invoice.invoice_number }}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; font-size: 14px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||||
|
th, td { border: 1px solid #000; padding: 8px; text-align: left; }
|
||||||
|
th { background-color: #eee; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Faktura {{ invoice.invoice_number }}</h1>
|
||||||
|
<p>Datum vystavení: {{ invoice.issue_date.strftime("%Y-%m-%d") }}</p>
|
||||||
|
<p>Zákazník: {{ invoice.order.customer_name }}</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Produkt</th>
|
||||||
|
<th>Množství</th>
|
||||||
|
<th>Cena</th>
|
||||||
|
<th>Celkem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in invoice.order.items.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.product.name }}</td>
|
||||||
|
<td>{{ item.quantity }}</td>
|
||||||
|
<td>{{ item.price }}</td>
|
||||||
|
<td>{{ item.price * item.quantity }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p><strong>Celkem k úhradě: {{ invoice.total_amount }} Kč</strong></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
backend/commerce/tests.py
Normal file
3
backend/commerce/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
36
backend/commerce/urls.py
Normal file
36
backend/commerce/urls.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import (
|
||||||
|
OrderViewSet,
|
||||||
|
ProductViewSet,
|
||||||
|
CategoryViewSet,
|
||||||
|
ProductImageViewSet,
|
||||||
|
DiscountCodeViewSet,
|
||||||
|
RefundViewSet,
|
||||||
|
RefundPublicView,
|
||||||
|
ReviewPostPublicView,
|
||||||
|
ReviewPublicViewSet,
|
||||||
|
CartViewSet,
|
||||||
|
WishlistViewSet,
|
||||||
|
AdminWishlistViewSet,
|
||||||
|
AnalyticsView,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'orders', OrderViewSet)
|
||||||
|
router.register(r'products', ProductViewSet, basename='product')
|
||||||
|
router.register(r'categories', CategoryViewSet, basename='category')
|
||||||
|
router.register(r'product-images', ProductImageViewSet, basename='product-image')
|
||||||
|
router.register(r'discount-codes', DiscountCodeViewSet, basename='discount-code')
|
||||||
|
router.register(r'refunds', RefundViewSet, basename='refund')
|
||||||
|
router.register(r'reviews', ReviewPublicViewSet, basename='review')
|
||||||
|
router.register(r'cart', CartViewSet, basename='cart')
|
||||||
|
router.register(r'wishlist', WishlistViewSet, basename='wishlist')
|
||||||
|
router.register(r'admin/wishlists', AdminWishlistViewSet, basename='admin-wishlist')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'),
|
||||||
|
path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'),
|
||||||
|
path('analytics/', AnalyticsView.as_view(), name='analytics'),
|
||||||
|
]
|
||||||
1097
backend/commerce/views.py
Normal file
1097
backend/commerce/views.py
Normal file
File diff suppressed because it is too large
Load Diff
0
backend/configuration/__init__.py
Normal file
0
backend/configuration/__init__.py
Normal file
59
backend/configuration/admin.py
Normal file
59
backend/configuration/admin.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import SiteConfiguration, VATRate
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
|
|
||||||
|
@admin.register(SiteConfiguration)
|
||||||
|
class SiteConfigurationAdmin(admin.ModelAdmin):
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'logo', 'favicon', 'currency')
|
||||||
|
}),
|
||||||
|
('Contact Information', {
|
||||||
|
'fields': ('contact_email', 'contact_phone', 'contact_address', 'opening_hours')
|
||||||
|
}),
|
||||||
|
('Social Media', {
|
||||||
|
'fields': ('facebook_url', 'instagram_url', 'youtube_url', 'tiktok_url', 'whatsapp_number')
|
||||||
|
}),
|
||||||
|
('Shipping Settings', {
|
||||||
|
'fields': ('zasilkovna_shipping_price', 'deutschepost_shipping_price', 'free_shipping_over')
|
||||||
|
}),
|
||||||
|
('API Credentials', {
|
||||||
|
'fields': ('zasilkovna_api_key', 'zasilkovna_api_password', 'deutschepost_client_id', 'deutschepost_client_secret', 'deutschepost_customer_ekp'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Coupon Settings', {
|
||||||
|
'fields': ('multiplying_coupons', 'addition_of_coupons_amount')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.register(VATRate)
|
||||||
|
class VATRateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'rate', 'is_default', 'is_active', 'description')
|
||||||
|
list_filter = ('is_active', 'is_default')
|
||||||
|
search_fields = ('name', 'description')
|
||||||
|
list_editable = ('is_active',)
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
# Make is_default read-only in change form to prevent conflicts
|
||||||
|
if obj: # editing an existing object
|
||||||
|
return ('is_default',) if not obj.is_default else ()
|
||||||
|
return ()
|
||||||
|
|
||||||
|
actions = ['make_default']
|
||||||
|
|
||||||
|
def make_default(self, request, queryset):
|
||||||
|
if queryset.count() != 1:
|
||||||
|
self.message_user(request, "Select exactly one VAT rate to make default.", level='ERROR')
|
||||||
|
return
|
||||||
|
|
||||||
|
vat_rate = queryset.first()
|
||||||
|
# Clear existing defaults
|
||||||
|
VATRate.objects.filter(is_default=True).update(is_default=False)
|
||||||
|
# Set new default
|
||||||
|
vat_rate.is_default = True
|
||||||
|
vat_rate.save()
|
||||||
|
|
||||||
|
self.message_user(request, f"'{vat_rate.name}' is now the default VAT rate.")
|
||||||
|
|
||||||
|
make_default.short_description = "Make selected VAT rate the default"
|
||||||
22
backend/configuration/apps.py
Normal file
22
backend/configuration/apps.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
from django.db.models.signals import post_migrate
|
||||||
|
|
||||||
|
def create_site_config(sender, **kwargs):
|
||||||
|
"""
|
||||||
|
Ensure the SiteConfiguration singleton exists after migrations.
|
||||||
|
"""
|
||||||
|
from .models import SiteConfiguration
|
||||||
|
try:
|
||||||
|
SiteConfiguration.get_solo()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ConfigurationConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'configuration'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
# Spustí create_site_config po dokončení migrací
|
||||||
|
post_migrate.connect(create_site_config, sender=self)
|
||||||
|
|
||||||
|
|
||||||
43
backend/configuration/migrations/0001_initial.py
Normal file
43
backend/configuration/migrations/0001_initial.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-18 15:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SiteConfiguration',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(default='Shop name', max_length=100, unique=True)),
|
||||||
|
('logo', models.ImageField(blank=True, null=True, upload_to='shop_logos/')),
|
||||||
|
('favicon', models.ImageField(blank=True, null=True, upload_to='shop_favicons/')),
|
||||||
|
('contact_email', models.EmailField(blank=True, max_length=254, null=True)),
|
||||||
|
('contact_phone', models.CharField(blank=True, max_length=20, null=True)),
|
||||||
|
('contact_address', models.TextField(blank=True, null=True)),
|
||||||
|
('opening_hours', models.JSONField(blank=True, null=True)),
|
||||||
|
('facebook_url', models.URLField(blank=True, null=True)),
|
||||||
|
('instagram_url', models.URLField(blank=True, null=True)),
|
||||||
|
('youtube_url', models.URLField(blank=True, null=True)),
|
||||||
|
('tiktok_url', models.URLField(blank=True, null=True)),
|
||||||
|
('whatsapp_number', models.CharField(blank=True, max_length=20, null=True)),
|
||||||
|
('zasilkovna_shipping_price', models.DecimalField(decimal_places=2, default=50, max_digits=10)),
|
||||||
|
('zasilkovna_api_key', models.CharField(blank=True, help_text='API klíč pro přístup k Zásilkovna API (zatím není využito)', max_length=255, null=True)),
|
||||||
|
('zasilkovna_api_password', models.CharField(blank=True, help_text='API heslo pro přístup k Zásilkovna API (zatím není využito)', max_length=255, null=True)),
|
||||||
|
('free_shipping_over', models.DecimalField(decimal_places=2, default=2000, max_digits=10)),
|
||||||
|
('multiplying_coupons', models.BooleanField(default=True, help_text='Násobení 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')),
|
||||||
|
('currency', models.CharField(choices=[('CZK', 'cz#Czech Koruna'), ('EUR', 'cz#Euro')], default='CZK', max_length=10)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Shop Configuration',
|
||||||
|
'verbose_name_plural': 'Shop Configuration',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-17 01:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('configuration', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='siteconfiguration',
|
||||||
|
name='deutschepost_api_url',
|
||||||
|
field=models.URLField(default='https://gw.sandbox.deutschepost.com', help_text='Deutsche Post API URL (sandbox/production)', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='siteconfiguration',
|
||||||
|
name='deutschepost_client_id',
|
||||||
|
field=models.CharField(blank=True, help_text='Deutsche Post OAuth Client ID', max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='siteconfiguration',
|
||||||
|
name='deutschepost_client_secret',
|
||||||
|
field=models.CharField(blank=True, help_text='Deutsche Post OAuth Client Secret', max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='siteconfiguration',
|
||||||
|
name='deutschepost_customer_ekp',
|
||||||
|
field=models.CharField(blank=True, help_text='Deutsche Post Customer EKP number', max_length=20, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='siteconfiguration',
|
||||||
|
name='deutschepost_shipping_price',
|
||||||
|
field=models.DecimalField(decimal_places=2, default=150, help_text='Default Deutsche Post shipping price', max_digits=10),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/configuration/migrations/__init__.py
Normal file
0
backend/configuration/migrations/__init__.py
Normal file
125
backend/configuration/models.py
Normal file
125
backend/configuration/models.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import decimal
|
||||||
|
from django.db import models
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
|
|
||||||
|
class SiteConfiguration(models.Model):
|
||||||
|
name = models.CharField(max_length=100, default="Shop name", unique=True)
|
||||||
|
|
||||||
|
logo = models.ImageField(upload_to='shop_logos/', blank=True, null=True)
|
||||||
|
favicon = models.ImageField(upload_to='shop_favicons/', blank=True, null=True)
|
||||||
|
|
||||||
|
contact_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||||
|
contact_phone = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
contact_address = models.TextField(blank=True, null=True)
|
||||||
|
opening_hours = models.JSONField(blank=True, null=True) #FIXME: vytvoř JSON tvar pro otvírací dobu, přes validátory
|
||||||
|
|
||||||
|
#Social
|
||||||
|
facebook_url = models.URLField(max_length=200, blank=True, null=True)
|
||||||
|
instagram_url = models.URLField(max_length=200, blank=True, null=True)
|
||||||
|
youtube_url = models.URLField(max_length=200, blank=True, null=True)
|
||||||
|
tiktok_url = models.URLField(max_length=200, blank=True, null=True)
|
||||||
|
whatsapp_number = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
|
||||||
|
#zasilkovna settings
|
||||||
|
zasilkovna_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("50.00"))
|
||||||
|
#FIXME: není implementováno ↓↓↓
|
||||||
|
zasilkovna_api_key = models.CharField(max_length=255, blank=True, null=True, help_text="API klíč pro přístup k Zásilkovna API (zatím není využito)")
|
||||||
|
#FIXME: není implementováno ↓↓↓
|
||||||
|
zasilkovna_api_password = models.CharField(max_length=255, blank=True, null=True, help_text="API heslo pro přístup k Zásilkovna API (zatím není využito)")
|
||||||
|
#FIXME: není implementováno ↓↓↓
|
||||||
|
free_shipping_over = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("2000.00"))
|
||||||
|
|
||||||
|
# Deutsche Post settings
|
||||||
|
deutschepost_api_url = models.URLField(max_length=255, default="https://gw.sandbox.deutschepost.com", help_text="Deutsche Post API URL (sandbox/production)")
|
||||||
|
deutschepost_client_id = models.CharField(max_length=255, blank=True, null=True, help_text="Deutsche Post OAuth Client ID")
|
||||||
|
deutschepost_client_secret = models.CharField(max_length=255, blank=True, null=True, help_text="Deutsche Post OAuth Client Secret")
|
||||||
|
deutschepost_customer_ekp = models.CharField(max_length=20, blank=True, null=True, help_text="Deutsche Post Customer EKP number")
|
||||||
|
deutschepost_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("6.00"), help_text="Default Deutsche Post shipping price in EUR")
|
||||||
|
|
||||||
|
#coupon settings
|
||||||
|
multiplying_coupons = models.BooleanField(default=True, help_text="Násobení 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):
|
||||||
|
CZK = "CZK", "Czech Koruna"
|
||||||
|
EUR = "EUR", "Euro"
|
||||||
|
currency = models.CharField(max_length=10, default=CURRENCY.CZK, choices=CURRENCY.choices)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Shop Configuration"
|
||||||
|
verbose_name_plural = "Shop Configuration"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# zajištění singletonu
|
||||||
|
self.pk = 1
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_solo(cls):
|
||||||
|
obj, _ = cls.objects.get_or_create(pk=1)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class VATRate(models.Model):
|
||||||
|
"""Business owner configurable VAT rates"""
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text="E.g. 'German Standard', 'German Reduced', 'Czech Standard'"
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Optional description: 'Standard rate for most products', 'Books and food', etc."
|
||||||
|
)
|
||||||
|
|
||||||
|
rate = models.DecimalField(
|
||||||
|
max_digits=5,
|
||||||
|
decimal_places=4, # Allows rates like 19.5000%
|
||||||
|
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('100'))],
|
||||||
|
help_text="VAT rate as percentage (e.g. 19.00 for 19%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
is_default = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Default rate for new products"
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this VAT rate is active and available for use"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "VAT Rate"
|
||||||
|
verbose_name_plural = "VAT Rates"
|
||||||
|
ordering = ['-is_default', 'rate', 'name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.rate}%)"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Ensure only one default rate
|
||||||
|
if self.is_default:
|
||||||
|
VATRate.objects.filter(is_default=True).update(is_default=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# If no default exists, make first active one default
|
||||||
|
if not VATRate.objects.filter(is_default=True).exists():
|
||||||
|
first_active = VATRate.objects.filter(is_active=True).first()
|
||||||
|
if first_active:
|
||||||
|
first_active.is_default = True
|
||||||
|
first_active.save()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rate_decimal(self):
|
||||||
|
"""Returns rate as decimal for calculations (19.00% -> 0.19)"""
|
||||||
|
return self.rate / Decimal('100')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default(cls):
|
||||||
|
"""Get the default VAT rate"""
|
||||||
|
return cls.objects.filter(is_default=True, is_active=True).first()
|
||||||
100
backend/configuration/serializers.py
Normal file
100
backend/configuration/serializers.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import SiteConfiguration, VATRate
|
||||||
|
|
||||||
|
|
||||||
|
class SiteConfigurationSerializer(serializers.ModelSerializer):
|
||||||
|
"""Site configuration serializer - sensitive fields only for admins"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SiteConfiguration
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"logo",
|
||||||
|
"favicon",
|
||||||
|
"contact_email",
|
||||||
|
"contact_phone",
|
||||||
|
"contact_address",
|
||||||
|
"opening_hours",
|
||||||
|
"facebook_url",
|
||||||
|
"instagram_url",
|
||||||
|
"youtube_url",
|
||||||
|
"tiktok_url",
|
||||||
|
"whatsapp_number",
|
||||||
|
"zasilkovna_shipping_price",
|
||||||
|
"zasilkovna_api_key",
|
||||||
|
"zasilkovna_api_password",
|
||||||
|
"deutschepost_api_url",
|
||||||
|
"deutschepost_client_id",
|
||||||
|
"deutschepost_client_secret",
|
||||||
|
"deutschepost_customer_ekp",
|
||||||
|
"deutschepost_shipping_price",
|
||||||
|
"free_shipping_over",
|
||||||
|
"multiplying_coupons",
|
||||||
|
"addition_of_coupons_amount",
|
||||||
|
"currency",
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
"""Hide sensitive fields from non-admin users"""
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
request = self.context.get('request')
|
||||||
|
|
||||||
|
# If user is not admin, remove sensitive fields
|
||||||
|
if not (request and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'):
|
||||||
|
sensitive_fields = [
|
||||||
|
'zasilkovna_api_key',
|
||||||
|
'zasilkovna_api_password',
|
||||||
|
'deutschepost_client_id',
|
||||||
|
'deutschepost_client_secret',
|
||||||
|
'deutschepost_customer_ekp',
|
||||||
|
'deutschepost_api_url',
|
||||||
|
]
|
||||||
|
for field in sensitive_fields:
|
||||||
|
data.pop(field, None)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class VATRateSerializer(serializers.ModelSerializer):
|
||||||
|
"""VAT Rate serializer - admin fields only visible to admins"""
|
||||||
|
|
||||||
|
rate_decimal = serializers.ReadOnlyField(help_text="VAT rate as decimal (e.g., 0.19 for 19%)")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = VATRate
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'rate',
|
||||||
|
'rate_decimal',
|
||||||
|
'description',
|
||||||
|
'is_active',
|
||||||
|
'is_default',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at', 'rate_decimal']
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
"""Hide admin-only fields from non-admin users"""
|
||||||
|
data = super().to_representation(instance)
|
||||||
|
request = self.context.get('request')
|
||||||
|
|
||||||
|
# If user is not admin, remove admin-only fields
|
||||||
|
if not (request and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'):
|
||||||
|
admin_fields = ['is_active', 'is_default']
|
||||||
|
for field in admin_fields:
|
||||||
|
data.pop(field, None)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Custom validation for VAT rates"""
|
||||||
|
# Ensure rate is reasonable (0-100%)
|
||||||
|
rate = attrs.get('rate')
|
||||||
|
if rate is not None and (rate < 0 or rate > 100):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{'rate': 'VAT rate must be between 0% and 100%'}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
3
backend/configuration/tests.py
Normal file
3
backend/configuration/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
10
backend/configuration/urls.py
Normal file
10
backend/configuration/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import (
|
||||||
|
SiteConfigurationViewSet,
|
||||||
|
VATRateViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"shop-configuration", SiteConfigurationViewSet, basename="shop-config")
|
||||||
|
router.register(r"vat-rates", VATRateViewSet, basename="vat-rates")
|
||||||
|
urlpatterns = router.urls
|
||||||
74
backend/configuration/views.py
Normal file
74
backend/configuration/views.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from rest_framework import viewsets, mixins
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
from account.permissions import AdminWriteOnlyOrReadOnly
|
||||||
|
from .models import SiteConfiguration, VATRate
|
||||||
|
from .serializers import (
|
||||||
|
SiteConfigurationSerializer,
|
||||||
|
VATRateSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SingletonQuerysetMixin:
|
||||||
|
def get_queryset(self):
|
||||||
|
return SiteConfiguration.objects.filter(pk=1)
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return SiteConfiguration.get_solo()
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=["configuration"], summary="List site configuration"),
|
||||||
|
retrieve=extend_schema(tags=["configuration"], summary="Retrieve site configuration"),
|
||||||
|
create=extend_schema(tags=["configuration"], summary="Create site configuration (admin only)"),
|
||||||
|
partial_update=extend_schema(tags=["configuration"], summary="Update site configuration (admin only)"),
|
||||||
|
update=extend_schema(tags=["configuration"], summary="Replace site configuration (admin only)"),
|
||||||
|
destroy=extend_schema(tags=["configuration"], summary="Delete site configuration (admin only)"),
|
||||||
|
)
|
||||||
|
class SiteConfigurationViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
|
||||||
|
permission_classes = [AdminWriteOnlyOrReadOnly]
|
||||||
|
serializer_class = SiteConfigurationSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=["configuration"], summary="List VAT rates"),
|
||||||
|
retrieve=extend_schema(tags=["configuration"], summary="Retrieve VAT rate"),
|
||||||
|
create=extend_schema(tags=["configuration"], summary="Create VAT rate (admin only)"),
|
||||||
|
partial_update=extend_schema(tags=["configuration"], summary="Update VAT rate (admin only)"),
|
||||||
|
update=extend_schema(tags=["configuration"], summary="Replace VAT rate (admin only)"),
|
||||||
|
destroy=extend_schema(tags=["configuration"], summary="Delete VAT rate (admin only)"),
|
||||||
|
)
|
||||||
|
class VATRateViewSet(viewsets.ModelViewSet):
|
||||||
|
"""VAT rate management - read for all, write for admins only"""
|
||||||
|
permission_classes = [AdminWriteOnlyOrReadOnly]
|
||||||
|
serializer_class = VATRateSerializer
|
||||||
|
queryset = VATRate.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Admins see all rates, others see only active ones"""
|
||||||
|
if self.request.user.is_authenticated and getattr(self.request.user, 'role', None) == 'admin':
|
||||||
|
return VATRate.objects.all()
|
||||||
|
return VATRate.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["configuration"],
|
||||||
|
summary="Make VAT rate the default (admin only)",
|
||||||
|
description="Set this VAT rate as the default for new products"
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def make_default(self, request, pk=None):
|
||||||
|
"""Make this VAT rate the default"""
|
||||||
|
vat_rate = self.get_object()
|
||||||
|
|
||||||
|
# Clear existing defaults
|
||||||
|
VATRate.objects.filter(is_default=True).update(is_default=False)
|
||||||
|
|
||||||
|
# Set new default
|
||||||
|
vat_rate.is_default = True
|
||||||
|
vat_rate.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': f'"{vat_rate.name}" is now the default VAT rate',
|
||||||
|
'default_rate_id': vat_rate.id
|
||||||
|
})
|
||||||
1
backend/globalstaticfiles/filler
Normal file
1
backend/globalstaticfiles/filler
Normal file
@@ -0,0 +1 @@
|
|||||||
|
filler
|
||||||
@@ -45,6 +45,12 @@ daphne
|
|||||||
|
|
||||||
gunicorn
|
gunicorn
|
||||||
|
|
||||||
|
# -- THIRD PARTY APIS --
|
||||||
|
# Deutsche Post International Shipping API client (local package)
|
||||||
|
httpx>=0.23.0,<0.29.0 # Required by Deutsche Post client
|
||||||
|
attrs>=22.2.0 # Required by Deutsche Post client
|
||||||
|
python-dateutil>=2.8.0 # Required by Deutsche Post client
|
||||||
|
|
||||||
# -- REST API --
|
# -- REST API --
|
||||||
djangorestframework #REST Framework
|
djangorestframework #REST Framework
|
||||||
|
|
||||||
@@ -71,6 +77,8 @@ django-cors-headers #csfr
|
|||||||
celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.)
|
celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.)
|
||||||
django-celery-beat #slouží k plánování úkolů pro Celery
|
django-celery-beat #slouží k plánování úkolů pro Celery
|
||||||
|
|
||||||
|
django-silk
|
||||||
|
django-silk[formatting]
|
||||||
|
|
||||||
# -- EDITING photos, gifs, videos --
|
# -- EDITING photos, gifs, videos --
|
||||||
|
|
||||||
@@ -78,7 +86,7 @@ django-celery-beat #slouží k plánování úkolů pro Celery
|
|||||||
#opencv-python #moviepy use this better instead of pillow
|
#opencv-python #moviepy use this better instead of pillow
|
||||||
#moviepy
|
#moviepy
|
||||||
|
|
||||||
#yt-dlp
|
yt-dlp
|
||||||
|
|
||||||
weasyprint #tvoření PDFek z html dokumentu + css styly
|
weasyprint #tvoření PDFek z html dokumentu + css styly
|
||||||
|
|
||||||
@@ -86,6 +94,14 @@ weasyprint #tvoření PDFek z html dokumentu + css styly
|
|||||||
|
|
||||||
faker #generates fake data for testing purposes
|
faker #generates fake data for testing purposes
|
||||||
|
|
||||||
## -- api --
|
zeep #SOAP tool
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## -- API --
|
||||||
|
|
||||||
|
#generates api (if schema exists (ONLY USE OPENAPI NO SWAGGER))
|
||||||
|
openapi-python-client
|
||||||
|
|
||||||
stripe
|
stripe
|
||||||
gopay
|
gopay
|
||||||
0
backend/social/chat/__init__.py
Normal file
0
backend/social/chat/__init__.py
Normal file
3
backend/social/chat/admin.py
Normal file
3
backend/social/chat/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
8
backend/social/chat/apps.py
Normal file
8
backend/social/chat/apps.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'social.chat'
|
||||||
|
|
||||||
|
label = "chat"
|
||||||
151
backend/social/chat/consumers.py
Normal file
151
backend/social/chat/consumers.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# chat/consumers.py
|
||||||
|
import json
|
||||||
|
|
||||||
|
from account.models import UserProfile
|
||||||
|
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from asgiref.sync import sync_to_async, async_to_sync
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConsumer(AsyncWebsocketConsumer):
|
||||||
|
# -- CONNECT --
|
||||||
|
async def connect(self):
|
||||||
|
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
|
||||||
|
self.chat_name = f"chat_{self.chat_id}"
|
||||||
|
|
||||||
|
user = self.scope["user"]
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
await self.close(code=4401) # unauthorized
|
||||||
|
return
|
||||||
|
|
||||||
|
#join chat group
|
||||||
|
async_to_sync(self.channel_layer.group_add)(
|
||||||
|
self.chat_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
# -- DISCONNECT --
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
async_to_sync(self.channel_layer.group_discard)(
|
||||||
|
self.chat_name
|
||||||
|
)
|
||||||
|
|
||||||
|
self.disconnect()
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- RECIVE --
|
||||||
|
async def receive(self, data):
|
||||||
|
if data["type"] == "new_chat_message":
|
||||||
|
|
||||||
|
message = data["message"]
|
||||||
|
|
||||||
|
# Send message to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "chat.message", "message": message}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "new_reply_chat_message":
|
||||||
|
message = data["message"]
|
||||||
|
reply_to_id = data["reply_to_id"]
|
||||||
|
|
||||||
|
# Send message to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "reply.chat.message", "message": message, "reply_to_id": reply_to_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "edit_chat_message":
|
||||||
|
message = data["message"]
|
||||||
|
|
||||||
|
# Send message to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "edit.message", "message": message}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "delete_chat_message":
|
||||||
|
message_id = data["message_id"]
|
||||||
|
|
||||||
|
# Send message to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "delete.message", "message_id": message_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "typing":
|
||||||
|
is_typing = data["is_typing"]
|
||||||
|
|
||||||
|
# Send typing status to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "typing.status", "user": self.scope["user"].username, "is_typing": is_typing}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "stop_typing":
|
||||||
|
# Send stop typing status to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "stop.typing", "user": self.scope["user"].username}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "reaction":
|
||||||
|
message_id = data["message_id"]
|
||||||
|
emoji = data["emoji"]
|
||||||
|
|
||||||
|
# Send reaction to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "message.reaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "unreaction":
|
||||||
|
message_id = data["message_id"]
|
||||||
|
emoji = data["emoji"]
|
||||||
|
|
||||||
|
# Send unreaction to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "message.unreaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.close(reason="Unsupported message type")
|
||||||
|
|
||||||
|
|
||||||
|
# -- CUSTOM METHODS --
|
||||||
|
|
||||||
|
def send_message_to_chat_group(self, event):
|
||||||
|
message = event["message"]
|
||||||
|
create_new_message()
|
||||||
|
self.send(text_data=json.dumps({"message": message}))
|
||||||
|
|
||||||
|
def edit_message_in_chat_group(self, event):
|
||||||
|
message = event["message"]
|
||||||
|
self.send(text_data=json.dumps({"message": message}))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- MESSAGES --
|
||||||
|
@database_sync_to_async
|
||||||
|
def create_new_message():
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def create_new_reply_message():
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def edit_message():
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def delete_message():
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -- REACTIONS --
|
||||||
|
@database_sync_to_async
|
||||||
|
def react_to_message():
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def unreact_to_message():
|
||||||
|
return None
|
||||||
77
backend/social/chat/migrations/0001_initial.py
Normal file
77
backend/social/chat/migrations/0001_initial.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-17 01:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Chat',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('members', models.ManyToManyField(blank=True, related_name='chats', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('moderators', models.ManyToManyField(blank=True, related_name='moderated_chats', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_chats', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Message',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('content', models.TextField(blank=True)),
|
||||||
|
('is_edited', models.BooleanField(default=False)),
|
||||||
|
('edited_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat')),
|
||||||
|
('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replies', to='chat.message')),
|
||||||
|
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageFile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file', models.FileField(upload_to='chat_uploads/%Y/%m/%d/')),
|
||||||
|
('media_type', models.CharField(choices=[('IMAGE', 'Image'), ('VIDEO', 'Video'), ('FILE', 'File')], default='FILE', max_length=20)),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='chat.message')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('old_content', models.TextField()),
|
||||||
|
('archived_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edit_history', to='chat.message')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-archived_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageReaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('emoji', models.CharField(max_length=10)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='chat.message')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('message', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/social/chat/migrations/__init__.py
Normal file
0
backend/social/chat/migrations/__init__.py
Normal file
187
backend/social/chat/models.py
Normal file
187
backend/social/chat/models.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
class Chat(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='owned_chats'
|
||||||
|
)
|
||||||
|
|
||||||
|
members = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
related_name='chats',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
moderators = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
related_name='moderated_chats',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = self._state.adding
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# LOGIC: Ensure owner is always a member and moderator
|
||||||
|
if is_new and self.owner:
|
||||||
|
self.members.add(self.owner)
|
||||||
|
self.moderators.add(self.owner)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Chat {self.id}"
|
||||||
|
|
||||||
|
|
||||||
|
class Message(models.Model):
|
||||||
|
chat = models.ForeignKey(Chat, related_name='messages', on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
sender = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='sent_messages'
|
||||||
|
)
|
||||||
|
|
||||||
|
#odpověď na jinou zprávu
|
||||||
|
reply_to = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='replies'
|
||||||
|
)
|
||||||
|
|
||||||
|
content = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# --- TRACKING EDIT STATUS ---
|
||||||
|
# We add these so the frontend doesn't need to check MessageHistory table
|
||||||
|
is_edited = models.BooleanField(default=False)
|
||||||
|
edited_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
# VALIDATION: Ensure sender is actually in the chat
|
||||||
|
# Note: We check self.id to avoid running this on creation if logic depends on M2M
|
||||||
|
# But generally, a sender must be a member.
|
||||||
|
if self.chat and self.sender:
|
||||||
|
if not self.chat.members.filter(id=self.sender.id).exists():
|
||||||
|
raise ValidationError("Sender is not a member of this chat.")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Optional: Run validation before saving
|
||||||
|
# self.full_clean()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# --- HELPER METHODS FOR WEBSOCKETS / VIEWS ---
|
||||||
|
|
||||||
|
def edit_content(self, new_text):
|
||||||
|
"""
|
||||||
|
Handles the complex logic of editing:
|
||||||
|
1. Checks if text actually changed.
|
||||||
|
2. Saves old text to History.
|
||||||
|
3. Updates current text and timestamps.
|
||||||
|
"""
|
||||||
|
if self.content == new_text:
|
||||||
|
return False # No change happened
|
||||||
|
|
||||||
|
# 1. Save History
|
||||||
|
MessageHistory.objects.create(
|
||||||
|
message=self,
|
||||||
|
old_content=self.content
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Update Self
|
||||||
|
self.content = new_text
|
||||||
|
self.is_edited = True
|
||||||
|
self.edited_at = timezone.now()
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def toggle_reaction(self, user, emoji):
|
||||||
|
"""
|
||||||
|
Handles Add/Remove/Switch logic.
|
||||||
|
Returns a tuple: (action, reaction_object)
|
||||||
|
action can be: 'added', 'removed', 'switched'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
reaction = MessageReaction.objects.get(message=self, user=user)
|
||||||
|
|
||||||
|
if reaction.emoji == emoji:
|
||||||
|
# Same emoji -> Remove it (Toggle)
|
||||||
|
reaction.delete()
|
||||||
|
return 'removed', None
|
||||||
|
else:
|
||||||
|
# Different emoji -> Switch it
|
||||||
|
reaction.emoji = emoji
|
||||||
|
reaction.save()
|
||||||
|
return 'switched', reaction
|
||||||
|
|
||||||
|
except MessageReaction.DoesNotExist:
|
||||||
|
# New reaction -> Create it
|
||||||
|
reaction = MessageReaction.objects.create(
|
||||||
|
message=self,
|
||||||
|
user=user,
|
||||||
|
emoji=emoji
|
||||||
|
)
|
||||||
|
return 'added', reaction
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Message {self.id} from {self.sender}"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageHistory(models.Model):
|
||||||
|
message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='edit_history'
|
||||||
|
)
|
||||||
|
old_content = models.TextField()
|
||||||
|
archived_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-archived_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReaction(models.Model):
|
||||||
|
message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='reactions'
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
|
emoji = models.CharField(max_length=10)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('message', 'user')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} reacted {self.emoji}"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFile(models.Model):
|
||||||
|
message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='media_files'
|
||||||
|
)
|
||||||
|
file = models.FileField(upload_to='chat_uploads/%Y/%m/%d/')
|
||||||
|
|
||||||
|
media_type = models.CharField(max_length=20, choices=[
|
||||||
|
('IMAGE', 'Image'),
|
||||||
|
('VIDEO', 'Video'),
|
||||||
|
('FILE', 'File')
|
||||||
|
], default='FILE')
|
||||||
|
|
||||||
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Media {self.id} for Message {self.message.id}"
|
||||||
8
backend/social/chat/routing.py
Normal file
8
backend/social/chat/routing.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# chat/routing.py
|
||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r"ws/chat/(?P<chat_id>\w+)/$", consumers.ChatConsumer.as_asgi()),
|
||||||
|
]
|
||||||
3
backend/social/chat/tests.py
Normal file
3
backend/social/chat/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
25
backend/social/chat/views.py
Normal file
25
backend/social/chat/views.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_chats(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_chat(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def invite_user_to_chat(request, chat_id: int, user_ids: list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_chat(request, chat_id: int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def leave_chat(request, chat_id: int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def edit_chat(request, chat_object):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_chat_messages(request, chat_id: int, limit: int = 50, offset: int = 0):
|
||||||
|
return None
|
||||||
0
backend/social/pages/__init__.py
Normal file
0
backend/social/pages/__init__.py
Normal file
3
backend/social/pages/admin.py
Normal file
3
backend/social/pages/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/social/pages/apps.py
Normal file
6
backend/social/pages/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PagesConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'pages'
|
||||||
0
backend/social/pages/migrations/__init__.py
Normal file
0
backend/social/pages/migrations/__init__.py
Normal file
3
backend/social/pages/models.py
Normal file
3
backend/social/pages/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
3
backend/social/pages/tests.py
Normal file
3
backend/social/pages/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
backend/social/pages/views.py
Normal file
3
backend/social/pages/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
backend/social/posts/__init__.py
Normal file
0
backend/social/posts/__init__.py
Normal file
3
backend/social/posts/admin.py
Normal file
3
backend/social/posts/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/social/posts/apps.py
Normal file
6
backend/social/posts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PostsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'posts'
|
||||||
0
backend/social/posts/migrations/__init__.py
Normal file
0
backend/social/posts/migrations/__init__.py
Normal file
3
backend/social/posts/models.py
Normal file
3
backend/social/posts/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
3
backend/social/posts/tests.py
Normal file
3
backend/social/posts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
backend/social/posts/views.py
Normal file
3
backend/social/posts/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
51
backend/templates/email/components/base.html
Normal file
51
backend/templates/email/components/base.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Email</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f2f2f2;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:20px 0;">
|
||||||
|
|
||||||
|
<!-- HLAVNÍ KONTEJNER -->
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff; border-collapse:collapse;">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0; margin:0;">
|
||||||
|
{% include 'email/components/header.html' %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; line-height:20px; color:#000000;">
|
||||||
|
|
||||||
|
{% if content_template %}
|
||||||
|
{% include content_template %}
|
||||||
|
{% else %}
|
||||||
|
<p>missing content_template !!!</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0; margin:0;">
|
||||||
|
{% include 'email/components/footer.html' %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
backend/templates/email/components/footer.html
Normal file
8
backend/templates/email/components/footer.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#eeeeee;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:15px; font-size:12px; color:#666666; font-family:Arial, Helvetica, sans-serif;">
|
||||||
|
Tento e-mail byl odeslán automaticky.<br>
|
||||||
|
© {{ site_name|default:"E-shop" }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
9
backend/templates/email/components/header.html
Normal file
9
backend/templates/email/components/header.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#222;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:20px; color:#ffffff; font-family:Arial, Helvetica, sans-serif;">
|
||||||
|
<h2 style="margin:0; font-size:22px;">
|
||||||
|
{{ site_name|default:"E-shop" }}
|
||||||
|
</h2>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
78
backend/templates/email/email_verification.html
Normal file
78
backend/templates/email/email_verification.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<style>
|
||||||
|
.verification-container {
|
||||||
|
text-align: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.verification-title {
|
||||||
|
color: #333;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.verification-text {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 30px 0;
|
||||||
|
}
|
||||||
|
.verification-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
.verification-info {
|
||||||
|
color: #0c5460;
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.welcome-note {
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="verification-container">
|
||||||
|
<h1 class="verification-title">✉️ Ověření e-mailové adresy</h1>
|
||||||
|
|
||||||
|
<p class="verification-text">
|
||||||
|
Vítejte {{ user.first_name|default:user.username }}!<br><br>
|
||||||
|
Děkujeme za registraci. Pro dokončení vytvoření účtu je nutné
|
||||||
|
ověřit vaši e-mailovou adresu kliknutím na tlačítko níže.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{{ action_url }}" class="verification-button">{{ cta_label }}</a>
|
||||||
|
|
||||||
|
<div class="welcome-note">
|
||||||
|
<strong>🎉 Těšíme se na vás!</strong><br>
|
||||||
|
Po ověření e-mailu budete moci využívat všechny funkce naší platformy
|
||||||
|
a začít nakupovat nebo prodávat.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verification-info">
|
||||||
|
<strong>ℹ️ Co dělat, když tlačítko nefunguje?</strong><br>
|
||||||
|
Zkopírujte a vložte následující odkaz do adresního řádku prohlížeče:
|
||||||
|
<br><br>
|
||||||
|
<span style="word-break: break-all; font-family: monospace; font-size: 12px;">{{ action_url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="verification-text" style="font-size: 14px; color: #999;">
|
||||||
|
Odkaz pro ověření je platný po omezenou dobu.
|
||||||
|
Pokud jste se neregistrovali na našich stránkách, ignorujte tento e-mail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
75
backend/templates/email/password_reset.html
Normal file
75
backend/templates/email/password_reset.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<style>
|
||||||
|
.reset-container {
|
||||||
|
text-align: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.reset-title {
|
||||||
|
color: #333;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.reset-text {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 30px 0;
|
||||||
|
}
|
||||||
|
.reset-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
.reset-warning {
|
||||||
|
color: #856404;
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.security-note {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="reset-container">
|
||||||
|
<h1 class="reset-title">🔐 Obnova hesla</h1>
|
||||||
|
|
||||||
|
<p class="reset-text">
|
||||||
|
Dobrý den {{ user.first_name|default:user.username }},<br><br>
|
||||||
|
Obdrželi jsme požadavek na obnovení hesla k vašemu účtu.
|
||||||
|
Klikněte na tlačítko níže pro vytvoření nového hesla.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{{ action_url }}" class="reset-button">{{ cta_label }}</a>
|
||||||
|
|
||||||
|
<div class="reset-warning">
|
||||||
|
<strong>⚠️ Bezpečnostní upozornění:</strong><br>
|
||||||
|
Pokud jste o obnovu hesla nepožádali, ignorujte tento e-mail.
|
||||||
|
Váše heslo zůstane nezměněno.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="reset-text">
|
||||||
|
Odkaz pro obnovu hesla je platný pouze po omezenou dobu.
|
||||||
|
Pokud odkaz nefunguje, zkopírujte a vložte následující adresu do prohlížeče:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="word-break: break-all; color: #007bff; font-size: 14px;">
|
||||||
|
{{ action_url }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="security-note">
|
||||||
|
Tento odkaz je určen pouze pro vás a nelze ho sdílet s ostatními.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
0
backend/thirdparty/deutschepost/__init__.py
vendored
Normal file
0
backend/thirdparty/deutschepost/__init__.py
vendored
Normal file
183
backend/thirdparty/deutschepost/admin.py
vendored
Normal file
183
backend/thirdparty/deutschepost/admin.py
vendored
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
|
from .models import DeutschePostOrder, DeutschePostBulkOrder
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeutschePostOrder)
|
||||||
|
class DeutschePostOrderAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'id', 'order_id', 'recipient_name', 'state', 'commerce_order_link',
|
||||||
|
'awb_number', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = ['state', 'product_type', 'service_level', 'destination_country', 'created_at']
|
||||||
|
search_fields = ['order_id', 'recipient_name', 'recipient_email', 'awb_number', 'barcode']
|
||||||
|
readonly_fields = [
|
||||||
|
'order_id', 'awb_number', 'barcode', 'tracking_url',
|
||||||
|
'metadata', 'last_error', 'created_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('state', 'commerce_order', 'order_id', 'customer_ekp')
|
||||||
|
}),
|
||||||
|
('Recipient Information', {
|
||||||
|
'fields': (
|
||||||
|
'recipient_name', 'recipient_phone', 'recipient_email',
|
||||||
|
'address_line1', 'address_line2', 'address_line3',
|
||||||
|
'city', 'address_state', 'postal_code', 'destination_country'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Shipment Details', {
|
||||||
|
'fields': (
|
||||||
|
'product_type', 'service_level', 'shipment_gross_weight',
|
||||||
|
'shipment_amount', 'shipment_currency',
|
||||||
|
'sender_tax_id', 'importer_tax_id', 'return_item_wanted',
|
||||||
|
'cust_ref'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Tracking Information', {
|
||||||
|
'fields': ('awb_number', 'barcode', 'tracking_url'),
|
||||||
|
'classes': ['collapse']
|
||||||
|
}),
|
||||||
|
('API Data', {
|
||||||
|
'fields': ('metadata', 'last_error', 'created_at'),
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = ['create_remote_order', 'finalize_remote_order', 'refresh_tracking']
|
||||||
|
|
||||||
|
def commerce_order_link(self, obj):
|
||||||
|
"""Link to related commerce order."""
|
||||||
|
if obj.commerce_order:
|
||||||
|
url = reverse('admin:commerce_order_change', args=[obj.commerce_order.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.commerce_order)
|
||||||
|
return '-'
|
||||||
|
commerce_order_link.short_description = 'Commerce Order'
|
||||||
|
|
||||||
|
def create_remote_order(self, request, queryset):
|
||||||
|
"""Admin action to create orders remotely."""
|
||||||
|
created_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for order in queryset:
|
||||||
|
try:
|
||||||
|
if not order.order_id:
|
||||||
|
order.create_remote_order()
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error creating order {order.id}: {str(e)}')
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
if created_count:
|
||||||
|
messages.success(request, f'{created_count} orders created remotely')
|
||||||
|
if error_count:
|
||||||
|
messages.warning(request, f'{error_count} orders could not be created')
|
||||||
|
|
||||||
|
create_remote_order.short_description = 'Create selected orders remotely'
|
||||||
|
|
||||||
|
def finalize_remote_order(self, request, queryset):
|
||||||
|
"""Admin action to finalize orders remotely."""
|
||||||
|
finalized_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for order in queryset:
|
||||||
|
try:
|
||||||
|
if order.order_id and order.state != DeutschePostOrder.STATE.FINALIZED:
|
||||||
|
order.finalize_remote_order()
|
||||||
|
finalized_count += 1
|
||||||
|
else:
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error finalizing order {order.id}: {str(e)}')
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
if finalized_count:
|
||||||
|
messages.success(request, f'{finalized_count} orders finalized')
|
||||||
|
if error_count:
|
||||||
|
messages.warning(request, f'{error_count} orders could not be finalized')
|
||||||
|
|
||||||
|
finalize_remote_order.short_description = 'Finalize selected orders'
|
||||||
|
|
||||||
|
def refresh_tracking(self, request, queryset):
|
||||||
|
"""Admin action to refresh tracking information."""
|
||||||
|
updated_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for order in queryset:
|
||||||
|
try:
|
||||||
|
if order.order_id:
|
||||||
|
order.refresh_tracking()
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error refreshing tracking for order {order.id}: {str(e)}')
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
if updated_count:
|
||||||
|
messages.success(request, f'{updated_count} orders tracking updated')
|
||||||
|
if error_count:
|
||||||
|
messages.warning(request, f'{error_count} orders could not be updated')
|
||||||
|
|
||||||
|
refresh_tracking.short_description = 'Refresh tracking for selected orders'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeutschePostBulkOrder)
|
||||||
|
class DeutschePostBulkOrderAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['id', 'bulk_order_id', 'status', 'orders_count', 'created_at']
|
||||||
|
list_filter = ['status', 'bulk_order_type', 'created_at']
|
||||||
|
search_fields = ['bulk_order_id', 'description']
|
||||||
|
readonly_fields = ['bulk_order_id', 'orders_count', 'metadata', 'last_error', 'created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('status', 'bulk_order_id', 'bulk_order_type', 'description')
|
||||||
|
}),
|
||||||
|
('Orders', {
|
||||||
|
'fields': ('deutschepost_orders', 'orders_count')
|
||||||
|
}),
|
||||||
|
('API Data', {
|
||||||
|
'fields': ('metadata', 'last_error', 'created_at'),
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_horizontal = ['deutschepost_orders']
|
||||||
|
actions = ['create_remote_bulk_order']
|
||||||
|
|
||||||
|
def orders_count(self, obj):
|
||||||
|
"""Count of orders in this bulk order."""
|
||||||
|
return obj.deutschepost_orders.count()
|
||||||
|
orders_count.short_description = 'Orders Count'
|
||||||
|
|
||||||
|
def create_remote_bulk_order(self, request, queryset):
|
||||||
|
"""Admin action to create bulk orders remotely."""
|
||||||
|
created_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for bulk_order in queryset:
|
||||||
|
try:
|
||||||
|
if not bulk_order.bulk_order_id:
|
||||||
|
bulk_order.create_remote_bulk_order()
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error creating bulk order {bulk_order.id}: {str(e)}')
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
if created_count:
|
||||||
|
messages.success(request, f'{created_count} bulk orders created remotely')
|
||||||
|
if error_count:
|
||||||
|
messages.warning(request, f'{error_count} bulk orders could not be created')
|
||||||
|
|
||||||
|
create_remote_bulk_order.short_description = 'Create selected bulk orders remotely'
|
||||||
6
backend/thirdparty/deutschepost/apps.py
vendored
Normal file
6
backend/thirdparty/deutschepost/apps.py
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DeutschepostConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'thirdparty.deutschepost'
|
||||||
23
backend/thirdparty/deutschepost/client/.gitignore
vendored
Normal file
23
backend/thirdparty/deutschepost/client/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
__pycache__/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# JetBrains
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
/coverage.xml
|
||||||
|
/.coverage
|
||||||
124
backend/thirdparty/deutschepost/client/README.md
vendored
Normal file
124
backend/thirdparty/deutschepost/client/README.md
vendored
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# deutsche-post-international-shipping-api-client
|
||||||
|
A client library for accessing Deutsche Post International Shipping API
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
First, create a client:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deutsche_post_international_shipping_api_client import Client
|
||||||
|
|
||||||
|
client = Client(base_url="https://api.example.com")
|
||||||
|
```
|
||||||
|
|
||||||
|
If the endpoints you're going to hit require authentication, use `AuthenticatedClient` instead:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deutsche_post_international_shipping_api_client import AuthenticatedClient
|
||||||
|
|
||||||
|
client = AuthenticatedClient(base_url="https://api.example.com", token="SuperSecretToken")
|
||||||
|
```
|
||||||
|
|
||||||
|
Now call your endpoint and use your models:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deutsche_post_international_shipping_api_client.models import MyDataModel
|
||||||
|
from deutsche_post_international_shipping_api_client.api.my_tag import get_my_data_model
|
||||||
|
from deutsche_post_international_shipping_api_client.types import Response
|
||||||
|
|
||||||
|
with client as client:
|
||||||
|
my_data: MyDataModel = get_my_data_model.sync(client=client)
|
||||||
|
# or if you need more info (e.g. status_code)
|
||||||
|
response: Response[MyDataModel] = get_my_data_model.sync_detailed(client=client)
|
||||||
|
```
|
||||||
|
|
||||||
|
Or do the same thing with an async version:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deutsche_post_international_shipping_api_client.models import MyDataModel
|
||||||
|
from deutsche_post_international_shipping_api_client.api.my_tag import get_my_data_model
|
||||||
|
from deutsche_post_international_shipping_api_client.types import Response
|
||||||
|
|
||||||
|
async with client as client:
|
||||||
|
my_data: MyDataModel = await get_my_data_model.asyncio(client=client)
|
||||||
|
response: Response[MyDataModel] = await get_my_data_model.asyncio_detailed(client=client)
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, when you're calling an HTTPS API it will attempt to verify that SSL is working correctly. Using certificate verification is highly recommended most of the time, but sometimes you may need to authenticate to a server (especially an internal server) using a custom certificate bundle.
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = AuthenticatedClient(
|
||||||
|
base_url="https://internal_api.example.com",
|
||||||
|
token="SuperSecretToken",
|
||||||
|
verify_ssl="/path/to/certificate_bundle.pem",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also disable certificate validation altogether, but beware that **this is a security risk**.
|
||||||
|
|
||||||
|
```python
|
||||||
|
client = AuthenticatedClient(
|
||||||
|
base_url="https://internal_api.example.com",
|
||||||
|
token="SuperSecretToken",
|
||||||
|
verify_ssl=False
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Things to know:
|
||||||
|
1. Every path/method combo becomes a Python module with four functions:
|
||||||
|
1. `sync`: Blocking request that returns parsed data (if successful) or `None`
|
||||||
|
1. `sync_detailed`: Blocking request that always returns a `Request`, optionally with `parsed` set if the request was successful.
|
||||||
|
1. `asyncio`: Like `sync` but async instead of blocking
|
||||||
|
1. `asyncio_detailed`: Like `sync_detailed` but async instead of blocking
|
||||||
|
|
||||||
|
1. All path/query params, and bodies become method arguments.
|
||||||
|
1. If your endpoint had any tags on it, the first tag will be used as a module name for the function (my_tag above)
|
||||||
|
1. Any endpoint which did not have a tag will be in `deutsche_post_international_shipping_api_client.api.default`
|
||||||
|
|
||||||
|
## Advanced customizations
|
||||||
|
|
||||||
|
There are more settings on the generated `Client` class which let you control more runtime behavior, check out the docstring on that class for more info. You can also customize the underlying `httpx.Client` or `httpx.AsyncClient` (depending on your use-case):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from deutsche_post_international_shipping_api_client import Client
|
||||||
|
|
||||||
|
def log_request(request):
|
||||||
|
print(f"Request event hook: {request.method} {request.url} - Waiting for response")
|
||||||
|
|
||||||
|
def log_response(response):
|
||||||
|
request = response.request
|
||||||
|
print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}")
|
||||||
|
|
||||||
|
client = Client(
|
||||||
|
base_url="https://api.example.com",
|
||||||
|
httpx_args={"event_hooks": {"request": [log_request], "response": [log_response]}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Or get the underlying httpx client to modify directly with client.get_httpx_client() or client.get_async_httpx_client()
|
||||||
|
```
|
||||||
|
|
||||||
|
You can even set the httpx client directly, but beware that this will override any existing settings (e.g., base_url):
|
||||||
|
|
||||||
|
```python
|
||||||
|
import httpx
|
||||||
|
from deutsche_post_international_shipping_api_client import Client
|
||||||
|
|
||||||
|
client = Client(
|
||||||
|
base_url="https://api.example.com",
|
||||||
|
)
|
||||||
|
# Note that base_url needs to be re-set, as would any shared cookies, headers, etc.
|
||||||
|
client.set_httpx_client(httpx.Client(base_url="https://api.example.com", proxies="http://localhost:8030"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building / publishing this package
|
||||||
|
This project uses [Poetry](https://python-poetry.org/) to manage dependencies and packaging. Here are the basics:
|
||||||
|
1. Update the metadata in pyproject.toml (e.g. authors, version)
|
||||||
|
1. If you're using a private repository, configure it with Poetry
|
||||||
|
1. `poetry config repositories.<your-repository-name> <url-to-your-repository>`
|
||||||
|
1. `poetry config http-basic.<your-repository-name> <username> <password>`
|
||||||
|
1. Publish the client with `poetry publish --build -r <your-repository-name>` or, if for public PyPI, just `poetry publish --build`
|
||||||
|
|
||||||
|
If you want to install this client into another project without publishing it (e.g. for development) then:
|
||||||
|
1. If that project **is using Poetry**, you can simply do `poetry add <path-to-this-client>` from that project
|
||||||
|
1. If that project is not using Poetry:
|
||||||
|
1. Build a wheel with `poetry build -f wheel`
|
||||||
|
1. Install that wheel from the other project `pip install <path-to-wheel>`
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""A client library for accessing Deutsche Post International Shipping API"""
|
||||||
|
|
||||||
|
from .client import AuthenticatedClient, Client
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"AuthenticatedClient",
|
||||||
|
"Client",
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user