Compare commits
59 Commits
main
...
27b346c1f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.**
|
||||||
165
.gitignore copy
165
.gitignore copy
@@ -1,165 +0,0 @@
|
|||||||
#
|
|
||||||
*.mp4
|
|
||||||
backups/
|
|
||||||
collectedstaticfiles/
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
||||||
.pdm.toml
|
|
||||||
.pdm-python
|
|
||||||
.pdm-build/
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
|
||||||
.venv
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
|
||||||
.spyderproject
|
|
||||||
.spyproject
|
|
||||||
|
|
||||||
# Rope project settings
|
|
||||||
.ropeproject
|
|
||||||
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
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
|
||||||
|
}
|
||||||
23
README.md
23
README.md
@@ -1,23 +0,0 @@
|
|||||||
# vontor-cz
|
|
||||||
|
|
||||||
## venv
|
|
||||||
- windows
|
|
||||||
|
|
||||||
```
|
|
||||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned
|
|
||||||
python -m venv venv
|
|
||||||
.\venv\Scripts\Activate
|
|
||||||
|
|
||||||
#start server
|
|
||||||
daphne -b localhost -p 8000 vontor_cz.asgi:application
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## docker compose
|
|
||||||
spuštění dockeru pro lokální hosting, s instantníma změnami během editace ve vscodu.
|
|
||||||
```docker-compose up --build```
|
|
||||||
|
|
||||||
## dns reset windows
|
|
||||||
```ipconfig /flushdns```
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from .models import MyModel # Importuj modely
|
|
||||||
|
|
||||||
# Příklad přizpůsobení zobrazení modelu v administrátorské sekci
|
|
||||||
class MyModelAdmin(admin.ModelAdmin):
|
|
||||||
# Určují se pole, která se zobrazí v seznamu (list view)
|
|
||||||
list_display = ('field1', 'field2', 'field3')
|
|
||||||
# Určuje, podle kterých polí lze vyhledávat
|
|
||||||
search_fields = ('field1', 'field2')
|
|
||||||
# Aktivuje filtrování podle hodnoty pole v pravém postranním panelu
|
|
||||||
list_filter = ('field1', 'field2')
|
|
||||||
# Určuje pole, která se zobrazí ve formuláři při detailním pohledu na model
|
|
||||||
fields = ('field1', 'field2', 'field3')
|
|
||||||
# Definuje rozložení polí ve formuláři
|
|
||||||
fieldsets = (
|
|
||||||
(None, {
|
|
||||||
'fields': ('field1', 'field2'),
|
|
||||||
}),
|
|
||||||
('Další informace', {
|
|
||||||
'classes': ('collapse',),
|
|
||||||
'fields': ('field3',),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
# Nastavuje výchozí řazení záznamů při jejich zobrazení
|
|
||||||
ordering = ('field1',)
|
|
||||||
# Určuje počet záznamů zobrazených na jedné stránce
|
|
||||||
list_per_page = 10
|
|
||||||
# Definuje akce dostupné pro vybrané objekty
|
|
||||||
actions = ['custom_action']
|
|
||||||
|
|
||||||
# Příklad vlastní akce
|
|
||||||
def custom_action(self, request, queryset):
|
|
||||||
# Vlastní logika pro akci
|
|
||||||
queryset.update(field1='Updated Value')
|
|
||||||
|
|
||||||
# Registrování modelu s vlastními nastaveními administrátorského rozhraní
|
|
||||||
admin.site.register(MyModel, MyModelAdmin)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
# Create your models here.
|
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
|
||||||
# Add custom fields here
|
|
||||||
bio = models.TextField(blank=True)
|
|
||||||
birthdate = models.DateField(null=True, blank=True)
|
|
||||||
profile_picture = models.ImageField(upload_to='profile_pics/', null=True, blank=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f'{self.user.username} Profile'
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from rest_framework_api_key.permissions import HasAPIKey
|
|
||||||
|
|
||||||
class UserEditAPIKeyPermissions(HasAPIKey):
|
|
||||||
"""
|
|
||||||
Custom permision for restricting access using API key.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from .models import User
|
|
||||||
|
|
||||||
#Serializers are for what views can show fields of models
|
|
||||||
|
|
||||||
class PublicUserSerializers(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for public User fields
|
|
||||||
"""
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ['id', 'username']
|
|
||||||
|
|
||||||
class SecureUserSerializers(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
Serializer for all User fields
|
|
||||||
Requires API key
|
|
||||||
"""
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'bio']
|
|
||||||
10
api/urls.py
10
api/urls.py
@@ -1,10 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from .views import PublicUserView, SecureUserUpdateView
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# URL for the public view to list users with public fields
|
|
||||||
path('users/', PublicUserView.as_view(), name='public-user-list'),
|
|
||||||
|
|
||||||
# URL for secure view to retrieve and update user with all fields
|
|
||||||
path('users/<int:pk>/', SecureUserUpdateView.as_view(), name='secure-user-update'),
|
|
||||||
]
|
|
||||||
20
api/views.py
20
api/views.py
@@ -1,20 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
|
|
||||||
from rest_framework import generics, permissions
|
|
||||||
from .models import User
|
|
||||||
from .serializers import PublicUserSerializers, SecureUserSerializers
|
|
||||||
from .permissions import UserEditAPIKeyPermissions
|
|
||||||
|
|
||||||
#Public view: List users with only public fields
|
|
||||||
class PublicUserView(generics.ListAPIView):
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = PublicUserSerializers
|
|
||||||
permission_classes = [permissions.AllowAny]
|
|
||||||
|
|
||||||
#Secure view for retrive/update user all fields (API key)
|
|
||||||
class SecureUserUpdateView(generics.RetrieveUpdateAPIView):
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = SecureUserSerializers
|
|
||||||
permission_classes = [UserEditAPIKeyPermissions]
|
|
||||||
26
backend/Dockerfile
Normal file
26
backend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
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 .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
23
backend/account/admin.py
Normal file
23
backend/account/admin.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id", "email", "role", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified", "create_time"
|
||||||
|
)
|
||||||
|
list_filter = ("role", "gdpr", "is_active", "email_verified", "city", "postal_code")
|
||||||
|
search_fields = ("email", "phone_number", "city", "street", "postal_code")
|
||||||
|
ordering = ("-create_time",)
|
||||||
|
fieldsets = (
|
||||||
|
(None, {"fields": ("email", "password", "role")}),
|
||||||
|
(_("Personal info"), {"fields": ("phone_number", "city", "street", "postal_code")}),
|
||||||
|
(_("Permissions"), {"fields": ("gdpr", "is_active", "email_verified")}),
|
||||||
|
(_("Important dates"), {"fields": ("create_time",)}),
|
||||||
|
)
|
||||||
|
readonly_fields = ("create_time",)
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
7
backend/account/apps.py
Normal file
7
backend/account/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
|
||||||
|
class AccountConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'account'
|
||||||
24
backend/account/filters.py
Normal file
24
backend/account/filters.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import django_filters
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserFilter(django_filters.FilterSet):
|
||||||
|
role = django_filters.CharFilter(field_name="role", lookup_expr="exact")
|
||||||
|
email = django_filters.CharFilter(field_name="email", lookup_expr="icontains")
|
||||||
|
phone_number = django_filters.CharFilter(field_name="phone_number", lookup_expr="icontains")
|
||||||
|
city = django_filters.CharFilter(field_name="city", lookup_expr="icontains")
|
||||||
|
street = django_filters.CharFilter(field_name="street", lookup_expr="icontains")
|
||||||
|
postal_code = django_filters.CharFilter(field_name="postal_code", lookup_expr="exact")
|
||||||
|
gdpr = django_filters.BooleanFilter(field_name="gdpr")
|
||||||
|
is_active = django_filters.BooleanFilter(field_name="is_active")
|
||||||
|
email_verified = django_filters.BooleanFilter(field_name="email_verified")
|
||||||
|
create_time_after = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="gte")
|
||||||
|
create_time_before = django_filters.IsoDateTimeFilter(field_name="create_time", lookup_expr="lte")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"role", "email", "phone_number", "city", "street", "postal_code", "gdpr", "is_active", "email_verified",
|
||||||
|
"create_time_after", "create_time_before"
|
||||||
|
]
|
||||||
61
backend/account/migrations/0001_initial.py
Normal file
61
backend/account/migrations/0001_initial.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-12-18 15:11
|
||||||
|
|
||||||
|
import account.models
|
||||||
|
import django.contrib.auth.validators
|
||||||
|
import django.core.validators
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomUser',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||||
|
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||||
|
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||||
|
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, 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', 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)),
|
||||||
|
('city', models.CharField(blank=True, max_length=100, 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}$')])),
|
||||||
|
('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')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', account.models.CustomUserManager()),
|
||||||
|
('active', account.models.ActiveUserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
175
backend/account/models.py
Normal file
175
backend/account/models.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
|
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from vontor_cz.models import SoftDeleteModel
|
||||||
|
|
||||||
|
from django.contrib.auth.models import UserManager
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CustomUserManager(UserManager):
|
||||||
|
# Inherit get_by_natural_key and all auth behaviors
|
||||||
|
use_in_migrations = True
|
||||||
|
|
||||||
|
class ActiveUserManager(CustomUserManager):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(is_active=True)
|
||||||
|
|
||||||
|
class CustomUser(SoftDeleteModel, AbstractUser):
|
||||||
|
groups = models.ManyToManyField(
|
||||||
|
Group,
|
||||||
|
related_name="customuser_set",
|
||||||
|
blank=True,
|
||||||
|
help_text="The groups this user belongs to.",
|
||||||
|
related_query_name="customuser",
|
||||||
|
)
|
||||||
|
user_permissions = models.ManyToManyField(
|
||||||
|
Permission,
|
||||||
|
related_name="customuser_set",
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this user.",
|
||||||
|
related_query_name="customuser",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Role(models.TextChoices):
|
||||||
|
ADMIN = "admin", "cz#Administrátor"
|
||||||
|
MANAGER = "mod", "cz#Moderator"
|
||||||
|
CUSTOMER = "regular", "cz#Regular"
|
||||||
|
|
||||||
|
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
|
||||||
|
|
||||||
|
phone_number = models.CharField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
unique=True,
|
||||||
|
max_length=16,
|
||||||
|
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)
|
||||||
|
|
||||||
|
# + 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)
|
||||||
|
|
||||||
|
|
||||||
|
#adresa
|
||||||
|
postal_code = models.CharField(max_length=20, blank=True)
|
||||||
|
city = models.CharField(null=True, blank=True, max_length=100)
|
||||||
|
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(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
|
||||||
|
max_length=5,
|
||||||
|
validators=[
|
||||||
|
RegexValidator(
|
||||||
|
regex=r'^\d{5}$',
|
||||||
|
message="Postal code must contain exactly 5 digits.",
|
||||||
|
code='invalid_postal_code'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
USERNAME_FIELD = "username"
|
||||||
|
REQUIRED_FIELDS = [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure default manager has get_by_natural_key
|
||||||
|
objects = CustomUserManager()
|
||||||
|
# Optional convenience manager for active users only
|
||||||
|
active = ActiveUserManager()
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
self.is_active = False
|
||||||
|
|
||||||
|
return super().delete(*args, **kwargs)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = self._state.adding # True if object hasn't been saved yet
|
||||||
|
|
||||||
|
# Pre-save flags for new users
|
||||||
|
if is_new:
|
||||||
|
if self.is_superuser or self.role == "admin":
|
||||||
|
# ensure admin flags are consistent
|
||||||
|
self.is_active = True
|
||||||
|
self.is_staff = True
|
||||||
|
self.is_superuser = True
|
||||||
|
self.role = "admin"
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
77
backend/account/permissions.py
Normal file
77
backend/account/permissions.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from urllib import request
|
||||||
|
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework_api_key.permissions import HasAPIKey
|
||||||
|
|
||||||
|
|
||||||
|
#TOHLE POUŽÍT!!!
|
||||||
|
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
|
||||||
|
def RoleAllowed(*roles):
|
||||||
|
"""
|
||||||
|
Allows safe methods for any authenticated user.
|
||||||
|
Allows unsafe methods only for users with specific roles.
|
||||||
|
Allows access if a valid API key is provided.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
RoleAllowed('admin', 'user')
|
||||||
|
"""
|
||||||
|
class SafeOrRolePermission(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Má API klíč?
|
||||||
|
has_api_key = HasAPIKey().has_permission(request, view)
|
||||||
|
|
||||||
|
# Allow safe methods for any authenticated user
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return IsAuthenticated().has_permission(request, view)
|
||||||
|
|
||||||
|
# Otherwise, check the user's role
|
||||||
|
user = request.user
|
||||||
|
return user and user.is_authenticated and getattr(user, "role", None) in roles
|
||||||
|
|
||||||
|
return SafeOrRolePermission
|
||||||
|
|
||||||
|
|
||||||
|
def OnlyRolesAllowed(*roles):
|
||||||
|
class SafeOrRolePermission(BasePermission):
|
||||||
|
"""
|
||||||
|
Allows all methods only for users with specific roles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Otherwise, check the user's role
|
||||||
|
user = request.user
|
||||||
|
return user and user.is_authenticated and getattr(user, "role", None) in roles
|
||||||
|
|
||||||
|
return SafeOrRolePermission
|
||||||
|
|
||||||
|
|
||||||
|
# For Settings.py
|
||||||
|
class AdminOnly(BasePermission):
|
||||||
|
""" Allows access only to users with the 'admin' role.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BasePermission (rest_framework.permissions.BasePermission): Base class for permission classes.
|
||||||
|
"""
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
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'
|
||||||
|
|
||||||
205
backend/account/serializers.py
Normal file
205
backend/account/serializers.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import re
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from rest_framework import serializers
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from .permissions import *
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class CustomUserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"username",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"role",
|
||||||
|
"email_verified",
|
||||||
|
"phone_number",
|
||||||
|
"create_time",
|
||||||
|
"city",
|
||||||
|
"street",
|
||||||
|
"postal_code",
|
||||||
|
"gdpr",
|
||||||
|
"is_active",
|
||||||
|
]
|
||||||
|
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
user = self.context["request"].user
|
||||||
|
staff_only_fields = ["role", "email_verified", "var_symbol", "is_active"]
|
||||||
|
|
||||||
|
if user.role not in ["admin", "cityClerk"]:
|
||||||
|
unauthorized = [f for f in staff_only_fields if f in validated_data]
|
||||||
|
if unauthorized:
|
||||||
|
raise PermissionDenied(f"You are not allowed to modify: {', '.join(unauthorized)}")
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Token obtaining Default Serializer
|
||||||
|
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||||
|
username_field = User.USERNAME_FIELD
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
login = attrs.get("username")
|
||||||
|
password = attrs.get("password")
|
||||||
|
|
||||||
|
# Allow login by username or email
|
||||||
|
user = User.objects.filter(email__iexact=login).first() or \
|
||||||
|
User.objects.filter(username__iexact=login).first()
|
||||||
|
|
||||||
|
if user is None or not user.check_password(password):
|
||||||
|
raise serializers.ValidationError(_("No active account found with the given credentials"))
|
||||||
|
|
||||||
|
# Call the parent validation to create token
|
||||||
|
data = super().validate({
|
||||||
|
self.username_field: user.username,
|
||||||
|
"password": password
|
||||||
|
})
|
||||||
|
|
||||||
|
data["user_id"] = user.id
|
||||||
|
data["username"] = user.username
|
||||||
|
data["email"] = user.email
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# user creating section start ------------------------------------------
|
||||||
|
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||||
|
password = serializers.CharField(
|
||||||
|
write_only=True,
|
||||||
|
help_text="Heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'first_name', 'last_name', 'email', 'phone_number', 'password',
|
||||||
|
'city', 'street', 'postal_code', 'gdpr'
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'first_name': {'required': True, 'help_text': 'Křestní jméno uživatele'},
|
||||||
|
'last_name': {'required': True, 'help_text': 'Příjmení uživatele'},
|
||||||
|
'email': {'required': True, 'help_text': 'Emailová adresa uživatele'},
|
||||||
|
'phone_number': {'required': True, 'help_text': 'Telefonní číslo uživatele'},
|
||||||
|
'city': {'required': True, 'help_text': 'Město uživatele'},
|
||||||
|
'street': {'required': True, 'help_text': 'Ulice uživatele'},
|
||||||
|
'postal_code': {'required': True, 'help_text': 'PSČ uživatele'},
|
||||||
|
'gdpr': {'required': True, 'help_text': 'Souhlas se zpracováním osobních údajů'},
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
if len(value) < 8:
|
||||||
|
raise serializers.ValidationError("Password must be at least 8 characters long.")
|
||||||
|
if not re.search(r"[A-Z]", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one uppercase letter.")
|
||||||
|
if not re.search(r"[a-z]", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one lowercase letter.")
|
||||||
|
if not re.search(r"\d", value):
|
||||||
|
raise serializers.ValidationError("Password must contain at least one digit.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
email = data.get("email")
|
||||||
|
phone = data.get("phone_number")
|
||||||
|
dgpr = data.get("GDPR")
|
||||||
|
if not dgpr:
|
||||||
|
raise serializers.ValidationError({"GDPR": "You must agree to the GDPR to register."})
|
||||||
|
|
||||||
|
if User.objects.filter(email=email).exists():
|
||||||
|
raise serializers.ValidationError({"email": "Account with this email already exists."})
|
||||||
|
|
||||||
|
if phone and User.objects.filter(phone_number=phone).exists():
|
||||||
|
raise serializers.ValidationError({"phone_number": "Account with this phone number already exists."})
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
password = validated_data.pop("password")
|
||||||
|
username = validated_data.get("username", "")
|
||||||
|
user = User.objects.create(
|
||||||
|
username=username,
|
||||||
|
is_active=False, #uživatel je defaultně deaktivovaný
|
||||||
|
**validated_data
|
||||||
|
)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
class UserActivationSerializer(serializers.Serializer):
|
||||||
|
user_id = serializers.IntegerField()
|
||||||
|
var_symbol = serializers.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(9999999999)])
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=self.validated_data['user_id'])
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise NotFound("User with this ID does not exist.")
|
||||||
|
user.var_symbol = self.validated_data['var_symbol']
|
||||||
|
user.is_active = True
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
return {
|
||||||
|
"id": instance.id,
|
||||||
|
"email": instance.email,
|
||||||
|
"var_symbol": instance.var_symbol,
|
||||||
|
"is_active": instance.is_active,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'user_id', 'var_symbol'
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'user_id': {'required': True, 'help_text': 'ID uživatele'},
|
||||||
|
'var_symbol': {'required': True, 'help_text': 'Variablní symbol, zadán úředníkem'},
|
||||||
|
}
|
||||||
|
# user creating section end --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequestSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField(
|
||||||
|
help_text="E-mail registrovaného a aktivního uživatele, na který bude zaslán reset hesla."
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
if not User.objects.filter(email=value, is_active=True).exists():
|
||||||
|
raise serializers.ValidationError("Účet s tímto emailem neexistuje nebo není aktivní.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class PasswordResetConfirmSerializer(serializers.Serializer):
|
||||||
|
password = serializers.CharField(
|
||||||
|
write_only=True,
|
||||||
|
help_text="Nové heslo musí mít alespoň 8 znaků, obsahovat velká a malá písmena a číslici."
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
import re
|
||||||
|
if len(value) < 8:
|
||||||
|
raise serializers.ValidationError("Heslo musí mít alespoň 8 znaků.")
|
||||||
|
if not re.search(r"[A-Z]", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat velké písmeno.")
|
||||||
|
if not re.search(r"[a-z]", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat malé písmeno.")
|
||||||
|
if not re.search(r"\d", value):
|
||||||
|
raise serializers.ValidationError("Musí obsahovat číslici.")
|
||||||
|
return value
|
||||||
121
backend/account/tasks.py
Normal file
121
backend/account/tasks.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
from celery.utils.log import get_task_logger
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.http import urlsafe_base64_encode
|
||||||
|
from django.utils.encoding import force_bytes
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from .tokens import *
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
|
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
|
||||||
|
"""
|
||||||
|
Send emails rendering a single HTML template.
|
||||||
|
- `template_name` is a simple base name without extension, e.g. "email/test".
|
||||||
|
- Renders only HTML (".html"), no ".txt" support.
|
||||||
|
- Converts `user` in context to a plain dict to avoid passing models to templates.
|
||||||
|
"""
|
||||||
|
if isinstance(recipients, str):
|
||||||
|
recipients = [recipients]
|
||||||
|
|
||||||
|
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(
|
||||||
|
"email/components/base.html",
|
||||||
|
{"content_template": template_path, **ctx},
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
def send_email_verification_task(user_id):
|
||||||
|
try:
|
||||||
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
|
# {changed} generate and store a per-user token
|
||||||
|
token = user.generate_email_verification_token()
|
||||||
|
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": user,
|
||||||
|
"action_url": verify_url,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Ověřit e‑mail",
|
||||||
|
}
|
||||||
|
|
||||||
|
send_email_with_context(
|
||||||
|
recipients=user.email,
|
||||||
|
subject="Ověření e‑mailu",
|
||||||
|
template_path="email/email_verification.html",
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_email_test_task(email):
|
||||||
|
context = {
|
||||||
|
"action_url": settings.FRONTEND_URL,
|
||||||
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"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:
|
||||||
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
|
except CustomUser.DoesNotExist:
|
||||||
|
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
|
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,
|
||||||
|
)
|
||||||
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>
|
||||||
28
backend/account/tests.py
Normal file
28
backend/account/tests.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
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)}")
|
||||||
45
backend/account/tokens.py
Normal file
45
backend/account/tokens.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||||
|
|
||||||
|
# Subclass PasswordResetTokenGenerator to create a separate token generator
|
||||||
|
# for account activation. This allows future customization specific to activation tokens,
|
||||||
|
# even though it currently behaves exactly like the base class.
|
||||||
|
class AccountActivationTokenGenerator(PasswordResetTokenGenerator):
|
||||||
|
pass # No changes yet; inherits all behavior from PasswordResetTokenGenerator
|
||||||
|
|
||||||
|
# Create an instance of AccountActivationTokenGenerator to be used for generating
|
||||||
|
# and validating account activation tokens throughout the app.
|
||||||
|
account_activation_token = AccountActivationTokenGenerator()
|
||||||
|
|
||||||
|
# Create an instance of the base PasswordResetTokenGenerator to be used
|
||||||
|
# for password reset tokens.
|
||||||
|
password_reset_token = PasswordResetTokenGenerator()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||||
|
|
||||||
|
#COOKIE + AUTHORIZATION HEADER JWT AUTHENTICATION FOR AXIOS COMPATIBILITY
|
||||||
|
class CookieJWTAuthentication(JWTAuthentication):
|
||||||
|
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')
|
||||||
|
|
||||||
|
if not raw_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
validated_token = self.get_validated_token(raw_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
|
||||||
|
|
||||||
26
backend/account/urls.py
Normal file
26
backend/account/urls.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'users', views.UserView, basename='user')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# Auth endpoints
|
||||||
|
path('login/', views.CookieTokenObtainPairView.as_view(), name='login'),
|
||||||
|
path('token/refresh/', views.CookieTokenRefreshView.as_view(), name='token-refresh'),
|
||||||
|
path('logout/', views.LogoutView.as_view(), name='logout'),
|
||||||
|
path('user/me/', views.CurrentUserView.as_view(), name='user-detail'),
|
||||||
|
|
||||||
|
# Registration & email endpoints
|
||||||
|
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
||||||
|
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
|
||||||
|
|
||||||
|
# Password reset endpoints
|
||||||
|
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
||||||
|
path('password-reset-confirm/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'),
|
||||||
|
|
||||||
|
# User CRUD (list, retrieve, update, delete)
|
||||||
|
path('', include(router.urls)), #/users/
|
||||||
|
]
|
||||||
411
backend/account/views.py
Normal file
411
backend/account/views.py
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
from django.contrib.auth import get_user_model, authenticate
|
||||||
|
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
|
||||||
|
from django.utils.encoding import force_bytes, force_str
|
||||||
|
|
||||||
|
from .serializers import *
|
||||||
|
from .permissions import *
|
||||||
|
from .models import CustomUser
|
||||||
|
from .tokens import *
|
||||||
|
from .tasks import send_password_reset_email_task, send_email_verification_task
|
||||||
|
from django.conf import settings
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from .filters import UserFilter
|
||||||
|
|
||||||
|
from rest_framework import generics, permissions, status, viewsets
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
from rest_framework_simplejwt.exceptions import TokenError, AuthenticationFailed
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
|
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
#general user view API
|
||||||
|
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||||
|
|
||||||
|
#---------------------------------------------TOKENY------------------------------------------------
|
||||||
|
|
||||||
|
# Custom Token obtaining view
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account", "public"],
|
||||||
|
summary="Obtain JWT access and refresh tokens (cookie-based)",
|
||||||
|
description="Authenticate user and obtain JWT access and refresh tokens. You can use either email or username.",
|
||||||
|
request=CustomTokenObtainPairSerializer,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=CustomTokenObtainPairSerializer, description="Tokens returned successfully."),
|
||||||
|
401: OpenApiResponse(description="Invalid credentials or inactive user."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class CookieTokenObtainPairView(TokenObtainPairView):
|
||||||
|
serializer_class = CustomTokenObtainPairSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
response = super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
# Získáme tokeny z odpovědi
|
||||||
|
access = response.data.get("access")
|
||||||
|
refresh = response.data.get("refresh")
|
||||||
|
|
||||||
|
if not access or not refresh:
|
||||||
|
return response # Např. při chybě přihlášení
|
||||||
|
|
||||||
|
jwt_settings = settings.SIMPLE_JWT
|
||||||
|
|
||||||
|
# Access token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key=jwt_settings.get("AUTH_COOKIE", "access_token"),
|
||||||
|
value=access,
|
||||||
|
httponly=jwt_settings.get("AUTH_COOKIE_HTTP_ONLY", True),
|
||||||
|
secure=jwt_settings.get("AUTH_COOKIE_SECURE", not settings.DEBUG),
|
||||||
|
samesite=jwt_settings.get("AUTH_COOKIE_SAMESITE", "Lax"),
|
||||||
|
path=jwt_settings.get("AUTH_COOKIE_PATH", "/"),
|
||||||
|
max_age=5 * 60, # 5 minut
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh token cookie
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=refresh,
|
||||||
|
httponly=True,
|
||||||
|
secure=not settings.DEBUG,
|
||||||
|
samesite="Lax",
|
||||||
|
path="/",
|
||||||
|
max_age=7 * 24 * 60 * 60, # 7 dní
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
username = attrs.get("username")
|
||||||
|
password = attrs.get("password")
|
||||||
|
|
||||||
|
# Přihlaš uživatele ručně
|
||||||
|
user = authenticate(request=self.context.get('request'), username=username, password=password)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise AuthenticationFailed("Špatné uživatelské jméno nebo heslo.")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
raise AuthenticationFailed("Uživatel je deaktivován.")
|
||||||
|
|
||||||
|
# Nastav validní uživatele (přebere další logiku ze SimpleJWT)
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
# Vrátí access a refresh token jako obvykle
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account", "public"],
|
||||||
|
summary="Refresh JWT token using cookie",
|
||||||
|
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Tokens refreshed successfully."),
|
||||||
|
400: OpenApiResponse(description="Refresh token cookie not found."),
|
||||||
|
401: OpenApiResponse(description="Invalid refresh token."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class CookieTokenRefreshView(APIView):
|
||||||
|
def post(self, request):
|
||||||
|
refresh_token = request.COOKIES.get('refresh_token')
|
||||||
|
if not refresh_token:
|
||||||
|
return Response({"detail": "Refresh token cookie not found."}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
refresh = RefreshToken(refresh_token)
|
||||||
|
access_token = str(refresh.access_token)
|
||||||
|
new_refresh_token = str(refresh) # volitelně nový refresh token
|
||||||
|
|
||||||
|
response = Response({
|
||||||
|
"access": access_token,
|
||||||
|
"refresh": new_refresh_token,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Nastav nové HttpOnly cookies
|
||||||
|
# Access token cookie (např. 5 minut platnost)
|
||||||
|
response.set_cookie(
|
||||||
|
"access_token",
|
||||||
|
access_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=not settings.DEBUG,
|
||||||
|
samesite="Lax",
|
||||||
|
max_age=5 * 60,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh token cookie (delší platnost, např. 7 dní)
|
||||||
|
response.set_cookie(
|
||||||
|
"refresh_token",
|
||||||
|
new_refresh_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=not settings.DEBUG,
|
||||||
|
samesite="Lax",
|
||||||
|
max_age=7 * 24 * 60 * 60,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except TokenError:
|
||||||
|
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
#---------------------------------------------LOGOUT------------------------------------------------
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account", "public"],
|
||||||
|
summary="Logout user (delete access and refresh token cookies)",
|
||||||
|
description="Logs out the user by deleting access and refresh token cookies.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Logout successful."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class LogoutView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
response = Response({"detail": "Logout successful"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# Smazání cookies
|
||||||
|
response.delete_cookie("access_token", path="/")
|
||||||
|
response.delete_cookie("refresh_token", path="/")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
#--------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account"],
|
||||||
|
summary="List, retrieve, update, and delete users.",
|
||||||
|
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=CustomUserSerializer, description="User(s) retrieved successfully."),
|
||||||
|
403: OpenApiResponse(description="Permission denied."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class UserView(viewsets.ModelViewSet):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
serializer_class = CustomUserSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend]
|
||||||
|
filterset_class = UserFilter
|
||||||
|
|
||||||
|
# Require authentication and role permission
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CustomUser
|
||||||
|
extra_kwargs = {
|
||||||
|
"email": {"help_text": "Unikátní e-mailová adresa uživatele."},
|
||||||
|
"phone_number": {"help_text": "Telefonní číslo ve formátu +420123456789."},
|
||||||
|
"role": {"help_text": "Role uživatele určující jeho oprávnění v systému."},
|
||||||
|
"account_type": {"help_text": "Typ účtu – firma nebo fyzická osoba."},
|
||||||
|
"email_verified": {"help_text": "Určuje, zda je e-mail ověřen."},
|
||||||
|
"create_time": {"help_text": "Datum a čas registrace uživatele (pouze pro čtení).", "read_only": True},
|
||||||
|
"var_symbol": {"help_text": "Variabilní symbol pro platby, pokud je vyžadován."},
|
||||||
|
"bank_account": {"help_text": "Číslo bankovního účtu uživatele."},
|
||||||
|
"ICO": {"help_text": "IČO firmy, pokud se jedná o firemní účet."},
|
||||||
|
"RC": {"help_text": "Rodné číslo pro fyzické osoby."},
|
||||||
|
"city": {"help_text": "Město trvalého pobytu / sídla."},
|
||||||
|
"street": {"help_text": "Ulice a číslo popisné."},
|
||||||
|
"PSC": {"help_text": "PSČ místa pobytu / sídla."},
|
||||||
|
"GDPR": {"help_text": "Souhlas se zpracováním osobních údajů."},
|
||||||
|
"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):
|
||||||
|
# Only admin can list or create users
|
||||||
|
if self.action in ['list', 'create']:
|
||||||
|
return [OnlyRolesAllowed("admin")()]
|
||||||
|
|
||||||
|
# Only admin or the user themselves can update or delete
|
||||||
|
elif self.action in ['update', 'partial_update', 'destroy']:
|
||||||
|
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")()]
|
||||||
|
|
||||||
|
# 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()]
|
||||||
|
|
||||||
|
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
||||||
|
return [OnlyRolesAllowed("admin")()]
|
||||||
|
|
||||||
|
# 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':
|
||||||
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
|
return super().get_permissions()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Get current user data
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account"],
|
||||||
|
summary="Get current authenticated user",
|
||||||
|
description="Returns details of the currently authenticated user based on JWT token or session.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(response=CustomUserSerializer, description="Current user details."),
|
||||||
|
401: OpenApiResponse(description="Unauthorized, user is not authenticated."),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
class CurrentUserView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
serializer = CustomUserSerializer(request.user)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
#------------------------------------------------REGISTRACE--------------------------------------------------------------
|
||||||
|
|
||||||
|
#1. registration API
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account", "public"],
|
||||||
|
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.",
|
||||||
|
request=UserRegistrationSerializer,
|
||||||
|
responses={
|
||||||
|
201: OpenApiResponse(response=UserRegistrationSerializer, description="User registered successfully."),
|
||||||
|
400: OpenApiResponse(description="Invalid registration data."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class UserRegistrationViewSet(ModelViewSet):
|
||||||
|
queryset = CustomUser.objects.all()
|
||||||
|
serializer_class = UserRegistrationSerializer
|
||||||
|
http_method_names = ['post']
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
user = serializer.save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_email_verification_task.delay(user.id) # posílaní emailu pro potvrzení registrace - CELERY TASK
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Celery not available, using fallback. Error: {e}")
|
||||||
|
send_email_verification_task(user.id) # posílaní emailu pro potvrzení registrace
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||||
|
|
||||||
|
#2. confirming email
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account", "public"],
|
||||||
|
summary="Verify user email via link",
|
||||||
|
description="Verify user email using the link with uid and token.",
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name='uidb64', type=str, location=OpenApiParameter.PATH, description="User ID encoded in base64 from email link."),
|
||||||
|
OpenApiParameter(name='token', type=str, location=OpenApiParameter.PATH, description="User token from email link."),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Email successfully verified."),
|
||||||
|
400: OpenApiResponse(description="Invalid or expired token."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class EmailVerificationView(APIView):
|
||||||
|
def get(self, request, uidb64, token):
|
||||||
|
try:
|
||||||
|
uid = force_str(urlsafe_base64_decode(uidb64))
|
||||||
|
user = User.objects.get(pk=uid)
|
||||||
|
except (User.DoesNotExist, ValueError, TypeError):
|
||||||
|
return Response({"error": "Neplatný odkaz."}, status=400)
|
||||||
|
|
||||||
|
if account_activation_token.check_token(user, token):
|
||||||
|
user.email_verified = True
|
||||||
|
user.is_active = True # Aktivace uživatele po ověření e-mailu
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return Response({"detail": "E-mail byl úspěšně ověřen. Účet je aktivován."})
|
||||||
|
else:
|
||||||
|
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
||||||
|
|
||||||
|
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
|
||||||
|
|
||||||
|
#1. PasswordReset + send Email
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account", "public"],
|
||||||
|
summary="Request password reset (send email)",
|
||||||
|
description="Request password reset by providing registered email. An email with instructions will be sent.",
|
||||||
|
request=PasswordResetRequestSerializer,
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Email with instructions sent."),
|
||||||
|
400: OpenApiResponse(description="Invalid email or request data."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class PasswordResetRequestView(APIView):
|
||||||
|
def post(self, request):
|
||||||
|
serializer = PasswordResetRequestSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=serializer.validated_data['email'])
|
||||||
|
except User.DoesNotExist:
|
||||||
|
# Always return 200 even if user doesn't exist to avoid user enumeration
|
||||||
|
return Response({"detail": "E-mail s odkazem byl odeslán."})
|
||||||
|
try:
|
||||||
|
send_password_reset_email_task.delay(user.id) # posílaní emailu pro obnovení hesla - CELERY TASK
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Celery not available, using fallback. Error: {e}")
|
||||||
|
send_password_reset_email_task(user.id) # posílaní emailu pro obnovení hesla registrace
|
||||||
|
|
||||||
|
return Response({"detail": "E-mail s odkazem byl odeslán."})
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
#2. Confirming reset
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account", "public"],
|
||||||
|
summary="Confirm password reset via token",
|
||||||
|
description="Confirm password reset using token from email.",
|
||||||
|
request=PasswordResetConfirmSerializer,
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(name='uidb64', type=str, location=OpenApiParameter.PATH, description="User ID encoded in base64 from email link."),
|
||||||
|
OpenApiParameter(name='token', type=str, location=OpenApiParameter.PATH, description="Password reset token from email link."),
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Password changed successfully."),
|
||||||
|
400: OpenApiResponse(description="Invalid token or request data."),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
class PasswordResetConfirmView(APIView):
|
||||||
|
def post(self, request, uidb64, token):
|
||||||
|
try:
|
||||||
|
uid = force_str(urlsafe_base64_decode(uidb64))
|
||||||
|
user = User.objects.get(pk=uid)
|
||||||
|
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
|
||||||
|
return Response({"error": "Neplatný odkaz."}, status=400)
|
||||||
|
|
||||||
|
if not password_reset_token.check_token(user, token):
|
||||||
|
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
||||||
|
|
||||||
|
serializer = PasswordResetConfirmSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
user.set_password(serializer.validated_data['password'])
|
||||||
|
user.save()
|
||||||
|
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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>
|
||||||
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>
|
||||||
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
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
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
|
||||||
107
backend/requirements.txt
Normal file
107
backend/requirements.txt
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# -- BASE --
|
||||||
|
requests
|
||||||
|
|
||||||
|
pip
|
||||||
|
python-dotenv # .env support
|
||||||
|
virtualenv #venv
|
||||||
|
|
||||||
|
Django
|
||||||
|
|
||||||
|
numpy # NumPy je knihovna programovacího jazyka Python, která poskytuje infrastrukturu pro práci s vektory, maticemi a obecně vícerozměrnými poli.
|
||||||
|
|
||||||
|
|
||||||
|
# -- DATABASE --
|
||||||
|
sqlparse #non-validating SQL parser for Python. It provides support for parsing, splitting and formatting SQL statements.
|
||||||
|
tzdata #timezone
|
||||||
|
|
||||||
|
|
||||||
|
psycopg[binary] #PostgreSQL database adapter for the Python
|
||||||
|
|
||||||
|
django-filter
|
||||||
|
|
||||||
|
django-constance #allows you to store and manage settings of page in the Django admin interface!!!!
|
||||||
|
|
||||||
|
# -- OBJECT STORAGE --
|
||||||
|
Pillow #adds image processing capabilities to your Python interpreter
|
||||||
|
|
||||||
|
whitenoise #pomáha se spuštěním serveru a načítaní static files
|
||||||
|
|
||||||
|
|
||||||
|
django-cleanup #odstraní zbytečné media soubory které nejsou v databázi/modelu
|
||||||
|
django-storages # potřeba k S3 bucket storage
|
||||||
|
boto3
|
||||||
|
|
||||||
|
|
||||||
|
# -- PROTOCOLS (asgi, websockets) --
|
||||||
|
redis
|
||||||
|
|
||||||
|
channels_redis
|
||||||
|
|
||||||
|
channels #django channels
|
||||||
|
|
||||||
|
#channels requried package
|
||||||
|
uvicorn[standard]
|
||||||
|
daphne
|
||||||
|
|
||||||
|
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 --
|
||||||
|
djangorestframework #REST Framework
|
||||||
|
|
||||||
|
djangorestframework-api-key #API key
|
||||||
|
|
||||||
|
djangorestframework-simplejwt #JWT authentication for Django REST Framework
|
||||||
|
PyJWT #JSON Web Token implementation in Python
|
||||||
|
|
||||||
|
asgiref #ASGI reference implementation, to be used with Django Channels
|
||||||
|
pytz
|
||||||
|
# pytz brings the Olson tz database into Python and allows
|
||||||
|
# accurate and cross platform timezone calculations.
|
||||||
|
# It also solves the issue of ambiguous times at the end of daylight saving time.
|
||||||
|
|
||||||
|
#documentation for frontend dev
|
||||||
|
drf-spectacular
|
||||||
|
|
||||||
|
# -- APPS --
|
||||||
|
|
||||||
|
django-tinymce
|
||||||
|
|
||||||
|
django-cors-headers #csfr
|
||||||
|
|
||||||
|
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-silk
|
||||||
|
django-silk[formatting]
|
||||||
|
|
||||||
|
# -- EDITING photos, gifs, videos --
|
||||||
|
|
||||||
|
#aiofiles
|
||||||
|
#opencv-python #moviepy use this better instead of pillow
|
||||||
|
#moviepy
|
||||||
|
|
||||||
|
yt-dlp
|
||||||
|
|
||||||
|
weasyprint #tvoření PDFek z html dokumentu + css styly
|
||||||
|
|
||||||
|
## -- MISCELLANEOUS --
|
||||||
|
|
||||||
|
faker #generates fake data for testing purposes
|
||||||
|
|
||||||
|
zeep #SOAP tool
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## -- API --
|
||||||
|
|
||||||
|
#generates api (if schema exists (ONLY USE OPENAPI NO SWAGGER))
|
||||||
|
openapi-python-client
|
||||||
|
|
||||||
|
stripe
|
||||||
|
gopay
|
||||||
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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
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
|
||||||
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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class ApiConfig(AppConfig):
|
class PagesConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'api'
|
name = 'pages'
|
||||||
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.
|
||||||
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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class HomeConfig(AppConfig):
|
class PostsConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'home'
|
name = 'posts'
|
||||||
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.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user