Compare commits
56 Commits
2118f002d1
...
bruno
| Author | SHA1 | Date | |
|---|---|---|---|
| f9636d1464 | |||
| 3e4d58f80d | |||
|
|
304194d7ec | ||
| 7c768c9be3 | |||
| ed1b7de7a7 | |||
| ca62e8895a | |||
| 679cff2366 | |||
| 775709bd08 | |||
| 8f6d864b4b | |||
| 3a7044d551 | |||
| 27b346c1f6 | |||
| 963ba6b824 | |||
| c0bd24ee5e | |||
|
|
b38d126b6c | ||
| 2a26edac80 | |||
| e78baf746c | |||
| b279ac36d5 | |||
| 98426f8b05 | |||
| 2213e115c6 | |||
|
|
7ebc83dd8c | ||
|
|
c6ca9e2741 | ||
| 4f56f4bbc5 | |||
| f7605812c1 | |||
| deb853b564 | |||
| 00271e59e4 | |||
| 264f0116ae | |||
| cf615c5279 | |||
| 1cec6be6d7 | |||
| abc6207296 | |||
| 9c48aee522 | |||
| 0346180d01 | |||
| 713c94d7e9 | |||
|
|
2498386477 | ||
| 72155d4560 | |||
| 1751badb90 | |||
| 564418501c | |||
|
|
df83288591 | ||
| b4e50eda30 | |||
| a2bc1e68ee | |||
|
|
ada74c84a6 | ||
|
|
946f86db7e | ||
| 5b066e2770 | |||
|
|
4cbebff43b | ||
|
|
d94ad93222 | ||
|
|
ebab304b75 | ||
|
|
37f36b3466 | ||
|
|
102855f812 | ||
| e86839f2da | |||
| b8a1a594b2 | |||
|
|
7a715efeda | ||
| f14c09bf7a | |||
|
|
052f7ab533 | ||
| 5c3a02d282 | |||
| c39467dc7d | |||
| a645c87020 | |||
| c3f837b90f |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
frontend/dist
|
||||
/frontend/node_modules
|
||||
/venv
|
||||
/backups
|
||||
/.github
|
||||
/.vscode
|
||||
/.git
|
||||
107
.github/copilot-instructions.md
vendored
107
.github/copilot-instructions.md
vendored
@@ -33,6 +33,10 @@ This monorepo contains a Django backend and a Vite/React frontend, orchestrated
|
||||
- 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/`.
|
||||
@@ -107,6 +111,109 @@ Notes
|
||||
- **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.
|
||||
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.13 (vontor-cz)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (vontor-cz)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/vontor-cz.iml" filepath="$PROJECT_DIR$/.idea/vontor-cz.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
35
.idea/vontor-cz.iml
generated
Normal file
35
.idea/vontor-cz.iml
generated
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="django" name="Django">
|
||||
<configuration>
|
||||
<option name="rootFolder" value="$MODULE_DIR$/backend" />
|
||||
<option name="settingsModule" value="vontor_cz/settings.py" />
|
||||
<option name="manageScript" value="$MODULE_DIR$/backend/manage.py" />
|
||||
<option name="environment" value="<map/>" />
|
||||
<option name="doNotUseTestRunner" value="false" />
|
||||
<option name="trackFilePattern" value="migrations" />
|
||||
</configuration>
|
||||
</facet>
|
||||
</component>
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.13 (vontor-cz)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="GOOGLE" />
|
||||
<option name="myDocStringFormat" value="Google" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
|
||||
<option name="TEMPLATE_FOLDERS">
|
||||
<list>
|
||||
<option value="$MODULE_DIR$/backend/account/templates" />
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</module>
|
||||
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
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -1,23 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="reset.css">
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4267
absolete_frontend/package-lock.json
generated
4267
absolete_frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/react-router": "^5.1.20",
|
||||
"axios": "^1.13.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.8.1",
|
||||
"tailwindcss": "^4.1.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/axios": "^0.9.36",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Link, Outlet } from "react-router-dom"
|
||||
import Home from "./pages/home/home";
|
||||
import HomeLayout from "./layouts/HomeLayout";
|
||||
import Downloader from "./pages/downloader/Downloader";
|
||||
|
||||
import PrivateRoute from "./routes/PrivateRoute";
|
||||
|
||||
import { UserContextProvider } from "./context/UserContext";
|
||||
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router>
|
||||
<UserContextProvider>
|
||||
|
||||
{/* Layout route */}
|
||||
<Route path="/" element={<HomeLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="downloader" element={<Downloader />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<PrivateRoute />}>
|
||||
{/* Protected routes go here */}
|
||||
<Route path="/" element={<HomeLayout />} >
|
||||
<Route path="protected-downloader" element={<Downloader />} />
|
||||
</Route>
|
||||
|
||||
</Route>
|
||||
|
||||
</UserContextProvider>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
import axios from "axios";
|
||||
|
||||
// --- ENV CONFIG ---
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";
|
||||
|
||||
const LOGIN_PATH = import.meta.env.VITE_LOGIN_PATH || "/login";
|
||||
|
||||
|
||||
// --- ERROR EVENT BUS ---
|
||||
const ERROR_EVENT = "api:error";
|
||||
type ApiErrorDetail = {
|
||||
message: string;
|
||||
status?: number;
|
||||
url?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
// Use interface instead of arrow function types for readability
|
||||
interface ApiErrorHandler {
|
||||
(e: CustomEvent<ApiErrorDetail>): void;
|
||||
}
|
||||
|
||||
function notifyError(detail: ApiErrorDetail) {
|
||||
window.dispatchEvent(new CustomEvent<ApiErrorDetail>(ERROR_EVENT, { detail }));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[API ERROR]", detail);
|
||||
}
|
||||
function onError(handler: ApiErrorHandler) {
|
||||
const wrapped = handler as EventListener;
|
||||
window.addEventListener(ERROR_EVENT, wrapped as EventListener);
|
||||
|
||||
return () => window.removeEventListener(ERROR_EVENT, wrapped);
|
||||
}
|
||||
|
||||
// --- AXIOS INSTANCES ---
|
||||
// Always send cookies. Django will set auth cookies; browser will include them automatically.
|
||||
function createAxios(baseURL: string): any {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true, // cookies
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 20000,
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Use a single behavior for both: cookies are always sent
|
||||
const apiPublic = createAxios(API_BASE_URL);
|
||||
const apiAuth = createAxios(API_BASE_URL);
|
||||
|
||||
|
||||
// --- REQUEST INTERCEPTOR (PUBLIC) ---
|
||||
// Ensure no Authorization header is ever sent by the public client
|
||||
apiPublic.interceptors.request.use(function (config: any) {
|
||||
if (config?.headers && (config.headers as any).Authorization) {
|
||||
delete (config.headers as any).Authorization;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- REQUEST INTERCEPTOR (AUTH) ---
|
||||
// Do not attach Authorization header; rely on cookies set by Django.
|
||||
apiAuth.interceptors.request.use(function (config: any) {
|
||||
(config as any)._retryCount = (config as any)._retryCount || 0;
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// --- RESPONSE INTERCEPTOR (AUTH) ---
|
||||
// Simplified: on 401, redirect to login. Server manages refresh via cookies.
|
||||
apiAuth.interceptors.response.use(
|
||||
function (response: any) {
|
||||
return response;
|
||||
},
|
||||
async function (error: any) {
|
||||
if (!error.response) {
|
||||
alert("Backend connection is unavailable. Please check your network.");
|
||||
|
||||
notifyError({
|
||||
message: "Network error or backend unavailable",
|
||||
url: error.config?.url,
|
||||
});
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
if (status === 401) {
|
||||
ClearTokens();
|
||||
window.location.assign(LOGIN_PATH);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
notifyError({
|
||||
message:
|
||||
(error.response.data as any)?.detail ||
|
||||
(error.response.data as any)?.message ||
|
||||
`Request failed with status ${status}`,
|
||||
status,
|
||||
url: error.config?.url,
|
||||
data: error.response.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// --- PUBLIC CLIENT: still emits errors and alerts on network failure ---
|
||||
apiPublic.interceptors.response.use(
|
||||
function (response: any) {
|
||||
return response;
|
||||
},
|
||||
async function (error: any) {
|
||||
if (!error.response) {
|
||||
alert("Backend connection is unavailable. Please check your network.");
|
||||
notifyError({
|
||||
message: "Network error or backend unavailable",
|
||||
url: error.config?.url,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
notifyError({
|
||||
message:
|
||||
(error.response.data as any)?.detail ||
|
||||
(error.response.data as any)?.message ||
|
||||
`Request failed with status ${error.response.status}`,
|
||||
status: error.response.status,
|
||||
url: error.config?.url,
|
||||
data: error.response.data,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
function Logout() {
|
||||
try {
|
||||
const LogOutResponse = apiAuth.post("/api/logout/");
|
||||
|
||||
if (LogOutResponse.body.detail != "Logout successful") {
|
||||
throw new Error("Logout failed");
|
||||
}
|
||||
|
||||
ClearTokens();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function ClearTokens(){
|
||||
document.cookie = "access_token=; Max-Age=0; path=/";
|
||||
document.cookie = "refresh_token=; Max-Age=0; path=/";
|
||||
}
|
||||
|
||||
|
||||
// --- EXPORT DEFAULT API WRAPPER ---
|
||||
const Client = {
|
||||
// Axios instances
|
||||
auth: apiAuth,
|
||||
public: apiPublic,
|
||||
|
||||
Logout,
|
||||
|
||||
// Error subscription
|
||||
onError,
|
||||
};
|
||||
|
||||
export default Client;
|
||||
|
||||
/**
|
||||
USAGE EXAMPLES (TypeScript/React)
|
||||
|
||||
Import the client
|
||||
--------------------------------------------------
|
||||
import Client from "@/api/Client";
|
||||
|
||||
|
||||
Login: obtain tokens and persist to cookies
|
||||
--------------------------------------------------
|
||||
async function login(username: string, password: string) {
|
||||
// SimpleJWT default login endpoint (adjust if your backend differs)
|
||||
// Example backend endpoint: POST /api/token/ -> { access, refresh }
|
||||
const res = await Client.public.post("/api/token/", { username, password });
|
||||
const { access, refresh } = res.data;
|
||||
Client.setTokens(access, refresh);
|
||||
// After this, Client.auth will automatically attach Authorization header
|
||||
// and refresh when receiving a 401 (up to 2 retries).
|
||||
}
|
||||
|
||||
|
||||
Public request (no cookies, no Authorization)
|
||||
--------------------------------------------------
|
||||
// The public client does NOT send cookies or Authorization.
|
||||
async function listPublicItems() {
|
||||
const res = await Client.public.get("/api/public/items/");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
Authenticated requests (auto Bearer header + refresh on 401)
|
||||
--------------------------------------------------
|
||||
async function fetchProfile() {
|
||||
const res = await Client.auth.get("/api/users/me/");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function updateProfile(payload: { first_name?: string; last_name?: string }) {
|
||||
const res = await Client.auth.patch("/api/users/me/", payload);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
Global error handling (UI notifications)
|
||||
--------------------------------------------------
|
||||
import { useEffect } from "react";
|
||||
|
||||
function useApiErrors(showToast: (msg: string) => void) {
|
||||
useEffect(function () {
|
||||
const unsubscribe = Client.onError(function (e) {
|
||||
const { message, status } = e.detail;
|
||||
showToast(status ? String(status) + ": " + message : message);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [showToast]);
|
||||
}
|
||||
|
||||
// Note: Network connectivity issues trigger an alert and also dispatch api:error.
|
||||
// All errors are logged to console for developers.
|
||||
|
||||
|
||||
Logout
|
||||
--------------------------------------------------
|
||||
function logout() {
|
||||
Client.clearTokens();
|
||||
window.location.assign("/login");
|
||||
}
|
||||
|
||||
|
||||
Route protection (PrivateRoute)
|
||||
--------------------------------------------------
|
||||
// If you created src/routes/PrivateRoute.tsx, wrap your protected routes with it.
|
||||
// PrivateRoute checks for "access_token" cookie presence and redirects to /login if missing.
|
||||
|
||||
// Example:
|
||||
// <Routes>
|
||||
// <Route element={<PrivateRoute />} >
|
||||
// <Route element={<MainLayout />}>
|
||||
// <Route path="/" element={<Dashboard />} />
|
||||
// <Route path="/profile" element={<Profile />} />
|
||||
// </Route>
|
||||
// </Route>
|
||||
// <Route path="/login" element={<Login />} />
|
||||
// </Routes>
|
||||
|
||||
|
||||
Refresh and retry flow (what happens on 401)
|
||||
--------------------------------------------------
|
||||
// 1) Client.auth request receives 401 from backend
|
||||
// 2) Client tries to refresh access token using refresh_token cookie
|
||||
// 3) If refresh succeeds, original request is retried (max 2 times)
|
||||
// 4) If still 401 (or no refresh token), tokens are cleared and user is redirected to /login
|
||||
|
||||
|
||||
Environment variables (optional overrides)
|
||||
--------------------------------------------------
|
||||
// VITE_API_BASE_URL default: "http://localhost:8000"
|
||||
// VITE_API_REFRESH_URL default: "/api/token/refresh/"
|
||||
// VITE_LOGIN_PATH default: "/login"
|
||||
*/
|
||||
@@ -1,114 +0,0 @@
|
||||
import Client from "../Client";
|
||||
|
||||
// Available output containers (must match backend)
|
||||
export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const;
|
||||
export type FormatExt = (typeof FORMAT_EXTS)[number];
|
||||
|
||||
export type InfoResponse = {
|
||||
title: string | null;
|
||||
duration: number | null;
|
||||
thumbnail: string | null;
|
||||
video_resolutions: string[]; // e.g. ["2160p", "1440p", "1080p", ...]
|
||||
audio_resolutions: string[]; // e.g. ["320kbps", "160kbps", ...]
|
||||
};
|
||||
|
||||
// GET info for a URL
|
||||
export async function fetchInfo(url: string): Promise<InfoResponse> {
|
||||
const res = await Client.public.get("/api/downloader/download/", {
|
||||
params: { url },
|
||||
});
|
||||
return res.data as InfoResponse;
|
||||
}
|
||||
|
||||
// POST to stream binary immediately; returns { blob, filename }
|
||||
export async function downloadImmediate(args: {
|
||||
url: string;
|
||||
ext: FormatExt;
|
||||
videoResolution?: string | number; // "1080p" or 1080
|
||||
audioResolution?: string | number; // "160kbps" or 160
|
||||
}): Promise<{ blob: Blob; filename: string }> {
|
||||
const video_quality = toHeight(args.videoResolution);
|
||||
const audio_quality = toKbps(args.audioResolution);
|
||||
|
||||
if (video_quality == null || audio_quality == null) {
|
||||
throw new Error("Please select both video and audio quality.");
|
||||
}
|
||||
|
||||
const res = await Client.public.post(
|
||||
"/api/downloader/download/",
|
||||
{
|
||||
url: args.url,
|
||||
ext: args.ext,
|
||||
video_quality,
|
||||
audio_quality,
|
||||
},
|
||||
{ responseType: "blob" }
|
||||
);
|
||||
|
||||
const cd = res.headers?.["content-disposition"] as string | undefined;
|
||||
const xfn = res.headers?.["x-filename"] as string | undefined;
|
||||
const filename =
|
||||
parseContentDispositionFilename(cd) ||
|
||||
(xfn && xfn.trim()) ||
|
||||
inferFilenameFromUrl(args.url, res.headers?.["content-type"] as string | undefined) ||
|
||||
`download.${args.ext}`;
|
||||
|
||||
return { blob: res.data as Blob, filename };
|
||||
}
|
||||
|
||||
// Helpers
|
||||
export function parseContentDispositionFilename(cd?: string): string | null {
|
||||
if (!cd) return null;
|
||||
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
||||
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
|
||||
return plainMatch?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function inferFilenameFromUrl(url: string, contentType?: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const last = u.pathname.split("/").filter(Boolean).pop();
|
||||
if (last) return last;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (contentType) {
|
||||
const ext = contentTypeToExt(contentType);
|
||||
return `download${ext ? `.${ext}` : ""}`;
|
||||
}
|
||||
return "download.bin";
|
||||
}
|
||||
|
||||
function contentTypeToExt(ct?: string): string | null {
|
||||
if (!ct) return null;
|
||||
const map: Record<string, string> = {
|
||||
"video/mp4": "mp4",
|
||||
"video/x-matroska": "mkv",
|
||||
"video/webm": "webm",
|
||||
"video/x-flv": "flv",
|
||||
"video/quicktime": "mov",
|
||||
"video/x-msvideo": "avi",
|
||||
"video/ogg": "ogg",
|
||||
"application/octet-stream": "bin",
|
||||
};
|
||||
return map[ct] || null;
|
||||
}
|
||||
|
||||
function toHeight(v?: string | number): number | undefined {
|
||||
if (typeof v === "number") return v || undefined;
|
||||
if (!v) return undefined;
|
||||
const m = /^(\d{2,4})p$/i.exec(v.trim());
|
||||
if (m) return parseInt(m[1], 10);
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? (n as number) : undefined;
|
||||
}
|
||||
|
||||
function toKbps(v?: string | number): number | undefined {
|
||||
if (typeof v === "number") return v || undefined;
|
||||
if (!v) return undefined;
|
||||
const m = /^(\d{2,4})\s*kbps$/i.exec(v.trim());
|
||||
if (m) return parseInt(m[1], 10);
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? (n as number) : undefined;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import Client from "./Client";
|
||||
|
||||
/**
|
||||
* Loads enum values from an OpenAPI schema for a given path, method, and field (e.g., category).
|
||||
*
|
||||
* @param path - API path, e.g., "/api/service-tickets/"
|
||||
* @param method - HTTP method
|
||||
* @param field - field name in parameters or request
|
||||
* @param schemaUrl - URL of the JSON schema, default "/api/schema/?format=json"
|
||||
* @returns Promise<Array<{ value: string; label: string }>>
|
||||
*/
|
||||
export async function fetchEnumFromSchemaJson(
|
||||
path: string,
|
||||
method: "get" | "post" | "patch" | "put",
|
||||
field: string,
|
||||
schemaUrl: string = "/schema/?format=json"
|
||||
): Promise<Array<{ value: string; label: string }>> {
|
||||
try {
|
||||
const schema = await Client.public.get(schemaUrl);
|
||||
|
||||
const methodDef = schema.paths?.[path]?.[method];
|
||||
if (!methodDef) {
|
||||
throw new Error(`Method ${method.toUpperCase()} for ${path} not found in schema.`);
|
||||
}
|
||||
|
||||
// Search in "parameters" (e.g., GET query parameters)
|
||||
const param = methodDef.parameters?.find((p: any) => p.name === field);
|
||||
|
||||
if (param?.schema?.enum) {
|
||||
return param.schema.enum.map((val: string) => ({
|
||||
value: val,
|
||||
label: val,
|
||||
}));
|
||||
}
|
||||
|
||||
throw new Error(`Field '${field}' does not contain enum`);
|
||||
} catch (error) {
|
||||
console.error("Error loading enum values:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// frontend/src/api/model/user.js
|
||||
// User API model for searching users by username
|
||||
// Structure matches other model files (see order.js for reference)
|
||||
|
||||
import Client from '../Client';
|
||||
|
||||
const API_BASE_URL = "/account/users";
|
||||
|
||||
const userAPI = {
|
||||
/**
|
||||
* Get current authenticated user
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
async getCurrentUser() {
|
||||
const response = await Client.auth.get(`${API_BASE_URL}/me/`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all users
|
||||
* @returns {Promise<Array<User>>}
|
||||
*/
|
||||
async getUsers(params: Object) {
|
||||
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single user by ID
|
||||
* @param {number|string} id
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
async getUser(id: number) {
|
||||
const response = await Client.auth.get(`${API_BASE_URL}/${id}/`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a user by ID
|
||||
* @param {number|string} id
|
||||
* @param {Object} data
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
async updateUser(id: number, data: Object) {
|
||||
const response = await Client.auth.patch(`${API_BASE_URL}/${id}/`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a user by ID
|
||||
* @param {number|string} id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteUser(id: number) {
|
||||
const response = await Client.auth.delete(`${API_BASE_URL}/${id}/`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
* @param {Object} data
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
async createUser(data: Object) {
|
||||
const response = await Client.auth.post(`${API_BASE_URL}/`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Search users by username (partial match)
|
||||
* @param {Object} params - { username: string }
|
||||
* @returns {Promise<Array<User>>}
|
||||
*/
|
||||
async searchUsers(params: { username: string }) {
|
||||
// Adjust the endpoint as needed for your backend
|
||||
const response = await Client.auth.get(`${API_BASE_URL}/`, { params });
|
||||
console.log("User search response:", response.data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default userAPI;
|
||||
@@ -1,40 +0,0 @@
|
||||
footer a{
|
||||
color: var(--c-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
footer a i{
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
footer a:hover i{
|
||||
color: var(--c-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
footer{
|
||||
font-family: "Roboto Mono", monospace;
|
||||
|
||||
background-color: var(--c-boxes);
|
||||
|
||||
margin-top: 2em;
|
||||
display: flex;
|
||||
|
||||
color: white;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
footer address{
|
||||
padding: 1em;
|
||||
font-style: normal;
|
||||
}
|
||||
footer .contacts{
|
||||
font-size: 2em;
|
||||
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 990px){
|
||||
footer{
|
||||
flex-direction: column;
|
||||
padding-bottom: 1em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import styles from "./footer.module.css"
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer id="contacts">
|
||||
<div>
|
||||
<h1>vontor.cz</h1>
|
||||
</div>
|
||||
|
||||
<address>
|
||||
Written by <b>David Bruno Vontor | © 2025</b>
|
||||
<br />
|
||||
<p>
|
||||
Tel.:{" "}
|
||||
<a href="tel:+420605512624">
|
||||
<u>+420 605 512 624</u>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
E-mail:{" "}
|
||||
<a href="mailto:brunovontor@gmail.com">
|
||||
<u>brunovontor@gmail.com</u>
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
IČO:{" "}
|
||||
<a
|
||||
href="https://www.rzp.cz/verejne-udaje/cs/udaje/vyber-subjektu;ico=21613109;"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<u>21613109</u>
|
||||
</a>
|
||||
</p>
|
||||
</address>
|
||||
|
||||
<div className="contacts">
|
||||
<a
|
||||
href="https://github.com/Brunobrno"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fa fa-github"></i>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.instagram.com/brunovontor/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fa fa-instagram"></i>
|
||||
</a>
|
||||
<a
|
||||
href="https://twitter.com/BVontor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fa-brands fa-x-twitter"></i>
|
||||
</a>
|
||||
<a
|
||||
href="https://steamcommunity.com/id/Brunobrno/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fa-brands fa-steam"></i>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/@brunovontor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<i className="fa-brands fa-youtube"></i>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
nav{
|
||||
padding: 1.1em;
|
||||
|
||||
font-family: "Roboto Mono", monospace;
|
||||
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0; /* required */
|
||||
|
||||
transition: top 1s ease-in-out, border-radius 1s ease-in-out;
|
||||
|
||||
|
||||
|
||||
z-index: 5;
|
||||
padding-left: 2em;
|
||||
padding-right: 2em;
|
||||
width: max-content;
|
||||
|
||||
background: var(--c-boxes);
|
||||
/*background: -moz-linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
||||
background: -webkit-linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
||||
background: linear-gradient(117deg, rgba(34,34,34,1) 0%, rgba(59,54,54,1) 100%);
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr="#222222",endColorstr="#3b3636",GradientType=1);*/
|
||||
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
|
||||
margin: auto;
|
||||
|
||||
border-radius: 2em;
|
||||
}
|
||||
nav.isSticky-nav{
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
nav ul #nav-logo{
|
||||
border-right: 0.2em solid var(--c-lines);
|
||||
}
|
||||
/* Add class alias for logo used in TSX */
|
||||
.logo {
|
||||
border-right: 0.2em solid var(--c-lines);
|
||||
}
|
||||
nav ul #nav-logo span{
|
||||
line-height: 0.75;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
nav a{
|
||||
color: #fff;
|
||||
transition: color 1s;
|
||||
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
}
|
||||
nav a:hover{
|
||||
color: #fff;
|
||||
}
|
||||
/* Unify link/summary layout to prevent distortion */
|
||||
nav a,
|
||||
nav summary {
|
||||
color: #fff;
|
||||
transition: color 1s;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
display: inline-block; /* ensure consistent inline sizing */
|
||||
vertical-align: middle; /* align with neighbors */
|
||||
padding: 0; /* keep padding controlled by li */
|
||||
}
|
||||
|
||||
nav a::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: #fff;
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
nav a:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
nav summary:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* underline effect shared for links and summary */
|
||||
nav a::before,
|
||||
nav summary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: #fff;
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
nav a:hover::before,
|
||||
nav summary:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
/* Submenu support */
|
||||
.hasSubmenu {
|
||||
position: relative;
|
||||
vertical-align: middle; /* align with other inline items */
|
||||
}
|
||||
|
||||
/* Keep details inline to avoid breaking the first row flow */
|
||||
.hasSubmenu details {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Ensure "Services" and caret stay on the same line */
|
||||
.hasSubmenu details > summary {
|
||||
display: inline-flex; /* horizontal layout */
|
||||
align-items: center; /* vertical alignment */
|
||||
gap: 0.5em; /* space between text and icon */
|
||||
white-space: nowrap; /* prevent wrapping */
|
||||
}
|
||||
|
||||
/* Hide native disclosure icon/marker on summary */
|
||||
.hasSubmenu details > summary {
|
||||
list-style: none;
|
||||
outline: none;
|
||||
}
|
||||
.hasSubmenu details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.hasSubmenu details > summary::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Reusable caret for submenu triggers */
|
||||
.caret {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Rotate caret when submenu is open */
|
||||
.hasSubmenu details[open] .caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Submenu box: place directly under nav with a tiny gap (no overlap) */
|
||||
.submenu {
|
||||
list-style: none;
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 0.25em);
|
||||
display: none;
|
||||
background: var(--c-background-light);
|
||||
border: 1px solid var(--c-lines);
|
||||
border-radius: 0.75em;
|
||||
min-width: max-content;
|
||||
text-align: left;
|
||||
z-index: 10;
|
||||
}
|
||||
.submenu li {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
.submenu a {
|
||||
display: inline-block;
|
||||
padding: 0; /* remove padding so underline equals text width */
|
||||
margin: 0.35em 0; /* spacing without affecting underline width */
|
||||
}
|
||||
|
||||
/* Show submenu when open */
|
||||
.hasSubmenu details[open] .submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Hamburger toggle class (used by TSX) */
|
||||
.toggle {
|
||||
display: none;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
.toggleRotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Bridge TSX classnames to existing rules */
|
||||
.navList {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.navList li {
|
||||
display: inline;
|
||||
padding: 0 3em;
|
||||
}
|
||||
.navList li a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline;
|
||||
padding: 0 3em;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
text-decoration: none;
|
||||
}
|
||||
#toggle-nav{
|
||||
display: none;
|
||||
|
||||
-webkit-transition: transform 0.5s ease;
|
||||
-moz-transition: transform 0.5s ease;
|
||||
-o-transition: transform 0.5s ease;
|
||||
-ms-transition: transform 0.5s ease;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
.toggle-nav-rotated {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
.nav-open{
|
||||
max-height: 20em;
|
||||
}
|
||||
@media only screen and (max-width: 990px){
|
||||
#toggle-nav{
|
||||
margin-top: 0.25em;
|
||||
margin-left: 0.75em;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: block;
|
||||
font-size: 2em;
|
||||
}
|
||||
nav{
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 1em;
|
||||
border-bottom-right-radius: 1em;
|
||||
overflow: hidden;
|
||||
}
|
||||
nav ul {
|
||||
margin-top: 1em;
|
||||
gap: 2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
-webkit-transition: max-height 1s ease;
|
||||
-moz-transition: max-height 1s ease;
|
||||
-o-transition: max-height 1s ease;
|
||||
-ms-transition: max-height 1s ease;
|
||||
transition: max-height 1s ease;
|
||||
|
||||
max-height: 2em;
|
||||
}
|
||||
/* When TSX adds styles.open to the UL, expand it */
|
||||
.open {
|
||||
max-height: 20em;
|
||||
}
|
||||
|
||||
nav ul:last-child{
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
nav ul #nav-logo {
|
||||
margin: auto;
|
||||
padding-bottom: 0.5em;
|
||||
margin-bottom: -1em;
|
||||
border-bottom: 0.2em solid var(--c-lines);
|
||||
border-right: none;
|
||||
}
|
||||
/* Show hamburger on mobile */
|
||||
.toggle {
|
||||
margin-top: 0.25em;
|
||||
margin-left: 0.75em;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
display: block;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
/* Submenu stacks inline under the parent item on mobile */
|
||||
.submenu {
|
||||
position: static;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0 0 0.5em 0.5em;
|
||||
min-width: unset;
|
||||
}
|
||||
.submenu a {
|
||||
display: inline-block;
|
||||
padding: 0; /* keep no padding on mobile too */
|
||||
margin: 0.25em 0.5em; /* spacing via margin */
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, { useState, useContext } from "react"
|
||||
import styles from "./HomeNav.module.css"
|
||||
import { FaBars, FaChevronDown } from "react-icons/fa";
|
||||
|
||||
import { UserContext } from "../../context/UserContext";
|
||||
|
||||
export default function HomeNav() {
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
|
||||
const toggleNav = () => setNavOpen((prev) => !prev)
|
||||
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<nav className={styles.nav}>
|
||||
<FaBars
|
||||
className={`${styles.toggle} ${navOpen ? styles.toggleRotated : ""}`}
|
||||
onClick={toggleNav}
|
||||
aria-label="Toggle navigation"
|
||||
aria-expanded={navOpen}
|
||||
/>
|
||||
|
||||
<ul className={`${styles.navList} ${navOpen ? styles.open : ""}`}>
|
||||
<li id="nav-logo" className={styles.logo}>
|
||||
<span>vontor.cz</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/">Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#portfolio">Portfolio</a>
|
||||
</li>
|
||||
<li className={styles.hasSubmenu}>
|
||||
<details>
|
||||
<summary>
|
||||
Services
|
||||
<FaChevronDown className={`${styles.caret} ml-2 inline-block`} aria-hidden="true" />
|
||||
</summary>
|
||||
<ul className={styles.submenu}>
|
||||
<li><a href="#web">Web development</a></li>
|
||||
<li><a href="#integration">Integrations</a></li>
|
||||
<li><a href="#support">Support</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#contactme-form">Contact me</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
# EXAMPLE USAGE OF CONTEXT IN A COMPONENT:
|
||||
|
||||
## Wrap your app tree with the provider (e.g., in App.tsx)
|
||||
|
||||
```tsx
|
||||
import { UserContextProvider } from "../context/UserContext";
|
||||
function App() {
|
||||
return (
|
||||
<UserContextProvider>
|
||||
<YourRoutes />
|
||||
</UserContextProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Consume in any child component
|
||||
```tsx
|
||||
import React, { useContext } from "react"
|
||||
import { UserContext } from '../context/UserContext';
|
||||
|
||||
export default function ExampleComponent() {
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
|
||||
|
||||
return ...;
|
||||
}
|
||||
```
|
||||
@@ -1,75 +0,0 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||
|
||||
import userAPI from '../api/models/User';
|
||||
|
||||
// definice uživatele
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// určíme typ kontextu
|
||||
interface GlobalContextType {
|
||||
user: User | null;
|
||||
setUser: React.Dispatch<React.SetStateAction<User | null>>;
|
||||
}
|
||||
|
||||
// vytvoříme a exportneme kontext !!!
|
||||
export const UserContext = createContext<GlobalContextType | null>(null);
|
||||
|
||||
|
||||
// hook pro použití kontextu
|
||||
// zabal routy do téhle komponenty!!!
|
||||
export const UserContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const currentUser = await userAPI.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
EXAMPLE USAGE OF CONTEXT IN A COMPONENT:
|
||||
|
||||
// Wrap your app tree with the provider (e.g., in App.tsx)
|
||||
// import { UserContextProvider } from "../context/UserContext";
|
||||
// function App() {
|
||||
// return (
|
||||
// <UserContextProvider>
|
||||
// <YourRoutes />
|
||||
// </UserContextProvider>
|
||||
// );
|
||||
// }
|
||||
|
||||
// Consume in any child component
|
||||
import React, { useContext } from "react"
|
||||
import { UserContext } from '../context/UserContext';
|
||||
|
||||
export default function ExampleComponent() {
|
||||
const { user, setUser } = useContext(UserContext);
|
||||
|
||||
|
||||
return ...;
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -1,67 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--c-background: #031D44; /*background*/
|
||||
--c-background-light: #04395E; /*background-highlight*/
|
||||
--c-boxes: #24719f;; /*boxes*/
|
||||
--c-lines: #87a9da; /*lines*/
|
||||
--c-text: #CAF0F8; /*text*/
|
||||
--c-other: #70A288; /*other*/
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Footer from "../components/Footer/footer";
|
||||
import ContactMeForm from "../components/Forms/ContactMe/ContactMeForm";
|
||||
import HomeNav from "../components/navbar/HomeNav";
|
||||
import Drone from "../components/ads/Drone/Drone";
|
||||
import Portfolio from "../components/ads/Portfolio/Portfolio";
|
||||
import Home from "../pages/home/home";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function HomeLayout(){
|
||||
return(
|
||||
<>
|
||||
{/* Example usage of imported components, adjust as needed */}
|
||||
|
||||
<HomeNav />
|
||||
|
||||
<Home /> {/*page*/}
|
||||
<div style={{margin: "10em 0"}}>
|
||||
<Drone />
|
||||
</div>
|
||||
<Outlet />
|
||||
<Portfolio />
|
||||
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
||||
<ContactMeForm />
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import Footer from "../components/Footer/footer";
|
||||
import ContactMeForm from "../components/Forms/ContactMe/ContactMeForm";
|
||||
import HomeNav from "../components/navbar/HomeNav";
|
||||
import Drone from "../components/ads/Drone/Drone";
|
||||
import Portfolio from "../components/ads/Portfolio/Portfolio";
|
||||
import Home from "../pages/home/home";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function HomeLayout(){
|
||||
return(
|
||||
<>
|
||||
{/* Example usage of imported components, adjust as needed */}
|
||||
|
||||
<HomeNav />
|
||||
|
||||
<Home /> {/*page*/}
|
||||
<div style={{margin: "10em 0"}}>
|
||||
<Drone />
|
||||
</div>
|
||||
<Outlet />
|
||||
<Portfolio />
|
||||
<div style={{ margin: "6em auto", marginTop: "15em", maxWidth: "80vw" }}>
|
||||
<ContactMeForm />
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,206 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
fetchInfo,
|
||||
downloadImmediate,
|
||||
FORMAT_EXTS,
|
||||
type InfoResponse,
|
||||
parseContentDispositionFilename,
|
||||
} from "../../api/apps/Downloader";
|
||||
|
||||
export default function Downloader() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [probing, setProbing] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<InfoResponse | null>(null);
|
||||
|
||||
const [ext, setExt] = useState<typeof FORMAT_EXTS[number]>("mp4");
|
||||
const [videoRes, setVideoRes] = useState<string | undefined>(undefined);
|
||||
const [audioRes, setAudioRes] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (info?.video_resolutions?.length && !videoRes) {
|
||||
setVideoRes(info.video_resolutions[0]);
|
||||
}
|
||||
if (info?.audio_resolutions?.length && !audioRes) {
|
||||
setAudioRes(info.audio_resolutions[0]);
|
||||
}
|
||||
}, [info]);
|
||||
|
||||
async function onProbe(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setProbing(true);
|
||||
try {
|
||||
const res = await fetchInfo(url);
|
||||
setInfo(res);
|
||||
// reset selections from fresh info
|
||||
setVideoRes(res.video_resolutions?.[0]);
|
||||
setAudioRes(res.audio_resolutions?.[0]);
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
e?.response?.data?.error ||
|
||||
e?.response?.data?.detail ||
|
||||
e?.message ||
|
||||
"Failed to get info."
|
||||
);
|
||||
} finally {
|
||||
setProbing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDownload() {
|
||||
setError(null);
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { blob, filename } = await downloadImmediate({
|
||||
url,
|
||||
ext,
|
||||
videoResolution: videoRes,
|
||||
audioResolution: audioRes,
|
||||
});
|
||||
const name = filename || parseContentDispositionFilename("") || `download.${ext}`;
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = href;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(href);
|
||||
} catch (e: any) {
|
||||
setError(
|
||||
e?.response?.data?.error ||
|
||||
e?.response?.data?.detail ||
|
||||
e?.message ||
|
||||
"Download failed."
|
||||
);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canDownload = useMemo(
|
||||
() => !!url && !!ext && !!videoRes && !!audioRes,
|
||||
[url, ext, videoRes, audioRes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto p-4 space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Downloader</h1>
|
||||
|
||||
{error && <div className="rounded border border-red-300 bg-red-50 text-red-700 p-2">{error}</div>}
|
||||
|
||||
<form onSubmit={onProbe} className="grid gap-3">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm font-medium">URL</span>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/video"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="w-full border rounded p-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!url || probing}
|
||||
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{probing ? "Probing..." : "Get info"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
disabled={!canDownload || downloading}
|
||||
className="px-3 py-2 rounded bg-emerald-600 text-white disabled:opacity-50"
|
||||
>
|
||||
{downloading ? "Downloading..." : "Download"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{info && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{info.thumbnail && (
|
||||
<img
|
||||
src={info.thumbnail}
|
||||
alt={info.title || "thumbnail"}
|
||||
className="w-40 h-24 object-cover rounded border"
|
||||
/>
|
||||
)}
|
||||
<div className="text-sm text-gray-800 space-y-1">
|
||||
<div>
|
||||
<span className="font-medium">Title:</span> {info.title || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Duration:</span>{" "}
|
||||
{info.duration ? `${Math.round(info.duration)} s` : "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm font-medium">Container</span>
|
||||
<select
|
||||
value={ext}
|
||||
onChange={(e) => setExt(e.target.value as any)}
|
||||
className="border rounded p-2"
|
||||
>
|
||||
{FORMAT_EXTS.map((x) => (
|
||||
<option key={x} value={x}>
|
||||
{x.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm font-medium">Video resolution</span>
|
||||
<select
|
||||
value={videoRes || ""}
|
||||
onChange={(e) => setVideoRes(e.target.value || undefined)}
|
||||
className="border rounded p-2"
|
||||
>
|
||||
{info.video_resolutions?.length ? (
|
||||
info.video_resolutions.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">-</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm font-medium">Audio bitrate</span>
|
||||
<select
|
||||
value={audioRes || ""}
|
||||
onChange={(e) => setAudioRes(e.target.value || undefined)}
|
||||
className="border rounded p-2"
|
||||
>
|
||||
{info.audio_resolutions?.length ? (
|
||||
info.audio_resolutions.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">-</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Doto:wght@300&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap');
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Exo:ital,wght@0,100..900;1,100..900&display=swap');
|
||||
|
||||
:root{
|
||||
--c-background: #031D44; /*background*/
|
||||
--c-background-light: #04395E; /*background-highlight*/
|
||||
--c-boxes: #24719f;; /*boxes*/
|
||||
--c-lines: #87a9da; /*lines*/
|
||||
--c-text: #CAF0F8; /*text*/
|
||||
--c-other: #70A288; /*other*/
|
||||
}
|
||||
|
||||
html{
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body{
|
||||
font-family: "Exo", serif;
|
||||
|
||||
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.doto-font{
|
||||
font-family: "Doto", serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-variation-settings: "ROND" 0;
|
||||
}
|
||||
.bebas-neue-regular {
|
||||
font-family: "Bebas Neue", sans-serif;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
.introduction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--c-text);
|
||||
|
||||
padding-bottom: 10em;
|
||||
margin-top: 6em;
|
||||
|
||||
width: 100%;
|
||||
position: relative;
|
||||
top:0;
|
||||
|
||||
/* gap: 4em;*/
|
||||
}
|
||||
|
||||
.introduction h1{
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.introduction article {
|
||||
/*background-color: cadetblue;*/
|
||||
|
||||
padding: 2em;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.introduction article header {}
|
||||
|
||||
.introduction article:nth-child(1) {
|
||||
width: 100%;
|
||||
/* transform: rotate(5deg); */
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
}
|
||||
/*
|
||||
.introduction article:nth-child(2) {
|
||||
width: 50%;
|
||||
transform: rotate(3deg);
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.introduction article:nth-child(3) {
|
||||
width: 50%;
|
||||
transform: rotate(-2deg);
|
||||
align-self: flex-start;
|
||||
}*/
|
||||
|
||||
|
||||
|
||||
|
||||
.animation-introduction{
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.animation-introduction ul{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/*overflow: hidden; ZAPNOUT KDYŽ NECHCEŠ ANIMACI PŘECHÁZET DO OSTATNÍCH DIVŮ*/
|
||||
}
|
||||
|
||||
.animation-introduction ul li{
|
||||
position: absolute;
|
||||
display: block;
|
||||
list-style: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 35%);
|
||||
animation: animate 4s linear infinite;
|
||||
bottom: -150px;
|
||||
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(1){
|
||||
left: 25%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
|
||||
.animation-introduction ul li:nth-child(2){
|
||||
left: 10%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation-delay: 2s;
|
||||
animation-duration: 12s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(3){
|
||||
left: 70%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(4){
|
||||
left: 40%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 18s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(5){
|
||||
left: 65%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(6){
|
||||
left: 75%;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
animation-delay: 3s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(7){
|
||||
left: 35%;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
animation-delay: 7s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(8){
|
||||
left: 50%;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
animation-delay: 15s;
|
||||
animation-duration: 45s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(9){
|
||||
left: 20%;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
animation-delay: 2s;
|
||||
animation-duration: 35s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(10){
|
||||
left: 85%;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 11s;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes animate {
|
||||
|
||||
0%{
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
100%{
|
||||
transform: translateY(-1000px) rotate(720deg);
|
||||
opacity: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 990px) {
|
||||
.animation-introduction ul li:nth-child(6){
|
||||
left: 67%;
|
||||
}
|
||||
.animation-introduction ul li:nth-child(10) {
|
||||
left: 60%;
|
||||
}
|
||||
.introduction {
|
||||
margin: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.introduction article {
|
||||
width: auto !important;
|
||||
transform: none !important;
|
||||
align-self: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import React, { useEffect } from "react"
|
||||
import styles from "./Home.module.css"
|
||||
|
||||
|
||||
|
||||
export default function Home() {
|
||||
useEffect(() => {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const randomId = "spark-" + Math.floor(Math.random() * 100000)
|
||||
|
||||
const spark = document.createElement("div")
|
||||
spark.className = "spark-cursor"
|
||||
spark.id = randomId
|
||||
document.body.appendChild(spark)
|
||||
|
||||
// pozice a barva
|
||||
spark.style.top = `${event.pageY}px`
|
||||
spark.style.left = `${event.pageX}px`
|
||||
spark.style.filter = `hue-rotate(${Math.random() * 360}deg)`
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const span = document.createElement("span")
|
||||
span.style.transform = `rotate(${i * 45}deg)`
|
||||
spark.appendChild(span)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
spark.querySelectorAll("span").forEach((s) => {
|
||||
(s as HTMLElement).classList.add("animate")
|
||||
})
|
||||
}, 10)
|
||||
|
||||
setTimeout(() => {
|
||||
spark.remove()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
document.body.addEventListener("click", handleClick)
|
||||
|
||||
// cleanup když komponenta zmizí
|
||||
return () => {
|
||||
document.body.removeEventListener("click", handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
.introduction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--c-text);
|
||||
|
||||
padding-bottom: 10em;
|
||||
margin-top: 6em;
|
||||
|
||||
width: 100%;
|
||||
position: relative;
|
||||
top:0;
|
||||
|
||||
/* gap: 4em;*/
|
||||
}
|
||||
.introduction h1{
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.introduction article {
|
||||
/*background-color: cadetblue;*/
|
||||
|
||||
padding: 2em;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.introduction article header {}
|
||||
|
||||
.introduction article:nth-child(1) {
|
||||
width: 100%;
|
||||
/* transform: rotate(5deg); */
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
font-size: 2em;
|
||||
}
|
||||
/*
|
||||
.introduction article:nth-child(2) {
|
||||
width: 50%;
|
||||
transform: rotate(3deg);
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.introduction article:nth-child(3) {
|
||||
width: 50%;
|
||||
transform: rotate(-2deg);
|
||||
align-self: flex-start;
|
||||
}*/
|
||||
|
||||
|
||||
|
||||
|
||||
.animation-introduction{
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
.animation-introduction ul{
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/*overflow: hidden; ZAPNOUT KDYŽ NECHCEŠ ANIMACI PŘECHÁZET DO OSTATNÍCH DIVŮ*/
|
||||
}
|
||||
|
||||
.animation-introduction ul li{
|
||||
position: absolute;
|
||||
display: block;
|
||||
list-style: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 35%);
|
||||
animation: animate 4s linear infinite;
|
||||
bottom: -150px;
|
||||
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(1){
|
||||
left: 25%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
|
||||
.animation-introduction ul li:nth-child(2){
|
||||
left: 10%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation-delay: 2s;
|
||||
animation-duration: 12s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(3){
|
||||
left: 70%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(4){
|
||||
left: 40%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 18s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(5){
|
||||
left: 65%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(6){
|
||||
left: 75%;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
animation-delay: 3s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(7){
|
||||
left: 35%;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
animation-delay: 7s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(8){
|
||||
left: 50%;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
animation-delay: 15s;
|
||||
animation-duration: 45s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(9){
|
||||
left: 20%;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
animation-delay: 2s;
|
||||
animation-duration: 35s;
|
||||
}
|
||||
|
||||
.animation-introduction ul li:nth-child(10){
|
||||
left: 85%;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 11s;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes animate {
|
||||
|
||||
0%{
|
||||
transform: translateY(0) rotate(0deg);
|
||||
opacity: 1;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
100%{
|
||||
transform: translateY(-1000px) rotate(720deg);
|
||||
opacity: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 990px) {
|
||||
.animation-introduction ul li:nth-child(6){
|
||||
left: 67%;
|
||||
}
|
||||
.animation-introduction ul li:nth-child(10) {
|
||||
left: 60%;
|
||||
}
|
||||
.introduction {
|
||||
margin: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.introduction article {
|
||||
width: auto !important;
|
||||
transform: none !important;
|
||||
align-self: none !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,22 +0,0 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const nameEQ = name + "=";
|
||||
const ca = document.cookie.split(";").map((c) => c.trim());
|
||||
for (const c of ca) {
|
||||
if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ACCESS_COOKIE = "access_token";
|
||||
|
||||
export default function PrivateRoute() {
|
||||
const location = useLocation();
|
||||
const isLoggedIn = !!getCookie(ACCESS_COOKIE);
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
})
|
||||
@@ -2,7 +2,21 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt update && apt install ffmpeg -y
|
||||
# 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
|
||||
|
||||
@@ -1,6 +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'
|
||||
name = 'account'
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-28 22:28
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
import account.models
|
||||
import django.contrib.auth.validators
|
||||
@@ -30,15 +30,23 @@ class Migration(migrations.Migration):
|
||||
('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', 'Admin'), ('mod', 'Moderator'), ('regular', 'Regular')], default='regular', max_length=20)),
|
||||
('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)),
|
||||
('newsletter', models.BooleanField(default=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')),
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 07:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('account', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='email_verification_sent_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customuser',
|
||||
name='email_verification_token',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=128, null=True),
|
||||
),
|
||||
]
|
||||
@@ -28,32 +28,29 @@ class ActiveUserManager(CustomUserManager):
|
||||
class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
groups = models.ManyToManyField(
|
||||
Group,
|
||||
related_name="customuser_set", # <- přidáš related_name
|
||||
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", # <- přidáš related_name
|
||||
related_name="customuser_set",
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_query_name="customuser",
|
||||
)
|
||||
|
||||
class Role(models.TextChoices):
|
||||
ADMIN = "admin", "Admin"
|
||||
MANAGER = "mod", "Moderator"
|
||||
CUSTOMER = "regular", "Regular"
|
||||
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.")]
|
||||
@@ -66,14 +63,25 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
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,
|
||||
@@ -94,6 +102,7 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
"email"
|
||||
]
|
||||
|
||||
|
||||
# Ensure default manager has get_by_natural_key
|
||||
objects = CustomUserManager()
|
||||
# Optional convenience manager for active users only
|
||||
@@ -157,4 +166,10 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@@ -55,3 +55,23 @@ class AdminOnly(BasePermission):
|
||||
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'
|
||||
|
||||
|
||||
@@ -10,33 +10,25 @@ from .models import CustomUser
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
|
||||
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
|
||||
"""
|
||||
General function to send emails with a specific context.
|
||||
Supports rendering plain text and HTML templates.
|
||||
Converts `user` in context to a plain dict to avoid template access to the model.
|
||||
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_name or html_template_name:
|
||||
# Best effort to resolve both templates if only one provided
|
||||
if not template_name and html_template_name:
|
||||
template_name = html_template_name.replace(".html", ".txt")
|
||||
if not html_template_name and template_name:
|
||||
html_template_name = template_name.replace(".txt", ".html")
|
||||
|
||||
if template_path:
|
||||
ctx = dict(context or {})
|
||||
# Sanitize user if someone passes the model by mistake
|
||||
if "user" in ctx and not isinstance(ctx["user"], dict):
|
||||
try:
|
||||
ctx["user"] = _build_user_template_ctx(ctx["user"])
|
||||
except Exception:
|
||||
ctx["user"] = {}
|
||||
|
||||
message = render_to_string(template_name, ctx)
|
||||
html_message = render_to_string(html_template_name, ctx)
|
||||
# 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(
|
||||
@@ -47,33 +39,13 @@ def send_email_with_context(recipients, subject, message=None, template_name=Non
|
||||
fail_silently=False,
|
||||
html_message=html_message,
|
||||
)
|
||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||
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
|
||||
|
||||
|
||||
def _build_user_template_ctx(user: CustomUser) -> dict:
|
||||
"""
|
||||
Return a plain dict for templates instead of passing the DB model.
|
||||
Provides aliases to avoid template errors (firstname vs first_name).
|
||||
Adds a backward-compatible key 'get_full_name' for templates using `user.get_full_name`.
|
||||
"""
|
||||
first_name = getattr(user, "first_name", "") or ""
|
||||
last_name = getattr(user, "last_name", "") or ""
|
||||
full_name = f"{first_name} {last_name}".strip()
|
||||
return {
|
||||
"id": user.pk,
|
||||
"email": getattr(user, "email", "") or "",
|
||||
"first_name": first_name,
|
||||
"firstname": first_name, # alias for templates using `firstname`
|
||||
"last_name": last_name,
|
||||
"lastname": last_name, # alias for templates using `lastname`
|
||||
"full_name": full_name,
|
||||
"get_full_name": full_name, # compatibility for templates using method-style access
|
||||
}
|
||||
|
||||
|
||||
#----------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -92,7 +64,7 @@ def send_email_verification_task(user_id):
|
||||
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||
|
||||
context = {
|
||||
"user": _build_user_template_ctx(user),
|
||||
"user": user,
|
||||
"action_url": verify_url,
|
||||
"frontend_url": settings.FRONTEND_URL,
|
||||
"cta_label": "Ověřit e‑mail",
|
||||
@@ -101,8 +73,7 @@ def send_email_verification_task(user_id):
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
subject="Ověření e‑mailu",
|
||||
template_name="email/email_verification.txt",
|
||||
html_template_name="email/email_verification.html",
|
||||
template_path="email/email_verification.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
@@ -118,8 +89,7 @@ def send_email_test_task(email):
|
||||
send_email_with_context(
|
||||
recipients=email,
|
||||
subject="Testovací e‑mail",
|
||||
template_name="email/test.txt",
|
||||
html_template_name="email/test.html",
|
||||
template_path="email/test.html",
|
||||
context=context,
|
||||
)
|
||||
|
||||
@@ -137,7 +107,7 @@ def send_password_reset_email_task(user_id):
|
||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
||||
|
||||
context = {
|
||||
"user": _build_user_template_ctx(user),
|
||||
"user": user,
|
||||
"action_url": reset_url,
|
||||
"frontend_url": settings.FRONTEND_URL,
|
||||
"cta_label": "Obnovit heslo",
|
||||
@@ -146,7 +116,6 @@ def send_password_reset_email_task(user_id):
|
||||
send_email_with_context(
|
||||
recipients=user.email,
|
||||
subject="Obnova hesla",
|
||||
template_name="email/password_reset.txt",
|
||||
html_template_name="email/password_reset.html",
|
||||
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>
|
||||
@@ -1,46 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
||||
<tr>
|
||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
||||
Ověření e‑mailu
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td 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 %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
||||
<tr>
|
||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +0,0 @@
|
||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
|
||||
|
||||
Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na následující odkaz:
|
||||
|
||||
{{ action_url }}
|
||||
|
||||
Pokud jste účet nevytvořili vy, tento e‑mail ignorujte.
|
||||
@@ -1,46 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
||||
<tr>
|
||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
||||
Obnova hesla
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td 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 %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
||||
<tr>
|
||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +0,0 @@
|
||||
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
|
||||
|
||||
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.
|
||||
|
||||
Pro nastavení nového hesla použijte tento odkaz:
|
||||
{{ action_url }}
|
||||
@@ -1,44 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="cs">
|
||||
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
||||
<tr>
|
||||
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
||||
Testovací e‑mail
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td 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 %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
||||
<tr>
|
||||
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
||||
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +0,0 @@
|
||||
Dobrý den,
|
||||
|
||||
Toto je testovací e‑mail z aplikace e‑tržnice.
|
||||
|
||||
Odkaz na aplikaci:
|
||||
{{ action_url }}
|
||||
@@ -18,16 +18,28 @@ password_reset_token = PasswordResetTokenGenerator()
|
||||
|
||||
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
|
||||
#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU
|
||||
#COOKIE + AUTHORIZATION HEADER JWT AUTHENTICATION FOR AXIOS COMPATIBILITY
|
||||
class CookieJWTAuthentication(JWTAuthentication):
|
||||
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
|
||||
|
||||
validated_token = self.get_validated_token(raw_token)
|
||||
return self.get_user(validated_token), validated_token
|
||||
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
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ urlpatterns = [
|
||||
# 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'),
|
||||
path('activate/', views.UserActivationViewSet.as_view(), name='activate-user'),
|
||||
|
||||
# Password reset endpoints
|
||||
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
||||
|
||||
@@ -6,7 +6,7 @@ from .serializers import *
|
||||
from .permissions import *
|
||||
from .models import CustomUser
|
||||
from .tokens import *
|
||||
from .tasks import send_password_reset_email_task
|
||||
from .tasks import send_password_reset_email_task, send_email_verification_task
|
||||
from django.conf import settings
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,7 +38,7 @@ from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
|
||||
# Custom Token obtaining view
|
||||
@extend_schema(
|
||||
tags=["Authentication"],
|
||||
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,
|
||||
@@ -107,7 +107,7 @@ class CookieTokenObtainPairView(TokenObtainPairView):
|
||||
return super().validate(attrs)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Authentication"],
|
||||
tags=["account", "public"],
|
||||
summary="Refresh JWT token using cookie",
|
||||
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
||||
responses={
|
||||
@@ -163,7 +163,7 @@ class CookieTokenRefreshView(APIView):
|
||||
#---------------------------------------------LOGOUT------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["Authentication"],
|
||||
tags=["account", "public"],
|
||||
summary="Logout user (delete access and refresh token cookies)",
|
||||
description="Logs out the user by deleting access and refresh token cookies.",
|
||||
responses={
|
||||
@@ -185,7 +185,7 @@ class LogoutView(APIView):
|
||||
#--------------------------------------------------------------------------------------------------------------
|
||||
|
||||
@extend_schema(
|
||||
tags=["User"],
|
||||
tags=["account"],
|
||||
summary="List, retrieve, update, and delete users.",
|
||||
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
||||
responses={
|
||||
@@ -222,6 +222,15 @@ class UserView(viewsets.ModelViewSet):
|
||||
"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']:
|
||||
@@ -241,9 +250,19 @@ class UserView(viewsets.ModelViewSet):
|
||||
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
||||
return [OnlyRolesAllowed("admin")()]
|
||||
|
||||
# Any authenticated user can retrieve (view) any user's profile
|
||||
# Users can only view their own profile, admins can view any profile
|
||||
elif self.action == 'retrieve':
|
||||
return [IsAuthenticated()]
|
||||
user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
|
||||
# Admins can view any user profile
|
||||
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
|
||||
return [IsAuthenticated()]
|
||||
|
||||
# Users can view their own profile
|
||||
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
|
||||
return [IsAuthenticated()]
|
||||
|
||||
# Deny access to other users' profiles
|
||||
return [OnlyRolesAllowed("admin")()]
|
||||
|
||||
return super().get_permissions()
|
||||
|
||||
@@ -251,7 +270,7 @@ class UserView(viewsets.ModelViewSet):
|
||||
|
||||
# Get current user data
|
||||
@extend_schema(
|
||||
tags=["User"],
|
||||
tags=["account"],
|
||||
summary="Get current authenticated user",
|
||||
description="Returns details of the currently authenticated user based on JWT token or session.",
|
||||
responses={
|
||||
@@ -271,7 +290,7 @@ class CurrentUserView(APIView):
|
||||
|
||||
#1. registration API
|
||||
@extend_schema(
|
||||
tags=["User Registration"],
|
||||
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,
|
||||
@@ -303,7 +322,7 @@ class UserRegistrationViewSet(ModelViewSet):
|
||||
|
||||
#2. confirming email
|
||||
@extend_schema(
|
||||
tags=["User Registration"],
|
||||
tags=["account", "public"],
|
||||
summary="Verify user email via link",
|
||||
description="Verify user email using the link with uid and token.",
|
||||
parameters=[
|
||||
@@ -325,45 +344,18 @@ class EmailVerificationView(APIView):
|
||||
|
||||
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 čeká na schválení."})
|
||||
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)
|
||||
|
||||
#3. seller activation API (var_symbol)
|
||||
@extend_schema(
|
||||
tags=["User Registration"],
|
||||
summary="Activate user and set variable symbol (admin/cityClerk only)",
|
||||
description="Activate user and set variable symbol. Only accessible by admin or cityClerk.",
|
||||
request=UserActivationSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(response=UserActivationSerializer, description="User activated successfully."),
|
||||
400: OpenApiResponse(description="Invalid activation data."),
|
||||
404: OpenApiResponse(description="User not found."),
|
||||
},
|
||||
)
|
||||
class UserActivationViewSet(APIView):
|
||||
permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')]
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
serializer = UserActivationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.save()
|
||||
|
||||
try:
|
||||
send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK
|
||||
except Exception as e:
|
||||
logger.error(f"Celery not available, using fallback. Error: {e}")
|
||||
send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol
|
||||
|
||||
return Response(serializer.to_representation(user), status=status.HTTP_200_OK)
|
||||
|
||||
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
|
||||
|
||||
#1. PasswordReset + send Email
|
||||
@extend_schema(
|
||||
tags=["User password reset"],
|
||||
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,
|
||||
@@ -393,7 +385,7 @@ class PasswordResetRequestView(APIView):
|
||||
|
||||
#2. Confirming reset
|
||||
@extend_schema(
|
||||
tags=["User password reset"],
|
||||
tags=["account", "public"],
|
||||
summary="Confirm password reset via token",
|
||||
description="Confirm password reset using token from email.",
|
||||
request=PasswordResetConfirmSerializer,
|
||||
@@ -422,4 +414,7 @@ class PasswordResetConfirmView(APIView):
|
||||
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)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
|
||||
|
||||
|
||||
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.7 on 2026-01-24 22:44
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,3 +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"]
|
||||
@@ -1,2 +1,52 @@
|
||||
#udělat zasílaní reklamních emailů uživatelům.
|
||||
#newletter --> když se vytvoří nový record s reklamou email se uloží pomocí zaškrtnutí tlačítka v záznamu
|
||||
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
|
||||
)
|
||||
|
||||
config = SiteConfiguration.get_solo()
|
||||
|
||||
send_email_with_context(
|
||||
recipients=config.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,
|
||||
"site_currency": config.currency,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }} {{ site_currency|default:"€" }}
|
||||
</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"),
|
||||
]
|
||||
@@ -1,3 +1,86 @@
|
||||
from django.shortcuts import render
|
||||
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
|
||||
|
||||
# Create your views here.
|
||||
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)
|
||||
|
||||
@@ -1,14 +1,117 @@
|
||||
from django.contrib import admin
|
||||
from .models import Carrier, Product
|
||||
# Register your models here.
|
||||
from .models import (
|
||||
Category, Product, ProductImage, Order, OrderItem,
|
||||
Carrier, Payment, DiscountCode, Refund, Invoice, Cart, CartItem, Wishlist
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Carrier)
|
||||
class CarrierAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "base_price", "is_active")
|
||||
@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", "currency", "stock", "is_active")
|
||||
search_fields = ("name", "description")
|
||||
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)
|
||||
}
|
||||
}
|
||||
45
backend/commerce/currency_info_view.py
Normal file
45
backend/commerce/currency_info_view.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from configuration.models import SiteConfiguration
|
||||
|
||||
class CurrencyInfoView(APIView):
|
||||
"""
|
||||
Get current site currency and display information.
|
||||
"""
|
||||
|
||||
@extend_schema(
|
||||
summary="Get site currency information",
|
||||
description="Returns the current site currency and available options",
|
||||
tags=["configuration"]
|
||||
)
|
||||
def get(self, request):
|
||||
config = SiteConfiguration.get_solo()
|
||||
|
||||
currency_symbols = {
|
||||
'EUR': '€',
|
||||
'CZK': 'Kč',
|
||||
'USD': '$',
|
||||
'GBP': '£',
|
||||
'PLN': 'zł',
|
||||
'HUF': 'Ft',
|
||||
'SEK': 'kr',
|
||||
'DKK': 'kr',
|
||||
'NOK': 'kr',
|
||||
'CHF': 'Fr'
|
||||
}
|
||||
|
||||
return Response({
|
||||
'current_currency': config.currency,
|
||||
'currency_symbol': currency_symbols.get(config.currency, config.currency),
|
||||
'currency_name': dict(SiteConfiguration.CURRENCY.choices)[config.currency],
|
||||
'available_currencies': [
|
||||
{
|
||||
'code': choice[0],
|
||||
'name': choice[1],
|
||||
'symbol': currency_symbols.get(choice[0], choice[0])
|
||||
}
|
||||
for choice in SiteConfiguration.CURRENCY.choices
|
||||
]
|
||||
})
|
||||
1
backend/commerce/management/__init__.py
Normal file
1
backend/commerce/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Management commands module
|
||||
1
backend/commerce/management/commands/__init__.py
Normal file
1
backend/commerce/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Commerce management commands
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Management command to migrate from per-product currency to global currency system.
|
||||
Usage: python manage.py migrate_to_global_currency
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from commerce.models import Product, Order
|
||||
from configuration.models import SiteConfiguration
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Migrate from per-product currency to global currency system'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--target-currency',
|
||||
type=str,
|
||||
default='EUR',
|
||||
help='Target currency to migrate to (default: EUR)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be changed without making changes'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
target_currency = options['target_currency']
|
||||
dry_run = options['dry_run']
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Migrating to global currency: {target_currency}")
|
||||
)
|
||||
|
||||
# Check current state
|
||||
config = SiteConfiguration.get_solo()
|
||||
self.stdout.write(f"Current site currency: {config.currency}")
|
||||
|
||||
if hasattr(Product.objects.first(), 'currency'):
|
||||
# Products still have currency field
|
||||
product_currencies = Product.objects.values_list('currency', flat=True).distinct()
|
||||
self.stdout.write(f"Product currencies found: {list(product_currencies)}")
|
||||
|
||||
if len(product_currencies) > 1:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
"Multiple currencies detected in products. "
|
||||
"Consider currency conversion before migration."
|
||||
)
|
||||
)
|
||||
|
||||
order_currencies = Order.objects.values_list('currency', flat=True).distinct()
|
||||
order_currencies = [c for c in order_currencies if c] # Remove empty strings
|
||||
self.stdout.write(f"Order currencies found: {list(order_currencies)}")
|
||||
|
||||
if not dry_run:
|
||||
# Update site configuration
|
||||
config.currency = target_currency
|
||||
config.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Updated site currency to {target_currency}")
|
||||
)
|
||||
|
||||
# Update orders with empty currency
|
||||
orders_updated = Order.objects.filter(currency='').update(currency=target_currency)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Updated {orders_updated} orders to use {target_currency}")
|
||||
)
|
||||
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("DRY RUN - No changes made"))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Migration completed successfully!")
|
||||
)
|
||||
@@ -1,6 +1,10 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-28 22:28
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from decimal import Decimal
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -9,19 +13,115 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('configuration', '0001_initial'),
|
||||
('deutschepost', '0002_deutschepostbulkorder_bulk_label_pdf_and_more'),
|
||||
('stripe', '0001_initial'),
|
||||
('zasilkovna', '0002_alter_zasilkovnapacket_state'),
|
||||
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', 'Zásilkovna'), ('deutschepost', 'Deutsche Post'), ('store', 'Osobní odběr')], default='store', max_length=20)),
|
||||
('state', models.CharField(choices=[('ordered', 'Objednávka se připravuje'), ('shipped', 'Odesláno'), ('delivered', 'Doručeno'), ('ready_to_pickup', '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=Decimal('0.00'), max_digits=10)),
|
||||
('deutschepost', models.ManyToManyField(blank=True, related_name='carriers', to='deutschepost.deutschepostorder')),
|
||||
('zasilkovna', models.ManyToManyField(blank=True, related_name='carriers', to='zasilkovna.zasilkovnapacket')),
|
||||
],
|
||||
),
|
||||
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='Category',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('base_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||
('delivery_time', models.CharField(blank=True, max_length=100)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('logo', models.ImageField(blank=True, null=True, upload_to='carriers/')),
|
||||
('external_id', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('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='Fixed discount amount in site currency', 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=[('shop', 'Platba v obchodě'), ('stripe', 'Platební Brána'), ('cash_on_delivery', 'Dobírka')], default='shop', max_length=30)),
|
||||
('payed_at_shop', models.BooleanField(blank=True, default=False, null=True)),
|
||||
('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', 'Vytvořeno'), ('cancelled', 'Zrušeno'), ('completed', 'Dokončeno'), ('refunding', 'Vrácení v procesu'), ('refunded', 'Vráceno')], default='created', max_length=20, null=True)),
|
||||
('total_price', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
|
||||
('currency', models.CharField(default='', help_text='Order currency - captured from site configuration at order creation and never changes', 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='order', 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='order', to='commerce.payment')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -30,12 +130,103 @@ class Migration(migrations.Migration):
|
||||
('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)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('currency', models.CharField(default='czk', max_length=10)),
|
||||
('code', models.CharField(blank=True, max_length=100, null=True, unique=True)),
|
||||
('price', models.DecimalField(decimal_places=2, help_text='Net price (without VAT)', 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)),
|
||||
('include_in_week_summary_email', models.BooleanField(default=False)),
|
||||
('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ě.', to='commerce.product')),
|
||||
('vat_rate', models.ForeignKey(blank=True, help_text='VAT rate for this product. Leave empty to use default rate.', null=True, on_delete=django.db.models.deletion.PROTECT, to='configuration.vatrate')),
|
||||
],
|
||||
),
|
||||
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)),
|
||||
('order', models.PositiveIntegerField(default=0, help_text='Display order (lower numbers first)')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='commerce.product')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', '-is_main', 'id'],
|
||||
},
|
||||
),
|
||||
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', 'Vrácení před uplynutím 14-ti denní lhůty'), ('damaged_product', 'Poškozený produkt'), ('wrong_item', 'Špatná položka'), ('other', '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')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Wishlist',
|
||||
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)),
|
||||
('products', models.ManyToManyField(blank=True, help_text='Products saved by the user', related_name='wishlisted_by', to='commerce.product')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='wishlist', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Wishlist',
|
||||
'verbose_name_plural': 'Wishlists',
|
||||
},
|
||||
),
|
||||
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')},
|
||||
},
|
||||
),
|
||||
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)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['product', 'rating'], name='commerce_re_product_9cd1a8_idx'), models.Index(fields=['created_at'], name='commerce_re_created_fe14ef_idx')],
|
||||
'unique_together': {('product', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,40 +1,951 @@
|
||||
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)
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
currency = models.CharField(max_length=10, default="czk")
|
||||
|
||||
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 is now global from SiteConfiguration, not per-product
|
||||
|
||||
# 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.price} {self.currency.upper()})"
|
||||
config = SiteConfiguration.get_solo()
|
||||
return f"{self.name} ({self.get_price_with_vat()} {config.currency} 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"
|
||||
|
||||
|
||||
# Dopravci a způsoby dopravy
|
||||
from django.db import models
|
||||
|
||||
# ------------------ 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 - captured from site configuration at creation time, never changes
|
||||
currency = models.CharField(max_length=10, default="", help_text="Order currency - captured from site configuration at order creation and never changes")
|
||||
|
||||
# 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")
|
||||
|
||||
# Check if order has been saved (has an ID) before accessing many-to-many relationships
|
||||
if self.pk and 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
|
||||
|
||||
# Only try to access items if order has been saved
|
||||
if self.pk:
|
||||
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 get_currency(self):
|
||||
"""Get order currency - falls back to site configuration if not set"""
|
||||
if self.currency:
|
||||
return self.currency
|
||||
config = SiteConfiguration.get_solo()
|
||||
return config.currency
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self.pk is None
|
||||
old_status = None
|
||||
|
||||
# Track old status for change detection
|
||||
if not is_new:
|
||||
try:
|
||||
old_instance = Order.objects.get(pk=self.pk)
|
||||
old_status = old_instance.status
|
||||
except Order.DoesNotExist:
|
||||
pass
|
||||
|
||||
# CRITICAL: Set currency from site configuration ONLY at creation time
|
||||
# Once set, currency should NEVER change to maintain order integrity
|
||||
if is_new and not self.currency:
|
||||
config = SiteConfiguration.get_solo()
|
||||
self.currency = config.currency
|
||||
|
||||
# Keep total_price always in sync with items and discount
|
||||
self.total_price = self.calculate_total_price()
|
||||
if self.user and is_new:
|
||||
self.import_data_from_user()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Send email notification for new orders
|
||||
if is_new:
|
||||
from .tasks import notify_order_successfuly_created
|
||||
notify_order_successfuly_created.delay(order=self)
|
||||
|
||||
# Send email notification when status changes to CANCELLED
|
||||
if not is_new and old_status != self.OrderStatus.CANCELLED and self.status == self.OrderStatus.CANCELLED:
|
||||
from .tasks import notify_order_cancelled
|
||||
notify_order_cancelled.delay(order=self)
|
||||
|
||||
# Send email notification when status changes to COMPLETED
|
||||
if not is_new and old_status != self.OrderStatus.COMPLETED and self.status == self.OrderStatus.COMPLETED:
|
||||
from .tasks import notify_order_completed
|
||||
notify_order_completed.delay(order=self)
|
||||
|
||||
def cancel_order(self):
|
||||
"""Cancel the order if possible"""
|
||||
if self.status == self.OrderStatus.CREATED:
|
||||
self.status = self.OrderStatus.CANCELLED
|
||||
self.save()
|
||||
#TODO: udělat ještě kontrolu jestli už nebyla odeslána zásilka a pokud bude už zaplacena tak se uděla refundace a pokud nebude zaplacena tak se zruší brána.
|
||||
else:
|
||||
raise ValidationError("Only orders in 'created' status can be cancelled.")
|
||||
|
||||
|
||||
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
|
||||
|
||||
class Carrier(models.Model):
|
||||
name = models.CharField(max_length=100) # název dopravce (Zásilkovna, Česká pošta…)
|
||||
base_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) # základní cena dopravy
|
||||
delivery_time = models.CharField(max_length=100, blank=True) # např. "2–3 pracovní dny"
|
||||
is_active = models.BooleanField(default=True)
|
||||
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)
|
||||
|
||||
# pole pro logo
|
||||
logo = models.ImageField(upload_to="carriers/", blank=True, null=True)
|
||||
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)
|
||||
|
||||
# pole pro propojení s externím API (např. ID služby u Zásilkovny)
|
||||
external_id = models.CharField(max_length=50, blank=True, null=True)
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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="Fixed discount amount in site currency")
|
||||
|
||||
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.name} ({self.base_price} Kč)"
|
||||
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)
|
||||
|
||||
|
||||
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()
|
||||
@@ -1,26 +1,564 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Carrier
|
||||
|
||||
class CarrierSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Carrier
|
||||
fields = [
|
||||
"id", "name", "base_price", "delivery_time",
|
||||
"is_active", "logo", "external_id"
|
||||
]
|
||||
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 .models import Product, Carrier, Order
|
||||
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 not attrs.get(field):
|
||||
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 --
|
||||
# Discount codes need to be added before payment/final save because calculate_total_price uses them
|
||||
if codes:
|
||||
discounts = list(DiscountCode.objects.filter(code__in=codes))
|
||||
if discounts:
|
||||
order.discount.add(*discounts)
|
||||
# Save to recalculate total with discounts
|
||||
order.save(update_fields=["total_price", "updated_at"])
|
||||
|
||||
|
||||
|
||||
# -- 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", "updated_at"])
|
||||
|
||||
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__"
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = "__all__"
|
||||
read_only_fields = ["created_at", "updated_at"]
|
||||
|
||||
|
||||
class CarrierSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Carrier
|
||||
fields = "__all__"
|
||||
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
|
||||
182
backend/commerce/tasks.py
Normal file
182
backend/commerce/tasks.py
Normal file
@@ -0,0 +1,182 @@
|
||||
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, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_zasilkovna_sended:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your order has been shipped",
|
||||
template_path="email/shipping/zasilkovna/zasilkovna_sended.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
# Shop
|
||||
@shared_task
|
||||
def notify_Ready_to_pickup(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_Ready_to_pickup:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your order is ready for pickup",
|
||||
template_path="email/shipping/ready_to_pickup/ready_to_pickup.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
# -- NOTIFICATIONS ORDER --
|
||||
|
||||
@shared_task
|
||||
def notify_order_successfuly_created(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_successfuly_created:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your order has been successfully created",
|
||||
template_path="email/order_created.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
@shared_task
|
||||
def notify_order_payed(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_payed:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your order has been paid",
|
||||
template_path="email/order_paid.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
@shared_task
|
||||
def notify_about_missing_payment(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_about_missing_payment:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Payment missing for your order",
|
||||
template_path="email/order_missing_payment.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
# -- NOTIFICATIONS REFUND --
|
||||
|
||||
@shared_task
|
||||
def notify_refund_items_arrived(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_refund_items_arrived:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your refund items have arrived",
|
||||
template_path="email/order_refund_items_arrived.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
# Refund accepted, returning money
|
||||
@shared_task
|
||||
def notify_refund_accepted(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_refund_accepted:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your refund has been accepted",
|
||||
template_path="email/order_refund_accepted.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
# -- NOTIFICATIONS ORDER STATUS --
|
||||
|
||||
@shared_task
|
||||
def notify_order_cancelled(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_cancelled:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your order has been cancelled",
|
||||
template_path="email/order_cancelled.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
|
||||
|
||||
@shared_task
|
||||
def notify_order_completed(order = None, **kwargs):
|
||||
if not order:
|
||||
raise ValueError("Order must be provided for notification.")
|
||||
|
||||
if kwargs:
|
||||
print("Additional kwargs received in notify_order_completed:", kwargs)
|
||||
|
||||
send_email_with_context(
|
||||
recipients=order.email,
|
||||
subject="Your order has been completed",
|
||||
template_path="email/order_completed.html",
|
||||
context={
|
||||
"order": order,
|
||||
})
|
||||
50
backend/commerce/templates/email/order_cancelled.html
Normal file
50
backend/commerce/templates/email/order_cancelled.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<h3 style="color:#d9534f; font-size:18px; margin-top:0;">Order Cancelled</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Your order has been cancelled.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Total Amount:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Cancellation Date:</td>
|
||||
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if order.payment.status == 'paid' %}
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refund Information</h4>
|
||||
<p>Since your order was already paid, you will receive a refund of {{ order.total_price }} {{ order.get_currency }}. The refund will be processed within 3-5 business days.</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
If you have any questions, please contact our support team.
|
||||
</p>
|
||||
49
backend/commerce/templates/email/order_completed.html
Normal file
49
backend/commerce/templates/email/order_completed.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Order Completed</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Great news! Your order has been completed and delivered. Thank you for your purchase!</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Total Amount:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Completed:</td>
|
||||
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
|
||||
<strong>We hope you enjoyed your purchase!</strong> If you have any feedback or need to return an item, please let us know.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Thank you for shopping with us!
|
||||
</p>
|
||||
50
backend/commerce/templates/email/order_created.html
Normal file
50
backend/commerce/templates/email/order_created.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<h3 style="color:#333; font-size:18px; margin-top:0;">Order Confirmation</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Thank you for your order! Your order has been successfully created and is being prepared for shipment.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Details</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Summary</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="text-align:right; padding:8px;">Subtotal:</td>
|
||||
<td style="text-align:right; padding:8px; font-weight:bold;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Shipping Address</h4>
|
||||
<p style="margin:0;">
|
||||
{{ order.first_name }} {{ order.last_name }}<br>
|
||||
{{ order.address }}<br>
|
||||
{{ order.postal_code }} {{ order.city }}<br>
|
||||
{{ order.country }}
|
||||
</p>
|
||||
|
||||
{% if order.note %}
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Special Instructions</h4>
|
||||
<p style="margin:0;">{{ order.note }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
We will notify you as soon as your order ships. If you have any questions, please contact us.
|
||||
</p>
|
||||
50
backend/commerce/templates/email/order_missing_payment.html
Normal file
50
backend/commerce/templates/email/order_missing_payment.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<h3 style="color:#d9534f; font-size:18px; margin-top:0;">⚠ Payment Reminder</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>We haven't received payment for your order yet. Your order is being held and may be cancelled if payment is not completed soon.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Details</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Amount Due:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Created:</td>
|
||||
<td style="padding:8px;">{{ order.created_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #d9534f;">
|
||||
<strong>Please complete your payment as soon as possible to avoid order cancellation.</strong>
|
||||
If you have questions or need assistance, contact us right away.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Thank you for your business!
|
||||
</p>
|
||||
45
backend/commerce/templates/email/order_paid.html
Normal file
45
backend/commerce/templates/email/order_paid.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<h3 style="color:#333; font-size:18px; margin-top:0;">✓ Payment Received</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Thank you! Your payment has been successfully received and processed. Your order is now confirmed and will be prepared for shipment.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Amount Paid:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px; font-weight:bold;">Payment Date:</td>
|
||||
<td style="padding:8px;">{{ order.payment.created_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Your order will be prepared and shipped as soon as possible. You will receive a shipping notification with tracking details.
|
||||
</p>
|
||||
53
backend/commerce/templates/email/order_refund_accepted.html
Normal file
53
backend/commerce/templates/email/order_refund_accepted.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Refund Processed</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Excellent! Your refund has been approved and processed. The funds will appear in your account within 3-5 business days, depending on your financial institution.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refund Details</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Original Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Refund Amount:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Processing Date:</td>
|
||||
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px; font-weight:bold;">Status:</td>
|
||||
<td style="padding:8px; color:#5cb85c; font-weight:bold;">✓ Completed</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refunded Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Refund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
|
||||
<strong>Timeline:</strong> Your refund should appear in your account within 3-5 business days. Some banks may take longer during weekends or holidays.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Thank you for giving us the opportunity to serve you. If you need anything else, please don't hesitate to contact us.
|
||||
</p>
|
||||
@@ -0,0 +1,49 @@
|
||||
<h3 style="color:#333; font-size:18px; margin-top:0;">Return Items Received</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Thank you! We have received your returned items from order #{{ order.id }}. Our team is now inspecting the items and processing your refund.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Total Refund Amount:</td>
|
||||
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px; font-weight:bold;">Received Date:</td>
|
||||
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Returned Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Refund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #5bc0de;">
|
||||
<strong>What's Next?</strong> We'll inspect the items and confirm the refund within 2-3 business days. You'll receive another confirmation email when your refund has been processed.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
If you have any questions about your return, please contact us.
|
||||
</p>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,49 @@
|
||||
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Your Order is Ready for Pickup!</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Excellent news! Your order is now ready for pickup. You can collect your package at your convenience during store hours.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Pickup Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Ready Since:</td>
|
||||
<td style="padding:8px;">{{ order.carrier.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px; font-weight:bold;">Pickup Location:</td>
|
||||
<td style="padding:8px;">Our Store</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
|
||||
<strong>What to Bring:</strong> Please bring a valid ID and your order confirmation (this email). Your package is being held for you and will be released upon presentation of these documents.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
Thank you for your business! If you have any questions, please don't hesitate to contact us.
|
||||
</p>
|
||||
@@ -0,0 +1,55 @@
|
||||
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">📦 Your Package is on its Way!</h3>
|
||||
|
||||
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
|
||||
|
||||
<p>Great news! Your order has been shipped via Zásilkovna and is on its way to you.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Shipping Information</h4>
|
||||
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Order ID:</td>
|
||||
<td style="padding:8px;">{{ order.id }}</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Carrier:</td>
|
||||
<td style="padding:8px;">Zásilkovna</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid #ddd;">
|
||||
<td style="padding:8px; font-weight:bold;">Shipped Date:</td>
|
||||
<td style="padding:8px;">{{ order.carrier.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Delivery Instructions</h4>
|
||||
<p>Your package will be delivered to your selected Zásilkovna pickup point. You will receive an SMS/email notification from Zásilkovna when the package arrives at the pickup point.</p>
|
||||
|
||||
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
|
||||
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
|
||||
<thead>
|
||||
<tr style="background-color:#f9f9f9;">
|
||||
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
|
||||
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
|
||||
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items.all %}
|
||||
<tr>
|
||||
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
|
||||
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
|
||||
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #5cb85c;">
|
||||
<strong>Delivery Address:</strong><br>
|
||||
{{ order.first_name }} {{ order.last_name }}<br>
|
||||
{{ order.address }}<br>
|
||||
{{ order.postal_code }} {{ order.city }}
|
||||
</p>
|
||||
|
||||
<p style="margin-top:20px; color:#666;">
|
||||
You can track your package on the Zásilkovna website. If you have any questions, please contact us.
|
||||
</p>
|
||||
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>
|
||||
38
backend/commerce/urls.py
Normal file
38
backend/commerce/urls.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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,
|
||||
)
|
||||
from .currency_info_view import CurrencyInfoView
|
||||
|
||||
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'),
|
||||
path('currency/info/', CurrencyInfoView.as_view(), name='currency-info'),
|
||||
]
|
||||
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)
|
||||
|
||||
|
||||
67
backend/configuration/migrations/0001_initial.py
Normal file
67
backend/configuration/migrations/0001_initial.py
Normal file
@@ -0,0 +1,67 @@
|
||||
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||
|
||||
import django.core.validators
|
||||
from decimal import Decimal
|
||||
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=Decimal('50.00'), 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=Decimal('2000.00'), max_digits=10)),
|
||||
('deutschepost_api_url', models.URLField(default='https://gw.sandbox.deutschepost.com', help_text='Deutsche Post API URL (sandbox/production)', max_length=255)),
|
||||
('deutschepost_client_id', models.CharField(blank=True, help_text='Deutsche Post OAuth Client ID', max_length=255, null=True)),
|
||||
('deutschepost_client_secret', models.CharField(blank=True, help_text='Deutsche Post OAuth Client Secret', max_length=255, null=True)),
|
||||
('deutschepost_customer_ekp', models.CharField(blank=True, help_text='Deutsche Post Customer EKP number', max_length=20, null=True)),
|
||||
('deutschepost_shipping_price', models.DecimalField(decimal_places=2, default=Decimal('6.00'), help_text='Default Deutsche Post shipping price in EUR', 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=[('EUR', 'Euro'), ('CZK', 'Czech Koruna'), ('USD', 'US Dollar'), ('GBP', 'British Pound'), ('PLN', 'Polish Zloty'), ('HUF', 'Hungarian Forint'), ('SEK', 'Swedish Krona'), ('DKK', 'Danish Krone'), ('NOK', 'Norwegian Krone'), ('CHF', 'Swiss Franc')], default='EUR', max_length=10)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Shop Configuration',
|
||||
'verbose_name_plural': 'Shop Configuration',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VATRate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="E.g. 'German Standard', 'German Reduced', 'Czech Standard'", max_length=100)),
|
||||
('description', models.TextField(blank=True, help_text="Optional description: 'Standard rate for most products', 'Books and food', etc.")),
|
||||
('rate', models.DecimalField(decimal_places=4, help_text='VAT rate as percentage (e.g. 19.00 for 19%)', max_digits=5, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('100'))])),
|
||||
('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)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VAT Rate',
|
||||
'verbose_name_plural': 'VAT Rates',
|
||||
'ordering': ['-is_default', 'rate', 'name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
133
backend/configuration/models.py
Normal file
133
backend/configuration/models.py
Normal file
@@ -0,0 +1,133 @@
|
||||
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):
|
||||
EUR = "EUR", "Euro"
|
||||
CZK = "CZK", "Czech Koruna"
|
||||
USD = "USD", "US Dollar"
|
||||
GBP = "GBP", "British Pound"
|
||||
PLN = "PLN", "Polish Zloty"
|
||||
HUF = "HUF", "Hungarian Forint"
|
||||
SEK = "SEK", "Swedish Krona"
|
||||
DKK = "DKK", "Danish Krone"
|
||||
NOK = "NOK", "Norwegian Krone"
|
||||
CHF = "CHF", "Swiss Franc"
|
||||
currency = models.CharField(max_length=10, default=CURRENCY.EUR, 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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user