Compare commits
58 Commits
05055415de
...
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 | |||
| 2118f002d1 | |||
| 602c5a40f1 |
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`).
|
- Static/media files: S3 in production, local in dev (see `settings.py`).
|
||||||
- API versioning and docs: DRF Spectacular config in `settings.py`.
|
- API versioning and docs: DRF Spectacular config in `settings.py`.
|
||||||
- Custom permissions, filters, and serializers in each app.
|
- 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**
|
- **Frontend**
|
||||||
- Use React Router layouts for shared UI (see `src/layouts/`, `LAYOUTS.md`).
|
- Use React Router layouts for shared UI (see `src/layouts/`, `LAYOUTS.md`).
|
||||||
- API calls and JWT handling in `src/api/`.
|
- API calls and JWT handling in `src/api/`.
|
||||||
@@ -107,6 +111,109 @@ Notes
|
|||||||
- **Task queue**: Celery + Redis for async/background jobs.
|
- **Task queue**: Celery + Redis for async/background jobs.
|
||||||
- **API**: REST endpoints, JWT auth, API key support.
|
- **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
|
## References
|
||||||
- [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions.
|
- [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions.
|
||||||
- [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns.
|
- [frontend/src/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
|
||||||
|
}
|
||||||
@@ -2,7 +2,21 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
|
||||||
class AccountConfig(AppConfig):
|
class AccountConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
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 account.models
|
||||||
import django.contrib.auth.validators
|
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')),
|
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||||
('is_deleted', models.BooleanField(default=False)),
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
('role', models.CharField(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.')])),
|
('phone_number', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
|
||||||
('email_verified', models.BooleanField(default=False)),
|
('email_verified', models.BooleanField(default=False)),
|
||||||
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
|
||||||
|
('email_verification_token', models.CharField(blank=True, db_index=True, max_length=128, null=True)),
|
||||||
|
('email_verification_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('newsletter', models.BooleanField(default=True)),
|
||||||
('gdpr', models.BooleanField(default=False)),
|
('gdpr', models.BooleanField(default=False)),
|
||||||
('is_active', models.BooleanField(default=False)),
|
('is_active', models.BooleanField(default=False)),
|
||||||
('create_time', models.DateTimeField(auto_now_add=True)),
|
('create_time', models.DateTimeField(auto_now_add=True)),
|
||||||
('city', models.CharField(blank=True, max_length=100, null=True)),
|
('city', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
('street', models.CharField(blank=True, max_length=200, null=True)),
|
('street', models.CharField(blank=True, max_length=200, null=True)),
|
||||||
|
('street_number', models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
('country', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('company_name', models.CharField(blank=True, max_length=255)),
|
||||||
|
('ico', models.CharField(blank=True, max_length=20)),
|
||||||
|
('dic', models.CharField(blank=True, max_length=20)),
|
||||||
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
|
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
|
||||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
|
||||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
|
||||||
|
|||||||
@@ -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):
|
class CustomUser(SoftDeleteModel, AbstractUser):
|
||||||
groups = models.ManyToManyField(
|
groups = models.ManyToManyField(
|
||||||
Group,
|
Group,
|
||||||
related_name="customuser_set", # <- přidáš related_name
|
related_name="customuser_set",
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="The groups this user belongs to.",
|
help_text="The groups this user belongs to.",
|
||||||
related_query_name="customuser",
|
related_query_name="customuser",
|
||||||
)
|
)
|
||||||
user_permissions = models.ManyToManyField(
|
user_permissions = models.ManyToManyField(
|
||||||
Permission,
|
Permission,
|
||||||
related_name="customuser_set", # <- přidáš related_name
|
related_name="customuser_set",
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Specific permissions for this user.",
|
help_text="Specific permissions for this user.",
|
||||||
related_query_name="customuser",
|
related_query_name="customuser",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Role(models.TextChoices):
|
class Role(models.TextChoices):
|
||||||
ADMIN = "admin", "Admin"
|
ADMIN = "admin", "cz#Administrátor"
|
||||||
MANAGER = "mod", "Moderator"
|
MANAGER = "mod", "cz#Moderator"
|
||||||
CUSTOMER = "regular", "Regular"
|
CUSTOMER = "regular", "cz#Regular"
|
||||||
|
|
||||||
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
|
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
phone_number = models.CharField(
|
phone_number = models.CharField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
||||||
unique=True,
|
unique=True,
|
||||||
max_length=16,
|
max_length=16,
|
||||||
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
|
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_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
|
||||||
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
|
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
newsletter = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
#misc
|
||||||
gdpr = models.BooleanField(default=False)
|
gdpr = models.BooleanField(default=False)
|
||||||
is_active = models.BooleanField(default=False)
|
is_active = models.BooleanField(default=False)
|
||||||
|
|
||||||
create_time = models.DateTimeField(auto_now_add=True)
|
create_time = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
#adresa
|
||||||
|
postal_code = models.CharField(max_length=20, blank=True)
|
||||||
city = models.CharField(null=True, blank=True, max_length=100)
|
city = models.CharField(null=True, blank=True, max_length=100)
|
||||||
street = models.CharField(null=True, blank=True, max_length=200)
|
street = models.CharField(null=True, blank=True, max_length=200)
|
||||||
|
street_number = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
country = models.CharField(null=True, blank=True, max_length=100)
|
||||||
|
|
||||||
|
# firemní fakturační údaje
|
||||||
|
company_name = models.CharField(max_length=255, blank=True)
|
||||||
|
ico = models.CharField(max_length=20, blank=True)
|
||||||
|
dic = models.CharField(max_length=20, blank=True)
|
||||||
|
|
||||||
postal_code = models.CharField(
|
postal_code = models.CharField(
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -94,6 +102,7 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
|||||||
"email"
|
"email"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Ensure default manager has get_by_natural_key
|
# Ensure default manager has get_by_natural_key
|
||||||
objects = CustomUserManager()
|
objects = CustomUserManager()
|
||||||
# Optional convenience manager for active users only
|
# Optional convenience manager for active users only
|
||||||
@@ -157,4 +166,10 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
|||||||
if save:
|
if save:
|
||||||
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
|
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
|
||||||
return True
|
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):
|
def has_permission(self, request, view):
|
||||||
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
|
||||||
|
|
||||||
|
# Commerce-specific permissions
|
||||||
|
class AdminWriteOnlyOrReadOnly(BasePermission):
|
||||||
|
"""Allow read for anyone, write only for admins"""
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return True
|
||||||
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
|
||||||
|
|
||||||
|
class AdminOnlyForPatchOtherwisePublic(BasePermission):
|
||||||
|
"""Allow GET/POST for anyone, PATCH/PUT/DELETE only for admins"""
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if request.method in SAFE_METHODS or request.method == "POST":
|
||||||
|
return True
|
||||||
|
if request.method in ["PATCH", "PUT", "DELETE"]:
|
||||||
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
# Default to admin for other unsafe methods
|
||||||
|
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
|
||||||
|
|
||||||
|
|||||||
@@ -10,33 +10,25 @@ from .models import CustomUser
|
|||||||
|
|
||||||
logger = get_task_logger(__name__)
|
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.
|
Send emails rendering a single HTML template.
|
||||||
Supports rendering plain text and HTML templates.
|
- `template_name` is a simple base name without extension, e.g. "email/test".
|
||||||
Converts `user` in context to a plain dict to avoid template access to the model.
|
- 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):
|
if isinstance(recipients, str):
|
||||||
recipients = [recipients]
|
recipients = [recipients]
|
||||||
|
|
||||||
html_message = None
|
html_message = None
|
||||||
if template_name or html_template_name:
|
if template_path:
|
||||||
# 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")
|
|
||||||
|
|
||||||
ctx = dict(context or {})
|
ctx = dict(context or {})
|
||||||
# Sanitize user if someone passes the model by mistake
|
# Render base layout and include the provided template as the main content.
|
||||||
if "user" in ctx and not isinstance(ctx["user"], dict):
|
# The included template receives the same context as the base.
|
||||||
try:
|
html_message = render_to_string(
|
||||||
ctx["user"] = _build_user_template_ctx(ctx["user"])
|
"email/components/base.html",
|
||||||
except Exception:
|
{"content_template": template_path, **ctx},
|
||||||
ctx["user"] = {}
|
)
|
||||||
|
|
||||||
message = render_to_string(template_name, ctx)
|
|
||||||
html_message = render_to_string(html_template_name, ctx)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_mail(
|
send_mail(
|
||||||
@@ -47,33 +39,13 @@ def send_email_with_context(recipients, subject, message=None, template_name=Non
|
|||||||
fail_silently=False,
|
fail_silently=False,
|
||||||
html_message=html_message,
|
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")
|
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"E-mail se neodeslal: {e}")
|
logger.error(f"E-mail se neodeslal: {e}")
|
||||||
return False
|
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}"
|
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"user": _build_user_template_ctx(user),
|
"user": user,
|
||||||
"action_url": verify_url,
|
"action_url": verify_url,
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
"cta_label": "Ověřit e‑mail",
|
"cta_label": "Ověřit e‑mail",
|
||||||
@@ -101,8 +73,7 @@ def send_email_verification_task(user_id):
|
|||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=user.email,
|
recipients=user.email,
|
||||||
subject="Ověření e‑mailu",
|
subject="Ověření e‑mailu",
|
||||||
template_name="email/email_verification.txt",
|
template_path="email/email_verification.html",
|
||||||
html_template_name="email/email_verification.html",
|
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,8 +89,7 @@ def send_email_test_task(email):
|
|||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=email,
|
recipients=email,
|
||||||
subject="Testovací e‑mail",
|
subject="Testovací e‑mail",
|
||||||
template_name="email/test.txt",
|
template_path="email/test.html",
|
||||||
html_template_name="email/test.html",
|
|
||||||
context=context,
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,7 +107,7 @@ def send_password_reset_email_task(user_id):
|
|||||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"user": _build_user_template_ctx(user),
|
"user": user,
|
||||||
"action_url": reset_url,
|
"action_url": reset_url,
|
||||||
"frontend_url": settings.FRONTEND_URL,
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
"cta_label": "Obnovit heslo",
|
"cta_label": "Obnovit heslo",
|
||||||
@@ -146,7 +116,6 @@ def send_password_reset_email_task(user_id):
|
|||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=user.email,
|
recipients=user.email,
|
||||||
subject="Obnova hesla",
|
subject="Obnova hesla",
|
||||||
template_name="email/password_reset.txt",
|
template_path="email/password_reset.html",
|
||||||
html_template_name="email/password_reset.html",
|
|
||||||
context=context,
|
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.authentication import JWTAuthentication
|
||||||
|
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||||
|
|
||||||
#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU
|
#COOKIE + AUTHORIZATION HEADER JWT AUTHENTICATION FOR AXIOS COMPATIBILITY
|
||||||
class CookieJWTAuthentication(JWTAuthentication):
|
class CookieJWTAuthentication(JWTAuthentication):
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
|
# First try Authorization header (standard axios pattern)
|
||||||
|
header_token = self.get_header(request)
|
||||||
|
if header_token is not None:
|
||||||
|
validated_token = self.get_validated_token(header_token)
|
||||||
|
return self.get_user(validated_token), validated_token
|
||||||
|
|
||||||
|
# Fallback to cookie-based authentication
|
||||||
raw_token = request.COOKIES.get('access_token')
|
raw_token = request.COOKIES.get('access_token')
|
||||||
|
|
||||||
if not raw_token:
|
if not raw_token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
validated_token = self.get_validated_token(raw_token)
|
try:
|
||||||
return self.get_user(validated_token), validated_token
|
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
|
# Registration & email endpoints
|
||||||
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
|
||||||
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
|
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
|
||||||
path('activate/', views.UserActivationViewSet.as_view(), name='activate-user'),
|
|
||||||
|
|
||||||
# Password reset endpoints
|
# Password reset endpoints
|
||||||
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from .serializers import *
|
|||||||
from .permissions import *
|
from .permissions import *
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
from .tokens import *
|
from .tokens import *
|
||||||
from .tasks import send_password_reset_email_task
|
from .tasks import send_password_reset_email_task, send_email_verification_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,7 +38,7 @@ from rest_framework_simplejwt.views import TokenObtainPairView
|
|||||||
|
|
||||||
# Custom Token obtaining view
|
# Custom Token obtaining view
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Authentication"],
|
tags=["account", "public"],
|
||||||
summary="Obtain JWT access and refresh tokens (cookie-based)",
|
summary="Obtain JWT access and refresh tokens (cookie-based)",
|
||||||
description="Authenticate user and obtain JWT access and refresh tokens. You can use either email or username.",
|
description="Authenticate user and obtain JWT access and refresh tokens. You can use either email or username.",
|
||||||
request=CustomTokenObtainPairSerializer,
|
request=CustomTokenObtainPairSerializer,
|
||||||
@@ -107,7 +107,7 @@ class CookieTokenObtainPairView(TokenObtainPairView):
|
|||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Authentication"],
|
tags=["account", "public"],
|
||||||
summary="Refresh JWT token using cookie",
|
summary="Refresh JWT token using cookie",
|
||||||
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
|
||||||
responses={
|
responses={
|
||||||
@@ -163,7 +163,7 @@ class CookieTokenRefreshView(APIView):
|
|||||||
#---------------------------------------------LOGOUT------------------------------------------------
|
#---------------------------------------------LOGOUT------------------------------------------------
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Authentication"],
|
tags=["account", "public"],
|
||||||
summary="Logout user (delete access and refresh token cookies)",
|
summary="Logout user (delete access and refresh token cookies)",
|
||||||
description="Logs out the user by deleting access and refresh token cookies.",
|
description="Logs out the user by deleting access and refresh token cookies.",
|
||||||
responses={
|
responses={
|
||||||
@@ -185,7 +185,7 @@ class LogoutView(APIView):
|
|||||||
#--------------------------------------------------------------------------------------------------------------
|
#--------------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User"],
|
tags=["account"],
|
||||||
summary="List, retrieve, update, and delete users.",
|
summary="List, retrieve, update, and delete users.",
|
||||||
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
|
||||||
responses={
|
responses={
|
||||||
@@ -222,6 +222,15 @@ class UserView(viewsets.ModelViewSet):
|
|||||||
"is_active": {"help_text": "Stav aktivace uživatele."},
|
"is_active": {"help_text": "Stav aktivace uživatele."},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["account"],
|
||||||
|
summary="Get permissions based on user role and action.",
|
||||||
|
description="Determines permissions for various actions based on user role and ownership.",
|
||||||
|
responses={
|
||||||
|
200: OpenApiResponse(description="Permissions determined successfully."),
|
||||||
|
403: OpenApiResponse(description="Permission denied."),
|
||||||
|
},
|
||||||
|
)
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
# Only admin can list or create users
|
# Only admin can list or create users
|
||||||
if self.action in ['list', 'create']:
|
if self.action in ['list', 'create']:
|
||||||
@@ -241,9 +250,19 @@ class UserView(viewsets.ModelViewSet):
|
|||||||
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
# Fallback - deny access (prevents AttributeError for AnonymousUser)
|
||||||
return [OnlyRolesAllowed("admin")()]
|
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':
|
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()
|
return super().get_permissions()
|
||||||
|
|
||||||
@@ -251,7 +270,7 @@ class UserView(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
# Get current user data
|
# Get current user data
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User"],
|
tags=["account"],
|
||||||
summary="Get current authenticated user",
|
summary="Get current authenticated user",
|
||||||
description="Returns details of the currently authenticated user based on JWT token or session.",
|
description="Returns details of the currently authenticated user based on JWT token or session.",
|
||||||
responses={
|
responses={
|
||||||
@@ -271,7 +290,7 @@ class CurrentUserView(APIView):
|
|||||||
|
|
||||||
#1. registration API
|
#1. registration API
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User Registration"],
|
tags=["account", "public"],
|
||||||
summary="Register a new user (company or individual)",
|
summary="Register a new user (company or individual)",
|
||||||
description="Register a new user (company or individual). The user will receive an email with a verification link.",
|
description="Register a new user (company or individual). The user will receive an email with a verification link.",
|
||||||
request=UserRegistrationSerializer,
|
request=UserRegistrationSerializer,
|
||||||
@@ -303,7 +322,7 @@ class UserRegistrationViewSet(ModelViewSet):
|
|||||||
|
|
||||||
#2. confirming email
|
#2. confirming email
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User Registration"],
|
tags=["account", "public"],
|
||||||
summary="Verify user email via link",
|
summary="Verify user email via link",
|
||||||
description="Verify user email using the link with uid and token.",
|
description="Verify user email using the link with uid and token.",
|
||||||
parameters=[
|
parameters=[
|
||||||
@@ -325,45 +344,18 @@ class EmailVerificationView(APIView):
|
|||||||
|
|
||||||
if account_activation_token.check_token(user, token):
|
if account_activation_token.check_token(user, token):
|
||||||
user.email_verified = True
|
user.email_verified = True
|
||||||
|
user.is_active = True # Aktivace uživatele po ověření e-mailu
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
return Response({"detail": "E-mail byl úspěšně ověřen. Účet čeká na schválení."})
|
return Response({"detail": "E-mail byl úspěšně ověřen. Účet je aktivován."})
|
||||||
else:
|
else:
|
||||||
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
|
||||||
|
|
||||||
#3. seller activation API (var_symbol)
|
|
||||||
@extend_schema(
|
|
||||||
tags=["User Registration"],
|
|
||||||
summary="Activate user and set variable symbol (admin/cityClerk only)",
|
|
||||||
description="Activate user and set variable symbol. Only accessible by admin or cityClerk.",
|
|
||||||
request=UserActivationSerializer,
|
|
||||||
responses={
|
|
||||||
200: OpenApiResponse(response=UserActivationSerializer, description="User activated successfully."),
|
|
||||||
400: OpenApiResponse(description="Invalid activation data."),
|
|
||||||
404: OpenApiResponse(description="User not found."),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
class UserActivationViewSet(APIView):
|
|
||||||
permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')]
|
|
||||||
|
|
||||||
def patch(self, request, *args, **kwargs):
|
|
||||||
serializer = UserActivationSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
user = serializer.save()
|
|
||||||
|
|
||||||
try:
|
|
||||||
send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Celery not available, using fallback. Error: {e}")
|
|
||||||
send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol
|
|
||||||
|
|
||||||
return Response(serializer.to_representation(user), status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
|
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
|
||||||
|
|
||||||
#1. PasswordReset + send Email
|
#1. PasswordReset + send Email
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User password reset"],
|
tags=["account", "public"],
|
||||||
summary="Request password reset (send email)",
|
summary="Request password reset (send email)",
|
||||||
description="Request password reset by providing registered email. An email with instructions will be sent.",
|
description="Request password reset by providing registered email. An email with instructions will be sent.",
|
||||||
request=PasswordResetRequestSerializer,
|
request=PasswordResetRequestSerializer,
|
||||||
@@ -393,7 +385,7 @@ class PasswordResetRequestView(APIView):
|
|||||||
|
|
||||||
#2. Confirming reset
|
#2. Confirming reset
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["User password reset"],
|
tags=["account", "public"],
|
||||||
summary="Confirm password reset via token",
|
summary="Confirm password reset via token",
|
||||||
description="Confirm password reset using token from email.",
|
description="Confirm password reset using token from email.",
|
||||||
request=PasswordResetConfirmSerializer,
|
request=PasswordResetConfirmSerializer,
|
||||||
@@ -422,4 +414,7 @@ class PasswordResetConfirmView(APIView):
|
|||||||
user.set_password(serializer.validated_data['password'])
|
user.set_password(serializer.validated_data['password'])
|
||||||
user.save()
|
user.save()
|
||||||
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
return Response({"detail": "Heslo bylo úspěšně změněno."})
|
||||||
return Response(serializer.errors, status=400)
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/advertisement/migrations/__init__.py
Normal file
0
backend/advertisement/migrations/__init__.py
Normal file
@@ -1,3 +1,14 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
# Create your models here.
|
# 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.
|
from venv import create
|
||||||
#newletter --> když se vytvoří nový record s reklamou email se uloží pomocí zaškrtnutí tlačítka v záznamu
|
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 django.contrib import admin
|
||||||
from .models import Carrier, Product
|
from .models import (
|
||||||
# Register your models here.
|
Category, Product, ProductImage, Order, OrderItem,
|
||||||
|
Carrier, Payment, DiscountCode, Refund, Invoice, Cart, CartItem, Wishlist
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Carrier)
|
@admin.register(Category)
|
||||||
class CarrierAdmin(admin.ModelAdmin):
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "base_price", "is_active")
|
list_display = ("name", "url", "parent")
|
||||||
|
search_fields = ("name", "description")
|
||||||
|
prepopulated_fields = {"url": ("name",)}
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Product)
|
@admin.register(Product)
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "price", "currency", "stock", "is_active")
|
list_display = ("name", "price", "stock", "is_active", "category", "created_at")
|
||||||
search_fields = ("name", "description")
|
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.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -9,19 +13,115 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
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 = [
|
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(
|
migrations.CreateModel(
|
||||||
name='Carrier',
|
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=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(max_length=100)),
|
('name', models.CharField(max_length=100)),
|
||||||
('base_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
('url', models.SlugField(unique=True)),
|
||||||
('delivery_time', models.CharField(blank=True, max_length=100)),
|
('description', models.TextField(blank=True)),
|
||||||
('is_active', models.BooleanField(default=True)),
|
('image', models.ImageField(blank=True, upload_to='categories/')),
|
||||||
('logo', models.ImageField(blank=True, null=True, upload_to='carriers/')),
|
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='commerce.category')),
|
||||||
('external_id', models.CharField(blank=True, max_length=50, null=True)),
|
],
|
||||||
|
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(
|
migrations.CreateModel(
|
||||||
@@ -30,12 +130,103 @@ class Migration(migrations.Migration):
|
|||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('name', models.CharField(max_length=200)),
|
('name', models.CharField(max_length=200)),
|
||||||
('description', models.TextField(blank=True)),
|
('description', models.TextField(blank=True)),
|
||||||
('price', models.DecimalField(decimal_places=2, max_digits=10)),
|
('code', models.CharField(blank=True, max_length=100, null=True, unique=True)),
|
||||||
('currency', models.CharField(default='czk', max_length=10)),
|
('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)),
|
('stock', models.PositiveIntegerField(default=0)),
|
||||||
('is_active', models.BooleanField(default=True)),
|
('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)),
|
('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')),
|
('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.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):
|
class Product(models.Model):
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
description = models.TextField(blank=True)
|
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)
|
stock = models.PositiveIntegerField(default=0)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
#časový limit (volitelné)
|
||||||
|
limited_to = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
#TODO: delete
|
||||||
default_carrier = models.ForeignKey(
|
default_carrier = models.ForeignKey(
|
||||||
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
|
"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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self):
|
||||||
return self.is_active and self.stock > 0
|
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):
|
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):
|
class Carrier(models.Model):
|
||||||
name = models.CharField(max_length=100) # název dopravce (Zásilkovna, Česká pošta…)
|
class SHIPPING(models.TextChoices):
|
||||||
base_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) # základní cena dopravy
|
ZASILKOVNA = "packeta", "Zásilkovna"
|
||||||
delivery_time = models.CharField(max_length=100, blank=True) # např. "2–3 pracovní dny"
|
DEUTSCHEPOST = "deutschepost", "Deutsche Post"
|
||||||
is_active = models.BooleanField(default=True)
|
STORE = "store", "Osobní odběr"
|
||||||
|
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
|
||||||
|
|
||||||
# pole pro logo
|
class STATE(models.TextChoices):
|
||||||
logo = models.ImageField(upload_to="carriers/", blank=True, null=True)
|
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)
|
# prodejce to přidá později
|
||||||
external_id = models.CharField(max_length=50, blank=True, null=True)
|
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):
|
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 rest_framework import serializers
|
||||||
from .models import Carrier
|
|
||||||
|
|
||||||
class CarrierSerializer(serializers.ModelSerializer):
|
from thirdparty.stripe.client import StripeClient
|
||||||
class Meta:
|
|
||||||
model = Carrier
|
|
||||||
fields = [
|
|
||||||
"id", "name", "base_price", "delivery_time",
|
|
||||||
"is_active", "logo", "external_id"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
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 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 ProductSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
read_only_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
class CarrierSerializer(serializers.ModelSerializer):
|
class DiscountCodeSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Carrier
|
model = DiscountCode
|
||||||
fields = "__all__"
|
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
0
backend/configuration/__init__.py
Normal file
0
backend/configuration/__init__.py
Normal file
59
backend/configuration/admin.py
Normal file
59
backend/configuration/admin.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import SiteConfiguration, VATRate
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
|
|
||||||
|
@admin.register(SiteConfiguration)
|
||||||
|
class SiteConfigurationAdmin(admin.ModelAdmin):
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('name', 'logo', 'favicon', 'currency')
|
||||||
|
}),
|
||||||
|
('Contact Information', {
|
||||||
|
'fields': ('contact_email', 'contact_phone', 'contact_address', 'opening_hours')
|
||||||
|
}),
|
||||||
|
('Social Media', {
|
||||||
|
'fields': ('facebook_url', 'instagram_url', 'youtube_url', 'tiktok_url', 'whatsapp_number')
|
||||||
|
}),
|
||||||
|
('Shipping Settings', {
|
||||||
|
'fields': ('zasilkovna_shipping_price', 'deutschepost_shipping_price', 'free_shipping_over')
|
||||||
|
}),
|
||||||
|
('API Credentials', {
|
||||||
|
'fields': ('zasilkovna_api_key', 'zasilkovna_api_password', 'deutschepost_client_id', 'deutschepost_client_secret', 'deutschepost_customer_ekp'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Coupon Settings', {
|
||||||
|
'fields': ('multiplying_coupons', 'addition_of_coupons_amount')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.register(VATRate)
|
||||||
|
class VATRateAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'rate', 'is_default', 'is_active', 'description')
|
||||||
|
list_filter = ('is_active', 'is_default')
|
||||||
|
search_fields = ('name', 'description')
|
||||||
|
list_editable = ('is_active',)
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
# Make is_default read-only in change form to prevent conflicts
|
||||||
|
if obj: # editing an existing object
|
||||||
|
return ('is_default',) if not obj.is_default else ()
|
||||||
|
return ()
|
||||||
|
|
||||||
|
actions = ['make_default']
|
||||||
|
|
||||||
|
def make_default(self, request, queryset):
|
||||||
|
if queryset.count() != 1:
|
||||||
|
self.message_user(request, "Select exactly one VAT rate to make default.", level='ERROR')
|
||||||
|
return
|
||||||
|
|
||||||
|
vat_rate = queryset.first()
|
||||||
|
# Clear existing defaults
|
||||||
|
VATRate.objects.filter(is_default=True).update(is_default=False)
|
||||||
|
# Set new default
|
||||||
|
vat_rate.is_default = True
|
||||||
|
vat_rate.save()
|
||||||
|
|
||||||
|
self.message_user(request, f"'{vat_rate.name}' is now the default VAT rate.")
|
||||||
|
|
||||||
|
make_default.short_description = "Make selected VAT rate the default"
|
||||||
22
backend/configuration/apps.py
Normal file
22
backend/configuration/apps.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
from django.db.models.signals import post_migrate
|
||||||
|
|
||||||
|
def create_site_config(sender, **kwargs):
|
||||||
|
"""
|
||||||
|
Ensure the SiteConfiguration singleton exists after migrations.
|
||||||
|
"""
|
||||||
|
from .models import SiteConfiguration
|
||||||
|
try:
|
||||||
|
SiteConfiguration.get_solo()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ConfigurationConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'configuration'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
# Spustí create_site_config po dokončení migrací
|
||||||
|
post_migrate.connect(create_site_config, sender=self)
|
||||||
|
|
||||||
|
|
||||||
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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/configuration/migrations/__init__.py
Normal file
0
backend/configuration/migrations/__init__.py
Normal file
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.
|
||||||
10
backend/configuration/urls.py
Normal file
10
backend/configuration/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import (
|
||||||
|
SiteConfigurationViewSet,
|
||||||
|
VATRateViewSet,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"shop-configuration", SiteConfigurationViewSet, basename="shop-config")
|
||||||
|
router.register(r"vat-rates", VATRateViewSet, basename="vat-rates")
|
||||||
|
urlpatterns = router.urls
|
||||||
74
backend/configuration/views.py
Normal file
74
backend/configuration/views.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
from rest_framework import viewsets, mixins
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
|
from account.permissions import AdminWriteOnlyOrReadOnly
|
||||||
|
from .models import SiteConfiguration, VATRate
|
||||||
|
from .serializers import (
|
||||||
|
SiteConfigurationSerializer,
|
||||||
|
VATRateSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SingletonQuerysetMixin:
|
||||||
|
def get_queryset(self):
|
||||||
|
return SiteConfiguration.objects.filter(pk=1)
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
return SiteConfiguration.get_solo()
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=["configuration"], summary="List site configuration"),
|
||||||
|
retrieve=extend_schema(tags=["configuration"], summary="Retrieve site configuration"),
|
||||||
|
create=extend_schema(tags=["configuration"], summary="Create site configuration (admin only)"),
|
||||||
|
partial_update=extend_schema(tags=["configuration"], summary="Update site configuration (admin only)"),
|
||||||
|
update=extend_schema(tags=["configuration"], summary="Replace site configuration (admin only)"),
|
||||||
|
destroy=extend_schema(tags=["configuration"], summary="Delete site configuration (admin only)"),
|
||||||
|
)
|
||||||
|
class SiteConfigurationViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
|
||||||
|
permission_classes = [AdminWriteOnlyOrReadOnly]
|
||||||
|
serializer_class = SiteConfigurationSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema_view(
|
||||||
|
list=extend_schema(tags=["configuration"], summary="List VAT rates"),
|
||||||
|
retrieve=extend_schema(tags=["configuration"], summary="Retrieve VAT rate"),
|
||||||
|
create=extend_schema(tags=["configuration"], summary="Create VAT rate (admin only)"),
|
||||||
|
partial_update=extend_schema(tags=["configuration"], summary="Update VAT rate (admin only)"),
|
||||||
|
update=extend_schema(tags=["configuration"], summary="Replace VAT rate (admin only)"),
|
||||||
|
destroy=extend_schema(tags=["configuration"], summary="Delete VAT rate (admin only)"),
|
||||||
|
)
|
||||||
|
class VATRateViewSet(viewsets.ModelViewSet):
|
||||||
|
"""VAT rate management - read for all, write for admins only"""
|
||||||
|
permission_classes = [AdminWriteOnlyOrReadOnly]
|
||||||
|
serializer_class = VATRateSerializer
|
||||||
|
queryset = VATRate.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Admins see all rates, others see only active ones"""
|
||||||
|
if self.request.user.is_authenticated and getattr(self.request.user, 'role', None) == 'admin':
|
||||||
|
return VATRate.objects.all()
|
||||||
|
return VATRate.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["configuration"],
|
||||||
|
summary="Make VAT rate the default (admin only)",
|
||||||
|
description="Set this VAT rate as the default for new products"
|
||||||
|
)
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def make_default(self, request, pk=None):
|
||||||
|
"""Make this VAT rate the default"""
|
||||||
|
vat_rate = self.get_object()
|
||||||
|
|
||||||
|
# Clear existing defaults
|
||||||
|
VATRate.objects.filter(is_default=True).update(is_default=False)
|
||||||
|
|
||||||
|
# Set new default
|
||||||
|
vat_rate.is_default = True
|
||||||
|
vat_rate.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': f'"{vat_rate.name}" is now the default VAT rate',
|
||||||
|
'default_rate_id': vat_rate.id
|
||||||
|
})
|
||||||
1
backend/globalstaticfiles/filler
Normal file
1
backend/globalstaticfiles/filler
Normal file
@@ -0,0 +1 @@
|
|||||||
|
filler
|
||||||
@@ -45,6 +45,12 @@ daphne
|
|||||||
|
|
||||||
gunicorn
|
gunicorn
|
||||||
|
|
||||||
|
# -- THIRD PARTY APIS --
|
||||||
|
# Deutsche Post International Shipping API client (local package)
|
||||||
|
httpx>=0.23.0,<0.29.0 # Required by Deutsche Post client
|
||||||
|
attrs>=22.2.0 # Required by Deutsche Post client
|
||||||
|
python-dateutil>=2.8.0 # Required by Deutsche Post client
|
||||||
|
|
||||||
# -- REST API --
|
# -- REST API --
|
||||||
djangorestframework #REST Framework
|
djangorestframework #REST Framework
|
||||||
|
|
||||||
@@ -71,6 +77,8 @@ django-cors-headers #csfr
|
|||||||
celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.)
|
celery #slouží k vytvaření asynchoních úkolu (třeba každou hodinu vyčistit cache atd.)
|
||||||
django-celery-beat #slouží k plánování úkolů pro Celery
|
django-celery-beat #slouží k plánování úkolů pro Celery
|
||||||
|
|
||||||
|
django-silk
|
||||||
|
django-silk[formatting]
|
||||||
|
|
||||||
# -- EDITING photos, gifs, videos --
|
# -- EDITING photos, gifs, videos --
|
||||||
|
|
||||||
@@ -86,6 +94,14 @@ weasyprint #tvoření PDFek z html dokumentu + css styly
|
|||||||
|
|
||||||
faker #generates fake data for testing purposes
|
faker #generates fake data for testing purposes
|
||||||
|
|
||||||
## -- api --
|
zeep #SOAP tool
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## -- API --
|
||||||
|
|
||||||
|
#generates api (if schema exists (ONLY USE OPENAPI NO SWAGGER))
|
||||||
|
openapi-python-client
|
||||||
|
|
||||||
stripe
|
stripe
|
||||||
gopay
|
gopay
|
||||||
@@ -5,23 +5,147 @@ from account.models import UserProfile
|
|||||||
|
|
||||||
from channels.db import database_sync_to_async
|
from channels.db import database_sync_to_async
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from asgiref.sync import sync_to_async, async_to_sync
|
||||||
|
|
||||||
|
|
||||||
class ChatConsumer(AsyncWebsocketConsumer):
|
class ChatConsumer(AsyncWebsocketConsumer):
|
||||||
|
# -- CONNECT --
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
|
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
|
||||||
|
self.chat_name = f"chat_{self.chat_id}"
|
||||||
|
|
||||||
|
user = self.scope["user"]
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
await self.close(code=4401) # unauthorized
|
||||||
|
return
|
||||||
|
|
||||||
|
#join chat group
|
||||||
|
async_to_sync(self.channel_layer.group_add)(
|
||||||
|
self.chat_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await self.accept()
|
await self.accept()
|
||||||
|
|
||||||
|
# -- DISCONNECT --
|
||||||
async def disconnect(self, close_code):
|
async def disconnect(self, close_code):
|
||||||
|
async_to_sync(self.channel_layer.group_discard)(
|
||||||
|
self.chat_name
|
||||||
|
)
|
||||||
|
|
||||||
|
self.disconnect()
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def receive(self, text_data):
|
# -- RECIVE --
|
||||||
text_data_json = json.loads(text_data)
|
async def receive(self, data):
|
||||||
message = text_data_json["message"]
|
if data["type"] == "new_chat_message":
|
||||||
|
|
||||||
await self.send(text_data=json.dumps({"message": message}))
|
message = data["message"]
|
||||||
|
|
||||||
|
# Send message to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "chat.message", "message": message}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "new_reply_chat_message":
|
||||||
|
message = data["message"]
|
||||||
|
reply_to_id = data["reply_to_id"]
|
||||||
|
|
||||||
|
# Send message to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "reply.chat.message", "message": message, "reply_to_id": reply_to_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "edit_chat_message":
|
||||||
|
message = data["message"]
|
||||||
|
|
||||||
|
# Send message to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "edit.message", "message": message}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "delete_chat_message":
|
||||||
|
message_id = data["message_id"]
|
||||||
|
|
||||||
|
# Send message to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "delete.message", "message_id": message_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "typing":
|
||||||
|
is_typing = data["is_typing"]
|
||||||
|
|
||||||
|
# Send typing status to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "typing.status", "user": self.scope["user"].username, "is_typing": is_typing}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "stop_typing":
|
||||||
|
# Send stop typing status to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "stop.typing", "user": self.scope["user"].username}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "reaction":
|
||||||
|
message_id = data["message_id"]
|
||||||
|
emoji = data["emoji"]
|
||||||
|
|
||||||
|
# Send reaction to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "message.reaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif data["type"] == "unreaction":
|
||||||
|
message_id = data["message_id"]
|
||||||
|
emoji = data["emoji"]
|
||||||
|
|
||||||
|
# Send unreaction to room group
|
||||||
|
async_to_sync(self.channel_layer.group_send)(
|
||||||
|
self.chat_name, {"type": "message.unreaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.close(reason="Unsupported message type")
|
||||||
|
|
||||||
|
|
||||||
|
# -- CUSTOM METHODS --
|
||||||
|
|
||||||
|
def send_message_to_chat_group(self, event):
|
||||||
|
message = event["message"]
|
||||||
|
create_new_message()
|
||||||
|
self.send(text_data=json.dumps({"message": message}))
|
||||||
|
|
||||||
|
def edit_message_in_chat_group(self, event):
|
||||||
|
message = event["message"]
|
||||||
|
self.send(text_data=json.dumps({"message": message}))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# -- MESSAGES --
|
||||||
|
@database_sync_to_async
|
||||||
|
def create_new_message():
|
||||||
|
return None
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def get_user_profile(user_id):
|
def create_new_reply_message():
|
||||||
return UserProfile.objects.get(pk=user_id)
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def edit_message():
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def delete_message():
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# -- REACTIONS --
|
||||||
|
@database_sync_to_async
|
||||||
|
def react_to_message():
|
||||||
|
return None
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def unreact_to_message():
|
||||||
|
return None
|
||||||
77
backend/social/chat/migrations/0001_initial.py
Normal file
77
backend/social/chat/migrations/0001_initial.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-01-24 22:44
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Chat',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('members', models.ManyToManyField(blank=True, related_name='chats', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('moderators', models.ManyToManyField(blank=True, related_name='moderated_chats', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_chats', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Message',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('content', models.TextField(blank=True)),
|
||||||
|
('is_edited', models.BooleanField(default=False)),
|
||||||
|
('edited_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat')),
|
||||||
|
('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replies', to='chat.message')),
|
||||||
|
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageFile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file', models.FileField(upload_to='chat_uploads/%Y/%m/%d/')),
|
||||||
|
('media_type', models.CharField(choices=[('IMAGE', 'Image'), ('VIDEO', 'Video'), ('FILE', 'File')], default='FILE', max_length=20)),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='chat.message')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('old_content', models.TextField()),
|
||||||
|
('archived_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edit_history', to='chat.message')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-archived_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MessageReaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('emoji', models.CharField(max_length=10)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='chat.message')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('message', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,3 +1,187 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
# Create your models here.
|
class Chat(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='owned_chats'
|
||||||
|
)
|
||||||
|
|
||||||
|
members = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
related_name='chats',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
moderators = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
related_name='moderated_chats',
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
is_new = self._state.adding
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# LOGIC: Ensure owner is always a member and moderator
|
||||||
|
if is_new and self.owner:
|
||||||
|
self.members.add(self.owner)
|
||||||
|
self.moderators.add(self.owner)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Chat {self.id}"
|
||||||
|
|
||||||
|
|
||||||
|
class Message(models.Model):
|
||||||
|
chat = models.ForeignKey(Chat, related_name='messages', on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
sender = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='sent_messages'
|
||||||
|
)
|
||||||
|
|
||||||
|
#odpověď na jinou zprávu
|
||||||
|
reply_to = models.ForeignKey(
|
||||||
|
'self',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='replies'
|
||||||
|
)
|
||||||
|
|
||||||
|
content = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# --- TRACKING EDIT STATUS ---
|
||||||
|
# We add these so the frontend doesn't need to check MessageHistory table
|
||||||
|
is_edited = models.BooleanField(default=False)
|
||||||
|
edited_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
# VALIDATION: Ensure sender is actually in the chat
|
||||||
|
# Note: We check self.id to avoid running this on creation if logic depends on M2M
|
||||||
|
# But generally, a sender must be a member.
|
||||||
|
if self.chat and self.sender:
|
||||||
|
if not self.chat.members.filter(id=self.sender.id).exists():
|
||||||
|
raise ValidationError("Sender is not a member of this chat.")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Optional: Run validation before saving
|
||||||
|
# self.full_clean()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# --- HELPER METHODS FOR WEBSOCKETS / VIEWS ---
|
||||||
|
|
||||||
|
def edit_content(self, new_text):
|
||||||
|
"""
|
||||||
|
Handles the complex logic of editing:
|
||||||
|
1. Checks if text actually changed.
|
||||||
|
2. Saves old text to History.
|
||||||
|
3. Updates current text and timestamps.
|
||||||
|
"""
|
||||||
|
if self.content == new_text:
|
||||||
|
return False # No change happened
|
||||||
|
|
||||||
|
# 1. Save History
|
||||||
|
MessageHistory.objects.create(
|
||||||
|
message=self,
|
||||||
|
old_content=self.content
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Update Self
|
||||||
|
self.content = new_text
|
||||||
|
self.is_edited = True
|
||||||
|
self.edited_at = timezone.now()
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def toggle_reaction(self, user, emoji):
|
||||||
|
"""
|
||||||
|
Handles Add/Remove/Switch logic.
|
||||||
|
Returns a tuple: (action, reaction_object)
|
||||||
|
action can be: 'added', 'removed', 'switched'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
reaction = MessageReaction.objects.get(message=self, user=user)
|
||||||
|
|
||||||
|
if reaction.emoji == emoji:
|
||||||
|
# Same emoji -> Remove it (Toggle)
|
||||||
|
reaction.delete()
|
||||||
|
return 'removed', None
|
||||||
|
else:
|
||||||
|
# Different emoji -> Switch it
|
||||||
|
reaction.emoji = emoji
|
||||||
|
reaction.save()
|
||||||
|
return 'switched', reaction
|
||||||
|
|
||||||
|
except MessageReaction.DoesNotExist:
|
||||||
|
# New reaction -> Create it
|
||||||
|
reaction = MessageReaction.objects.create(
|
||||||
|
message=self,
|
||||||
|
user=user,
|
||||||
|
emoji=emoji
|
||||||
|
)
|
||||||
|
return 'added', reaction
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Message {self.id} from {self.sender}"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageHistory(models.Model):
|
||||||
|
message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='edit_history'
|
||||||
|
)
|
||||||
|
old_content = models.TextField()
|
||||||
|
archived_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-archived_at']
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReaction(models.Model):
|
||||||
|
message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='reactions'
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
|
emoji = models.CharField(max_length=10)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('message', 'user')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} reacted {self.emoji}"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFile(models.Model):
|
||||||
|
message = models.ForeignKey(
|
||||||
|
Message,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='media_files'
|
||||||
|
)
|
||||||
|
file = models.FileField(upload_to='chat_uploads/%Y/%m/%d/')
|
||||||
|
|
||||||
|
media_type = models.CharField(max_length=20, choices=[
|
||||||
|
('IMAGE', 'Image'),
|
||||||
|
('VIDEO', 'Video'),
|
||||||
|
('FILE', 'File')
|
||||||
|
], default='FILE')
|
||||||
|
|
||||||
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Media {self.id} for Message {self.message.id}"
|
||||||
@@ -4,5 +4,5 @@ from django.urls import re_path
|
|||||||
from . import consumers
|
from . import consumers
|
||||||
|
|
||||||
websocket_urlpatterns = [
|
websocket_urlpatterns = [
|
||||||
re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
|
re_path(r"ws/chat/(?P<chat_id>\w+)/$", consumers.ChatConsumer.as_asgi()),
|
||||||
]
|
]
|
||||||
@@ -1,3 +1,25 @@
|
|||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_chats(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_chat(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def invite_user_to_chat(request, chat_id: int, user_ids: list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_chat(request, chat_id: int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def leave_chat(request, chat_id: int):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def edit_chat(request, chat_object):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_chat_messages(request, chat_id: int, limit: int = 50, offset: int = 0):
|
||||||
|
return None
|
||||||
0
backend/social/pages/__init__.py
Normal file
0
backend/social/pages/__init__.py
Normal file
3
backend/social/pages/admin.py
Normal file
3
backend/social/pages/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/social/pages/apps.py
Normal file
6
backend/social/pages/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PagesConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'pages'
|
||||||
0
backend/social/pages/migrations/__init__.py
Normal file
0
backend/social/pages/migrations/__init__.py
Normal file
3
backend/social/pages/models.py
Normal file
3
backend/social/pages/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
3
backend/social/pages/tests.py
Normal file
3
backend/social/pages/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
backend/social/pages/views.py
Normal file
3
backend/social/pages/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
backend/social/posts/__init__.py
Normal file
0
backend/social/posts/__init__.py
Normal file
3
backend/social/posts/admin.py
Normal file
3
backend/social/posts/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
backend/social/posts/apps.py
Normal file
6
backend/social/posts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PostsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'posts'
|
||||||
0
backend/social/posts/migrations/__init__.py
Normal file
0
backend/social/posts/migrations/__init__.py
Normal file
3
backend/social/posts/models.py
Normal file
3
backend/social/posts/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
3
backend/social/posts/tests.py
Normal file
3
backend/social/posts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
backend/social/posts/views.py
Normal file
3
backend/social/posts/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
51
backend/templates/email/components/base.html
Normal file
51
backend/templates/email/components/base.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Email</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||||
|
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f2f2f2;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:20px 0;">
|
||||||
|
|
||||||
|
<!-- HLAVNÍ KONTEJNER -->
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff; border-collapse:collapse;">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0; margin:0;">
|
||||||
|
{% include 'email/components/header.html' %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- CONTENT -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; line-height:20px; color:#000000;">
|
||||||
|
|
||||||
|
{% if content_template %}
|
||||||
|
{% include content_template %}
|
||||||
|
{% else %}
|
||||||
|
<p>missing content_template !!!</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0; margin:0;">
|
||||||
|
{% include 'email/components/footer.html' %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
backend/templates/email/components/footer.html
Normal file
8
backend/templates/email/components/footer.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#eeeeee;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:15px; font-size:12px; color:#666666; font-family:Arial, Helvetica, sans-serif;">
|
||||||
|
Tento e-mail byl odeslán automaticky.<br>
|
||||||
|
© {{ site_name|default:"E-shop" }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
9
backend/templates/email/components/header.html
Normal file
9
backend/templates/email/components/header.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#222;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:20px; color:#ffffff; font-family:Arial, Helvetica, sans-serif;">
|
||||||
|
<h2 style="margin:0; font-size:22px;">
|
||||||
|
{{ site_name|default:"E-shop" }}
|
||||||
|
</h2>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
78
backend/templates/email/email_verification.html
Normal file
78
backend/templates/email/email_verification.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<style>
|
||||||
|
.verification-container {
|
||||||
|
text-align: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.verification-title {
|
||||||
|
color: #333;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.verification-text {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 30px 0;
|
||||||
|
}
|
||||||
|
.verification-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
.verification-info {
|
||||||
|
color: #0c5460;
|
||||||
|
background-color: #d1ecf1;
|
||||||
|
border: 1px solid #bee5eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.welcome-note {
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="verification-container">
|
||||||
|
<h1 class="verification-title">✉️ Ověření e-mailové adresy</h1>
|
||||||
|
|
||||||
|
<p class="verification-text">
|
||||||
|
Vítejte {{ user.first_name|default:user.username }}!<br><br>
|
||||||
|
Děkujeme za registraci. Pro dokončení vytvoření účtu je nutné
|
||||||
|
ověřit vaši e-mailovou adresu kliknutím na tlačítko níže.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{{ action_url }}" class="verification-button">{{ cta_label }}</a>
|
||||||
|
|
||||||
|
<div class="welcome-note">
|
||||||
|
<strong>🎉 Těšíme se na vás!</strong><br>
|
||||||
|
Po ověření e-mailu budete moci využívat všechny funkce naší platformy
|
||||||
|
a začít nakupovat nebo prodávat.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="verification-info">
|
||||||
|
<strong>ℹ️ Co dělat, když tlačítko nefunguje?</strong><br>
|
||||||
|
Zkopírujte a vložte následující odkaz do adresního řádku prohlížeče:
|
||||||
|
<br><br>
|
||||||
|
<span style="word-break: break-all; font-family: monospace; font-size: 12px;">{{ action_url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="verification-text" style="font-size: 14px; color: #999;">
|
||||||
|
Odkaz pro ověření je platný po omezenou dobu.
|
||||||
|
Pokud jste se neregistrovali na našich stránkách, ignorujte tento e-mail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
75
backend/templates/email/password_reset.html
Normal file
75
backend/templates/email/password_reset.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<style>
|
||||||
|
.reset-container {
|
||||||
|
text-align: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
.reset-title {
|
||||||
|
color: #333;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.reset-text {
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 30px 0;
|
||||||
|
}
|
||||||
|
.reset-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 12px 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
.reset-warning {
|
||||||
|
color: #856404;
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.security-note {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="reset-container">
|
||||||
|
<h1 class="reset-title">🔐 Obnova hesla</h1>
|
||||||
|
|
||||||
|
<p class="reset-text">
|
||||||
|
Dobrý den {{ user.first_name|default:user.username }},<br><br>
|
||||||
|
Obdrželi jsme požadavek na obnovení hesla k vašemu účtu.
|
||||||
|
Klikněte na tlačítko níže pro vytvoření nového hesla.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{{ action_url }}" class="reset-button">{{ cta_label }}</a>
|
||||||
|
|
||||||
|
<div class="reset-warning">
|
||||||
|
<strong>⚠️ Bezpečnostní upozornění:</strong><br>
|
||||||
|
Pokud jste o obnovu hesla nepožádali, ignorujte tento e-mail.
|
||||||
|
Váše heslo zůstane nezměněno.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="reset-text">
|
||||||
|
Odkaz pro obnovu hesla je platný pouze po omezenou dobu.
|
||||||
|
Pokud odkaz nefunguje, zkopírujte a vložte následující adresu do prohlížeče:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="word-break: break-all; color: #007bff; font-size: 14px;">
|
||||||
|
{{ action_url }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="security-note">
|
||||||
|
Tento odkaz je určen pouze pro vás a nelze ho sdílet s ostatními.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
0
backend/thirdparty/deutschepost/__init__.py
vendored
Normal file
0
backend/thirdparty/deutschepost/__init__.py
vendored
Normal file
183
backend/thirdparty/deutschepost/admin.py
vendored
Normal file
183
backend/thirdparty/deutschepost/admin.py
vendored
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
|
from .models import DeutschePostOrder, DeutschePostBulkOrder
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeutschePostOrder)
|
||||||
|
class DeutschePostOrderAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
'id', 'order_id', 'recipient_name', 'state', 'commerce_order_link',
|
||||||
|
'awb_number', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = ['state', 'product_type', 'service_level', 'destination_country', 'created_at']
|
||||||
|
search_fields = ['order_id', 'recipient_name', 'recipient_email', 'awb_number', 'barcode']
|
||||||
|
readonly_fields = [
|
||||||
|
'order_id', 'awb_number', 'barcode', 'tracking_url',
|
||||||
|
'metadata', 'last_error', 'created_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('state', 'commerce_order', 'order_id', 'customer_ekp')
|
||||||
|
}),
|
||||||
|
('Recipient Information', {
|
||||||
|
'fields': (
|
||||||
|
'recipient_name', 'recipient_phone', 'recipient_email',
|
||||||
|
'address_line1', 'address_line2', 'address_line3',
|
||||||
|
'city', 'address_state', 'postal_code', 'destination_country'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Shipment Details', {
|
||||||
|
'fields': (
|
||||||
|
'product_type', 'service_level', 'shipment_gross_weight',
|
||||||
|
'shipment_amount', 'shipment_currency',
|
||||||
|
'sender_tax_id', 'importer_tax_id', 'return_item_wanted',
|
||||||
|
'cust_ref'
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Tracking Information', {
|
||||||
|
'fields': ('awb_number', 'barcode', 'tracking_url'),
|
||||||
|
'classes': ['collapse']
|
||||||
|
}),
|
||||||
|
('API Data', {
|
||||||
|
'fields': ('metadata', 'last_error', 'created_at'),
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = ['create_remote_order', 'finalize_remote_order', 'refresh_tracking']
|
||||||
|
|
||||||
|
def commerce_order_link(self, obj):
|
||||||
|
"""Link to related commerce order."""
|
||||||
|
if obj.commerce_order:
|
||||||
|
url = reverse('admin:commerce_order_change', args=[obj.commerce_order.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.commerce_order)
|
||||||
|
return '-'
|
||||||
|
commerce_order_link.short_description = 'Commerce Order'
|
||||||
|
|
||||||
|
def create_remote_order(self, request, queryset):
|
||||||
|
"""Admin action to create orders remotely."""
|
||||||
|
created_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for order in queryset:
|
||||||
|
try:
|
||||||
|
if not order.order_id:
|
||||||
|
order.create_remote_order()
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error creating order {order.id}: {str(e)}')
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
if created_count:
|
||||||
|
messages.success(request, f'{created_count} orders created remotely')
|
||||||
|
if error_count:
|
||||||
|
messages.warning(request, f'{error_count} orders could not be created')
|
||||||
|
|
||||||
|
create_remote_order.short_description = 'Create selected orders remotely'
|
||||||
|
|
||||||
|
def finalize_remote_order(self, request, queryset):
|
||||||
|
"""Admin action to finalize orders remotely."""
|
||||||
|
finalized_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for order in queryset:
|
||||||
|
try:
|
||||||
|
if order.order_id and order.state != DeutschePostOrder.STATE.FINALIZED:
|
||||||
|
order.finalize_remote_order()
|
||||||
|
finalized_count += 1
|
||||||
|
else:
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error finalizing order {order.id}: {str(e)}')
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
if finalized_count:
|
||||||
|
messages.success(request, f'{finalized_count} orders finalized')
|
||||||
|
if error_count:
|
||||||
|
messages.warning(request, f'{error_count} orders could not be finalized')
|
||||||
|
|
||||||
|
finalize_remote_order.short_description = 'Finalize selected orders'
|
||||||
|
|
||||||
|
def refresh_tracking(self, request, queryset):
|
||||||
|
"""Admin action to refresh tracking information."""
|
||||||
|
updated_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for order in queryset:
|
||||||
|
try:
|
||||||
|
if order.order_id:
|
||||||
|
order.refresh_tracking()
|
||||||
|
updated_count += 1
|
||||||
|
else:
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error refreshing tracking for order {order.id}: {str(e)}')
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
if updated_count:
|
||||||
|
messages.success(request, f'{updated_count} orders tracking updated')
|
||||||
|
if error_count:
|
||||||
|
messages.warning(request, f'{error_count} orders could not be updated')
|
||||||
|
|
||||||
|
refresh_tracking.short_description = 'Refresh tracking for selected orders'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeutschePostBulkOrder)
|
||||||
|
class DeutschePostBulkOrderAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['id', 'bulk_order_id', 'status', 'orders_count', 'created_at']
|
||||||
|
list_filter = ['status', 'bulk_order_type', 'created_at']
|
||||||
|
search_fields = ['bulk_order_id', 'description']
|
||||||
|
readonly_fields = ['bulk_order_id', 'orders_count', 'metadata', 'last_error', 'created_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Basic Information', {
|
||||||
|
'fields': ('status', 'bulk_order_id', 'bulk_order_type', 'description')
|
||||||
|
}),
|
||||||
|
('Orders', {
|
||||||
|
'fields': ('deutschepost_orders', 'orders_count')
|
||||||
|
}),
|
||||||
|
('API Data', {
|
||||||
|
'fields': ('metadata', 'last_error', 'created_at'),
|
||||||
|
'classes': ['collapse']
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_horizontal = ['deutschepost_orders']
|
||||||
|
actions = ['create_remote_bulk_order']
|
||||||
|
|
||||||
|
def orders_count(self, obj):
|
||||||
|
"""Count of orders in this bulk order."""
|
||||||
|
return obj.deutschepost_orders.count()
|
||||||
|
orders_count.short_description = 'Orders Count'
|
||||||
|
|
||||||
|
def create_remote_bulk_order(self, request, queryset):
|
||||||
|
"""Admin action to create bulk orders remotely."""
|
||||||
|
created_count = 0
|
||||||
|
error_count = 0
|
||||||
|
|
||||||
|
for bulk_order in queryset:
|
||||||
|
try:
|
||||||
|
if not bulk_order.bulk_order_id:
|
||||||
|
bulk_order.create_remote_bulk_order()
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
error_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(request, f'Error creating bulk order {bulk_order.id}: {str(e)}')
|
||||||
|
error_count += 1
|
||||||
|
|
||||||
|
if created_count:
|
||||||
|
messages.success(request, f'{created_count} bulk orders created remotely')
|
||||||
|
if error_count:
|
||||||
|
messages.warning(request, f'{error_count} bulk orders could not be created')
|
||||||
|
|
||||||
|
create_remote_bulk_order.short_description = 'Create selected bulk orders remotely'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user