Compare commits

...

59 Commits

Author SHA1 Message Date
27b346c1f6 Update models.py 2026-01-22 16:55:45 +01:00
963ba6b824 Add weekly new products email and related features
Introduces a weekly summary email for newly added products, including a new email template and Celery periodic task. Adds 'include_in_week_summary_email' to Product and 'newsletter' to CustomUser. Provides an admin endpoint to manually trigger the weekly email, updates Celery Beat schedule, and adds email templates for verification and password reset.
2026-01-22 00:22:21 +01:00
c0bd24ee5e Refactor commerce models and enhance payment logic
Refactored commerce models to remove language prefixes from status choices, improved order and payment validation, and enforced business rules for payment and shipping combinations. Updated order item and cart calculations to use VAT-inclusive prices, added unique constraints and indexes to reviews, and improved stock management logic. Added new Stripe client methods for session and refund management, and updated Zasilkovna and Deutsche Post models for consistency. Minor fixes and improvements across related tasks, URLs, and configuration models.
2026-01-20 23:45:21 +01:00
David Bruno Vontor
b38d126b6c Update main.tsx 2026-01-20 15:05:20 +01:00
2a26edac80 Add comprehensive analytics and VAT rate management
Introduced a full-featured analytics module for e-commerce business intelligence, including sales, product, customer, shipping, and review analytics, with API endpoints for dashboard and custom reports. Added VAT rate management: new VATRate model, admin interface, serializer, and API endpoints, and integrated VAT logic into Product and pricing calculations. Refactored configuration and admin code to support VAT rates, improved email notification tasks, and updated related serializers, views, and URLs for unified configuration and VAT management.
2026-01-19 02:13:47 +01:00
e78baf746c Add wishlist feature and admin/analytics endpoints
Introduces a Wishlist model with related serializers, admin, and API endpoints for users to manage favorite products. Adds admin endpoints for wishlist management and a placeholder AnalyticsViewSet for future business intelligence features. Refactors permissions for commerce views, updates product filtering and ordering, and improves carrier and payment logic. Also includes minor VSCode settings and Zasilkovna client import updates.
2026-01-17 18:04:27 +01:00
b279ac36d5 Add shopping cart and product review features
Introduces Cart and CartItem models, admin, serializers, and API endpoints for shopping cart management for both authenticated and anonymous users. Adds Review model, serializers, and endpoints for product reviews, including public creation and retrieval. Updates ProductImage ordering, enhances order save logic with notification, and improves product and order endpoints with new actions and filters. Includes related migrations for commerce, configuration, social chat, and Deutsche Post integration.
2026-01-17 02:38:02 +01:00
98426f8b05 Add product review model, serializer, and API endpoint
Introduces a Review model for product reviews, including rating and comment fields. Adds a public serializer and a ModelViewSet for reviews with search and ordering capabilities. Also updates the frontend API client to use the correct token refresh endpoint and improves FormData handling.
2026-01-14 00:10:46 +01:00
2213e115c6 Integrate Deutsche Post shipping API and models
Added Deutsche Post as a shipping carrier, including new models, admin, serializers, and API client integration. Updated Carrier and SiteConfiguration models to support Deutsche Post, including shipping price and API credentials. Added requirements for the Deutsche Post API client and dependencies.
2026-01-11 16:32:51 +01:00
David Bruno Vontor
7ebc83dd8c Update tokens.py 2026-01-07 13:03:32 +01:00
David Bruno Vontor
c6ca9e2741 Update copilot-instructions.md 2026-01-06 16:42:29 +01:00
4f56f4bbc5 Implement user authentication and account pages
Adds AuthContext with Orval API integration, user login/logout/register flows, and account settings page. Refactors navigation and layouts to use authentication context. Introduces new account-related pages (Login, Register, Logout, AccountSettings), updates PrivateRoute to use AuthContext, and improves error handling in Downloader. Removes obsolete context example and adds comprehensive usage documentation for AuthContext.
2026-01-05 11:53:44 +01:00
f7605812c1 Implement chat backend and frontend scaffolding
Expanded chat backend with message reply, edit, delete, and reaction support in consumers and models. Updated routing to use chat_id. Added chat-related view stubs. On the frontend, introduced ChatLayout and ChatPage scaffolding, and routed protected routes through ChatLayout.
2025-12-26 17:39:11 +01:00
deb853b564 Add chat models and scaffold pages/posts apps
Implemented comprehensive models for chat functionality, including Chat, Message, MessageHistory, MessageReaction, and MessageFile. Updated ChatConsumer to enforce authentication and improve message handling. Added initial scaffolding for 'pages' and 'posts' Django apps with basic files.
2025-12-26 04:48:39 +01:00
00271e59e4 Add state enums and HomeLayout styling improvements
Introduced generated stateE15Enum and stateFdaEnum TypeScript types for both private and public API models. Updated HomeLayout to use a new CSS module for layout styling, and adjusted navbar centering logic. Commented out reset.css in index.html and made minor CSS cleanups.
2025-12-25 04:54:44 +01:00
264f0116ae Add playlist support to downloader API and frontend
Enhanced the downloader backend and frontend to support playlist URLs for video info and downloads. The API now returns structured playlist information, allows selecting specific videos for download, and returns a ZIP file for playlist downloads. Updated OpenAPI types, removed deprecated parameters (start_time, end_time, playlist_items), and improved Content Security Policy handling in nginx. Refactored frontend to handle playlist selection and updated generated API models accordingly.
2025-12-25 04:54:27 +01:00
cf615c5279 Add Node.js to backend Dockerfile and enhance downloader
Added Node.js installation to the backend Dockerfile to support yt-dlp's JavaScript runtime. Updated downloader API to bypass SSL verification in Docker, improved error reporting, and convert video thumbnails to data URLs to avoid mixed content issues. In the frontend, improved Dockerfile.prod install process and added new service routes for drone and web services in App.tsx.
2025-12-23 13:37:24 +01:00
1cec6be6d7 feat(api): generate API models and hooks for public shop configuration and commerce entities
- Added generated API hooks and models for public shop configuration, including listing and retrieving configurations.
- Introduced models for commerce categories, discount codes, orders, product images, and products with pagination and search parameters.
- Ensured all generated files are structured for easy integration with React Query.
2025-12-22 02:20:43 +01:00
abc6207296 Refactor API hooks to remove infinite query support and update API client base URL
- Removed infinite query options and related functions from Trading212, User Registration, and User API files.
- Updated API client base URLs in privateClient.ts and publicClient.ts to use environment variable for backend URL.
- Refactored Downloader component to directly call API functions for video info retrieval instead of using a hook.
2025-12-21 16:37:56 +01:00
9c48aee522 feat(api): generate models for patched products, refunds, site configurations, payments, and user registration
- Added PatchedProduct, PatchedProductImage, PatchedRefund, and related models.
- Introduced Payment, PaymentBody, PaymentCreate, and PaymentRead models.
- Created enums for payment methods, reasons for refunds, roles, and shipping methods.
- Implemented models for site configurations and their opening hours.
- Added ZasilkovnaPacket and ZasilkovnaShipment models for handling shipping data.
- Generated user registration model with validation rules.
- Updated public API functions to support new models and queries.
2025-12-21 04:42:15 +01:00
0346180d01 feat: add prettier for code formatting
refactor: update route for downloader to apps/downloader

chore: remove unused filler model files

refactor: delete Default layout component and its imports
2025-12-20 23:18:20 +01:00
713c94d7e9 Refactor Stripe payment handling and order status
Refactored Stripe payment integration to use a dedicated Stripe model for session data, updating the PaymentSerializer accordingly. Renamed Order.Status to Order.OrderStatus for clarity. Updated configuration app to ensure SiteConfiguration singleton is created post-migration. Removed obsolete seed_app_config command from docker-compose. Adjusted frontend API generated files and moved orval config.
2025-12-19 14:08:40 +01:00
David Bruno Vontor
2498386477 Update 0001_initial.py 2025-12-19 10:08:08 +01:00
72155d4560 Refactor Zasilkovna client, update imports and cleanup
Refactored the Zasilkovna SOAP client to use a singleton pattern with caching and lazy loading, improving reliability and startup performance. Updated invoice PDF generation to import WeasyPrint lazily, preventing startup failures on systems missing dependencies. Cleaned up unused imports and code in several frontend components, removed unused state and variables, and adjusted Docker frontend port mapping. Also updated Django migration files to reflect a new generation timestamp.
2025-12-18 16:23:35 +01:00
1751badb90 Refactor frontend components and backend migrations
- Removed TradingGraph component from frontend/src/components/trading.
- Updated home page to import Services component and TradingGraph from new path.
- Modified PortfolioPage to return null instead of PortfolioGrid.
- Added initial migrations for account, advertisement, commerce, configuration, downloader, gopay, stripe, trading212, and zasilkovna apps in the backend.
- Created Services component with subcomponents for Kinematografie, Drone Service, and Website Service.
- Implemented TradingGraph component with dynamic data generation and canvas rendering.
- Updated DonationShop component to display donation tiers with icons and descriptions.
2025-12-14 03:49:16 +01:00
564418501c Refactor email system and add contact form backend
Refactored email sending to use a single HTML template with a base layout, removed plain text email templates, and updated all related backend logic. Introduced a new ContactMe model, serializer, Celery task, and API endpoints for handling contact form submissions, including email notifications. Renamed ShopConfiguration to SiteConfiguration throughout the backend for consistency. Updated frontend to remove unused components, add a new Services section, and adjust navigation and contact form integration.
2025-12-12 01:52:41 +01:00
David Bruno Vontor
df83288591 Improve mobile navbar and user area styling
Navbar now applies a mobileNavOpen style when the mobile menu is open, improving mobile compatibility. User area elements are moved into the link group for better mobile layout, and several CSS adjustments were made for smoother transitions and consistent icon sizing.
2025-12-11 14:32:19 +01:00
b4e50eda30 Refactor navbar and remove legacy API/context
Replaces HomeNav with a new SiteNav and associated CSS module, updating navigation structure and user menu. Removes legacy API client, downloader, user model, and UserContext in favor of a new AuthContext stub for future authentication logic. Also cleans up HeroCarousel and minor CSS fixes.
2025-12-11 02:45:28 +01:00
a2bc1e68ee Refactor footer and remove unused pages
Updated the footer component and its styles for improved layout and clarity, including new contact and service sections. Removed unused pages: DonateShopPage, SkillsPage, and related CSS/JS files, and cleaned up routing in App.tsx. Also streamlined Home.module.css by merging introduction styles and removing redundant imports.
2025-12-10 03:24:31 +01:00
David Bruno Vontor
ada74c84a6 Update models.py 2025-12-08 18:19:30 +01:00
David Bruno Vontor
946f86db7e Refactor order creation and add configuration endpoints
Refactored order creation logic to use new serializers and transaction handling, improving validation and modularity. Introduced admin and public endpoints for shop configuration with sensitive fields protected. Enhanced Zásilkovna (Packeta) integration, including packet widget template, new API fields, and improved error handling. Added django-silk for profiling, updated requirements and settings, and improved frontend Orval config for API client generation.
2025-12-08 18:19:20 +01:00
5b066e2770 Refactor code structure for improved readability and maintainability 2025-12-07 02:58:37 +01:00
David Bruno Vontor
4cbebff43b Add production Docker setup and update backend/frontend configs
Introduces .dockerignore, production Dockerfile and nginx config for frontend, and refactors docker-compose.yml for multi-service deployment. Updates backend and frontend code to support public API tagging, improves refund handling, adds test email endpoint, and migrates Orval config to TypeScript. Removes unused frontend Dockerfile and updates dependencies for React Query and Orval.
2025-12-05 18:22:35 +01:00
David Bruno Vontor
d94ad93222 Add choices API endpoint and OpenAPI client setup
Introduces a new /api/choices/ endpoint for fetching model choices with multilingual labels. Updates Django models to use 'cz#' prefix for Czech labels. Adds OpenAPI client generation via orval, refactors frontend API structure, and provides documentation and helper scripts for dynamic choices and OpenAPI usage.
2025-12-04 17:35:47 +01:00
David Bruno Vontor
ebab304b75 Add carrier field to Refund model and update Stripe views
Introduces a OneToOneField for carrier in the Refund model to support future integration and refactoring. Adds and updates TODO comments for email template usage, error handling in Stripe webhooks, and clarifies logic placement in Stripe models.
2025-11-21 15:15:47 +01:00
David Bruno Vontor
37f36b3466 Refactor email templates and add notification tasks
Moved email templates to a unified 'email' directory and added new base, header, and footer templates for emails. Implemented notification tasks in commerce for various order and refund events, and updated Carrier model to trigger notifications on shipping state changes.
2025-11-21 15:05:32 +01:00
David Bruno Vontor
102855f812 Update models.py 2025-11-20 18:00:30 +01:00
e86839f2da Add public refund creation endpoint and PDF generation
Introduces RefundPublicView for public refund creation via email and invoice/order ID, returning refund info and a base64-encoded PDF slip. Adds RefundCreatePublicSerializer for validation and creation, implements PDF generation in Refund model, and provides a customer-facing HTML template for the return slip. Updates URLs to expose the new endpoint.
2025-11-19 00:53:37 +01:00
b8a1a594b2 Major refactor of commerce and Stripe integration
Refactored commerce models to support refunds, invoices, and improved carrier/payment logic. Added new serializers and viewsets for products, categories, images, discount codes, and refunds. Introduced Stripe client integration and removed legacy Stripe admin/model code. Updated Dockerfile for PDF generation dependencies. Removed obsolete migration files and updated configuration app initialization. Added invoice template and tasks for order cleanup.
2025-11-18 01:00:03 +01:00
David Bruno Vontor
7a715efeda Enhance discount logic and shop configuration models
Refactored discount application logic in OrderItem to support multiple coupons with configurable multiplication and addition rules from ShopConfiguration. Updated DiscountCode model to use PositiveIntegerField for percent and improved validation. Extended ShopConfiguration with new fields for contact, social media, and coupon settings. Ensured ShopConfiguration singleton creation at app startup.
2025-11-14 15:18:08 +01:00
f14c09bf7a Refactor commerce models and add configuration app
Major refactor of commerce models: restructured Carrier, Payment, and DiscountCode models, improved order total calculation, and integrated Zasilkovna and Stripe logic. Added new configuration Django app for shop settings, updated Zasilkovna and Stripe models, and fixed Zasilkovna client WSDL URL. Removed unused serializers and views in commerce, and registered new apps in settings.
2025-11-14 02:21:20 +01:00
David Bruno Vontor
052f7ab533 API zásilkovna hotovo 2025-11-13 18:46:01 +01:00
5c3a02d282 commerce 2025-11-13 02:32:56 +01:00
c39467dc7d commerce logika 2025-11-12 02:12:41 +01:00
a645c87020 api done 2025-11-07 17:43:37 +01:00
c3f837b90f upgrade 2025-11-07 00:46:35 +01:00
2118f002d1 Merge branch 'bruno' of https://git.vontor.cz/Brunobrno/vontor-cz into bruno 2025-11-06 01:40:02 +01:00
602c5a40f1 id 2025-11-06 01:40:00 +01:00
05055415de gopay done 2025-11-05 18:13:01 +01:00
de5f54f4bc gopay 2025-11-05 02:05:35 +01:00
a324a9cf49 Merge branch 'bruno' of https://git.vontor.cz/Brunobrno/vontor-cz into bruno 2025-11-04 02:16:18 +01:00
47b9770a70 GoPay 2025-11-04 02:16:17 +01:00
David Bruno Vontor
4791bbc92c websockets + chat app (django) 2025-10-31 13:32:39 +01:00
8dd4f6e731 converter 2025-10-30 01:58:28 +01:00
dd9d076bd2 okay 2025-10-29 00:58:37 +01:00
73da41b514 commit 2025-10-28 03:21:01 +01:00
10796dcb31 integrace api, stripe, vytvoření commecre app 2025-10-05 23:41:14 +02:00
f5cf8bbaa7 style changes 2025-10-03 01:48:36 +02:00
d0227e4539 fixed components 2025-10-02 02:10:07 +02:00
548 changed files with 47736 additions and 2352 deletions

7
.dockerignore Normal file
View File

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

226
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,226 @@
# Copilot Instructions for Vontor CZ
## Overview
This monorepo contains a Django backend and a Vite/React frontend, orchestrated via Docker Compose. The project is designed for a Czech e-marketplace, with custom payment integrations and real-time features.
## Architecture
- **backend/**: Django project (`vontor_cz`), custom `account` app, and third-party payment integrations (`thirdparty/`).
- Uses Django REST Framework, Channels (ASGI), Celery, and S3/static/media via `django-storages`.
- Custom user model: `account.CustomUser`.
- API docs: DRF Spectacular (`/api/schema/`).
- **frontend/**: Vite + React + TypeScript app.
- Organized by `src/api/`, `components/`, `features/`, `layouts/`, `pages/`, `routes/`.
- Uses React Router layouts and nested routes (see `src/layouts/`, `src/routes/`).
- Uses Tailwind CSS for styling (configured via `src/index.css` with `@import "tailwindcss";`). Prefer utility classes over custom CSS.
## Developer Workflows
- **Backend**
- Local dev: `python manage.py runserver` (or use Docker Compose)
- Migrations: `python manage.py makemigrations && python manage.py migrate`
- Celery: `celery -A vontor_cz worker -l info`
- Channels: Daphne/ASGI (see Docker Compose command)
- Env config: `.env` files in `backend/` (see `.gitignore` for secrets)
- **Frontend**
- Install: `npm install`
- Dev server: `npm run dev`
- Build: `npm run build`
- Preview: `npm run preview`
- Static assets: `src/assets/` (import in JS/CSS), `public/` (referenced in HTML)
## Conventions & Patterns
- **Backend**
- Use environment variables for secrets and config (see `settings.py`).
- Static/media files: S3 in production, local in dev (see `settings.py`).
- API versioning and docs: DRF Spectacular config in `settings.py`.
- Custom permissions, filters, and serializers in each app.
- **Serializer Best Practices**:
- **Prevent Duplicate Schemas**: When the same `ChoiceField` or complex field appears in multiple serializers, define it once as a reusable field class and use it everywhere instead of repeated definitions.
- Example: Create `OrderStatusField(serializers.ChoiceField)` with `choices=Order.OrderStatus.choices` and reuse it in all serializers that need order status.
- This ensures consistent OpenAPI schema generation and reduces maintenance overhead.
- **Frontend**
- Use React Router layouts for shared UI (see `src/layouts/`, `LAYOUTS.md`).
- API calls and JWT handling in `src/api/`.
- Route definitions and guards in `src/routes/` (`ROUTES.md`).
- Use TypeScript strict mode (see `tsconfig.*.json`).
- Linting: ESLint config in `eslint.config.js`.
- Styling: Tailwind CSS is present. Prefer utility classes; keep minimal component-scoped CSS. Global/base styles live in `src/index.css`. Avoid inline styles and CSS-in-JS unless necessary.
### Frontend API Client (required)
All frontend API calls must use the shared client at frontend/src/api/Client.ts.
- Client.public: no cookies, no Authorization header (for public Django endpoints).
- Client.auth: sends cookies and includes Bearer token; auto-refreshes on 401 (retries up to 2x).
- Centralized error handling: subscribe via Client.onError to show toasts/snackbars.
- Tokens are stored in cookies by Client.setTokens and cleared by Client.clearTokens.
Example usage (TypeScript)
```ts
import Client from "@/api/Client";
// Public request (no credentials)
async function listPublicItems() {
const res = await Client.public.get("/api/public/items/");
return res.data;
}
// Login (obtain tokens and persist to cookies)
async function login(username: string, password: string) {
// Default SimpleJWT endpoint (adjust if your backend differs)
const res = await Client.public.post("/api/token/", { username, password });
const { access, refresh } = res.data;
Client.setTokens(access, refresh);
}
// Authenticated requests (auto Bearer + refresh on 401)
async function fetchProfile() {
const res = await Client.auth.get("/api/users/me/");
return res.data;
}
function logout() {
Client.clearTokens();
window.location.assign("/login");
}
// Global error toasts
import { useEffect } from "react";
function useApiErrors(showToast: (msg: string) => void) {
useEffect(() => {
const unsubscribe = Client.onError((e) => {
const { message, status } = e.detail;
showToast(status ? `${status}: ${message}` : message);
});
return unsubscribe;
}, [showToast]);
}
```
Vite env used by the client:
- VITE_API_BASE_URL (default: http://localhost:8000)
- VITE_API_REFRESH_URL (default: /api/token/refresh/)
- VITE_LOGIN_PATH (default: /login)
Notes
- Public client never sends cookies or Authorization.
- Ensure Django CORS settings allow your frontend origin. See backend/vontor_cz/settings.py.
- Use React Router layouts and guards as documented in frontend/src/routes/ROUTES.md and frontend/src/layouts/LAYOUTS.md.
## Integration Points
- **Payments**: `thirdparty/` contains custom integrations for Stripe, GoPay, Trading212.
- **Real-time**: Django Channels (ASGI, Redis) for websockets.
- **Task queue**: Celery + Redis for async/background jobs.
- **API**: REST endpoints, JWT auth, API key support.
### OpenAPI Client Generation (Orval)
This project uses **Orval** to auto-generate TypeScript API clients from the Django OpenAPI schema.
#### Configuration
- **Orval config**: `frontend/src/orval.config.ts`
- **Schema URL**: `/api/schema/` (DRF Spectacular endpoint)
- **Fetch script**: `frontend/scripts/fetch-openapi.js`
- **Commands**:
- `npm run api:update` — fetches schema + generates client
- Runs: `node scripts/fetch-openapi.js && npx orval`
#### Generated Output
- **Location**: `frontend/src/api/generated/`
- **Files**: TypeScript interfaces, Axios-based API hooks
- Uses custom mutators: `publicMutator` and `privateMutator`
#### Custom Mutators
Two Axios clients handle public/private API requests:
**Public Client** (`frontend/src/api/publicClient.ts`):
```ts
import axios, { type AxiosRequestConfig } from "axios";
const backendUrl = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000";
export const publicApi = axios.create({
baseURL: backendUrl + "/api/",
withCredentials: false, // no cookies for public endpoints
});
export const publicMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
const response = await publicApi.request<T>(config);
return response.data;
};
```
**Private Client** (`frontend/src/api/privateClient.ts`):
```ts
import axios, { type AxiosRequestConfig } from "axios";
const backendUrl = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000";
export const privateApi = axios.create({
baseURL: backendUrl + "/api/",
withCredentials: true, // sends HttpOnly cookies (access/refresh tokens)
});
// Auto-refresh on 401
privateApi.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
try {
await privateApi.post("/auth/refresh/");
return privateApi(original);
} catch {
// optional: logout
}
}
return Promise.reject(error);
}
);
export const privateMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
const response = await privateApi.request<T>(config);
return response.data;
};
```
#### Environment Variables (Vite)
- **IMPORTANT**: Use `import.meta.env.VITE_*` instead of `process.env` in browser code
- **NEVER** import `dotenv/config` in frontend files (causes "process is not defined" error)
- **Available vars**:
- `VITE_BACKEND_URL` (default: `http://localhost:8000`)
- `VITE_API_BASE_URL` (if using Client.ts wrapper)
- `VITE_API_REFRESH_URL` (default: `/api/token/refresh/`)
- `VITE_LOGIN_PATH` (default: `/login`)
#### Usage Example
```ts
import { useGetOrders } from "@/api/generated/orders";
function OrdersList() {
const { data, isLoading, error } = useGetOrders();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data?.map(order => <li key={order.id}>{order.status}</li>)}
</ul>
);
}
```
#### Helpers
- **Choices helper**: `frontend/src/api/get_choices.ts`
- Function: `getChoices(requests, lang)`
- Returns: `{ "Model.field": [{ value, label }] }`
## References
- [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions.
- [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns.
- [frontend/src/routes/ROUTES.md](../frontend/src/routes/ROUTES.md): Routing conventions.
- [backend/vontor_cz/settings.py](../backend/vontor_cz/settings.py): All backend config, env, and integration details.
- [docker-compose.yml](../docker-compose.yml): Service orchestration and dev workflow.
---
**When in doubt, check the referenced markdown files and `settings.py` for project-specific logic and patterns.**

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
"python.analysis.autoImportCompletions": true
}

View File

@@ -2,6 +2,22 @@ FROM python:3.12-slim
WORKDIR /app
# Install system dependencies including Node.js for yt-dlp JavaScript runtime
RUN apt update && apt install -y \
weasyprint \
libcairo2 \
pango1.0-tools \
libpango-1.0-0 \
libgobject-2.0-0 \
ffmpeg \
ca-certificates \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt install -y nodejs \
&& update-ca-certificates \
&& apt clean \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

View File

@@ -1,6 +1,7 @@
from django.apps import AppConfig
from django.contrib.auth import get_user_model
class AccountConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'account'
name = 'account'

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.2.5 on 2025-08-13 23:19
# Generated by Django 5.2.7 on 2025-12-18 15:11
import account.models
import django.contrib.auth.validators
@@ -30,16 +30,23 @@ class Migration(migrations.Migration):
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('role', models.CharField(blank=True, choices=[('admin', 'Administrátor'), ('user', 'Uživatel')], max_length=32, null=True)),
('role', models.CharField(choices=[('admin', 'cz#Administrátor'), ('mod', 'cz#Moderator'), ('regular', 'cz#Regular')], default='regular', max_length=20)),
('phone_number', models.CharField(blank=True, max_length=16, null=True, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
('email_verified', models.BooleanField(default=False)),
('phone_number', models.CharField(blank=True, max_length=16, unique=True, validators=[django.core.validators.RegexValidator('^\\+?\\d{9,15}$', message='Zadejte platné telefonní číslo.')])),
('email', models.EmailField(db_index=True, max_length=254, unique=True)),
('email_verification_token', models.CharField(blank=True, db_index=True, max_length=128, null=True)),
('email_verification_sent_at', models.DateTimeField(blank=True, null=True)),
('gdpr', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=False)),
('create_time', models.DateTimeField(auto_now_add=True)),
('city', models.CharField(blank=True, max_length=100, null=True)),
('street', models.CharField(blank=True, max_length=200, null=True)),
('street_number', models.PositiveIntegerField(blank=True, null=True)),
('country', models.CharField(blank=True, max_length=100, null=True)),
('company_name', models.CharField(blank=True, max_length=255)),
('ico', models.CharField(blank=True, max_length=20)),
('dic', models.CharField(blank=True, max_length=20)),
('postal_code', models.CharField(blank=True, max_length=5, null=True, validators=[django.core.validators.RegexValidator(code='invalid_postal_code', message='Postal code must contain exactly 5 digits.', regex='^\\d{5}$')])),
('gdpr', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='customuser_set', related_query_name='customuser', to='auth.group')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='customuser_set', related_query_name='customuser', to='auth.permission')),
],
@@ -47,8 +54,8 @@ class Migration(migrations.Migration):
'abstract': False,
},
managers=[
('objects', account.models.CustomUserActiveManager()),
('all_objects', account.models.CustomUserAllManager()),
('objects', account.models.CustomUserManager()),
('active', account.models.ActiveUserManager()),
],
),
]

View File

@@ -1,8 +1,9 @@
import uuid
from django.db import models
from django.contrib.auth.models import AbstractUser, Group, Permission
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
from django.core.validators import RegexValidator
from django.utils.crypto import get_random_string
from django.conf import settings
from django.db import models
from django.utils import timezone
@@ -16,91 +17,77 @@ import logging
logger = logging.getLogger(__name__)
# Custom User Manager to handle soft deletion
class CustomUserActiveManager(UserManager):
def get_queryset(self):
return super().get_queryset().filter(is_deleted=False)
class CustomUserManager(UserManager):
# Inherit get_by_natural_key and all auth behaviors
use_in_migrations = True
# Custom User Manager to handle all users, including soft deleted
class CustomUserAllManager(UserManager):
class ActiveUserManager(CustomUserManager):
def get_queryset(self):
return super().get_queryset()
return super().get_queryset().filter(is_active=True)
class CustomUser(SoftDeleteModel, AbstractUser):
groups = models.ManyToManyField(
Group,
related_name="customuser_set", # <- přidáš related_name
related_name="customuser_set",
blank=True,
help_text="The groups this user belongs to.",
related_query_name="customuser",
)
user_permissions = models.ManyToManyField(
Permission,
related_name="customuser_set", # <- přidáš related_name
related_name="customuser_set",
blank=True,
help_text="Specific permissions for this user.",
related_query_name="customuser",
)
ROLE_CHOICES = (
('admin', 'Administrátor'),
('user', 'Uživatel'),
)
role = models.CharField(max_length=32, choices=ROLE_CHOICES, null=True, blank=True)
class Role(models.TextChoices):
ADMIN = "admin", "cz#Administrátor"
MANAGER = "mod", "cz#Moderator"
CUSTOMER = "regular", "cz#Regular"
"""ACCOUNT_TYPES = (
('company', 'Firma'),
('individual', 'Fyzická osoba')
)
account_type = models.CharField(max_length=32, choices=ACCOUNT_TYPES, null=True, blank=True)"""
email_verified = models.BooleanField(default=False)
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
phone_number = models.CharField(
null=True,
blank=True,
unique=True,
max_length=16,
blank=True,
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
)
email_verified = models.BooleanField(default=False)
email = models.EmailField(unique=True, db_index=True)
# + fields for email verification flow
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
newsletter = models.BooleanField(default=True)
#misc
gdpr = models.BooleanField(default=False)
is_active = models.BooleanField(default=False)
create_time = models.DateTimeField(auto_now_add=True)
"""company_id = models.CharField(
max_length=8,
blank=True,
null=True,
validators=[
RegexValidator(
regex=r'^\d{8}$',
message="Company ID must contain exactly 8 digits.",
code='invalid_company_id'
)
]
)"""
"""personal_id = models.CharField(
max_length=11,
blank=True,
null=True,
validators=[
RegexValidator(
regex=r'^\d{6}/\d{3,4}$',
message="Personal ID must be in the format 123456/7890.",
code='invalid_personal_id'
)
]
)"""
#adresa
postal_code = models.CharField(max_length=20, blank=True)
city = models.CharField(null=True, blank=True, max_length=100)
street = models.CharField(null=True, blank=True, max_length=200)
street_number = models.PositiveIntegerField(null=True, blank=True)
country = models.CharField(null=True, blank=True, max_length=100)
# firemní fakturační údaje
company_name = models.CharField(max_length=255, blank=True)
ico = models.CharField(max_length=20, blank=True)
dic = models.CharField(max_length=20, blank=True)
postal_code = models.CharField(
max_length=5,
blank=True,
null=True,
max_length=5,
validators=[
RegexValidator(
regex=r'^\d{5}$',
@@ -109,44 +96,80 @@ class CustomUser(SoftDeleteModel, AbstractUser):
)
]
)
gdpr = models.BooleanField(default=False)
is_active = models.BooleanField(default=False)
objects = CustomUserActiveManager()
all_objects = CustomUserAllManager()
REQUIRED_FIELDS = ['email', "username", "password"]
USERNAME_FIELD = "username"
REQUIRED_FIELDS = [
"email"
]
def __str__(self):
return f"{self.email} at {self.create_time.strftime('%d-%m-%Y %H:%M:%S')}"
# Ensure default manager has get_by_natural_key
objects = CustomUserManager()
# Optional convenience manager for active users only
active = ActiveUserManager()
def delete(self, *args, **kwargs):
self.is_active = False
#self.orders.all().update(is_deleted=True, deleted_at=timezone.now())
return super().delete(*args, **kwargs)
def save(self, *args, **kwargs):
is_new = self.pk is None # check BEFORE saving
is_new = self._state.adding # True if object hasn't been saved yet
# Pre-save flags for new users
if is_new:
if self.is_superuser or self.role == "admin":
# ensure admin flags are consistent
self.is_active = True
if self.role == 'admin':
self.is_staff = True
self.is_superuser = True
if self.is_superuser:
self.role = 'admin'
self.is_staff = True
self.is_superuser = True
self.role = "admin"
else:
self.is_staff = False
# First save to obtain a primary key
super().save(*args, **kwargs)
# Assign group after we have a PK
if is_new:
from django.contrib.auth.models import Group
group, _ = Group.objects.get_or_create(name=self.role)
# Use add/set now that PK exists
self.groups.set([group])
return super().save(*args, **kwargs)
def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str:
token = get_random_string(length=length)
self.email_verification_token = token
self.email_verification_sent_at = timezone.now()
if save:
self.save(update_fields=["email_verification_token", "email_verification_sent_at"])
return token
def verify_email_token(self, token: str, max_age_hours: int = 48, save: bool = True) -> bool:
if not token or not self.email_verification_token:
return False
# optional expiry check
if self.email_verification_sent_at:
age = timezone.now() - self.email_verification_sent_at
if age > timedelta(hours=max_age_hours):
return False
if token != self.email_verification_token:
return False
if not self.email_verified:
self.email_verified = True
# clear token after success
self.email_verification_token = None
self.email_verification_sent_at = None
if save:
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
return True
def get_anonymous_user():
"""Return the singleton anonymous user."""
User = CustomUser
return User.objects.get(username="anonymous")

View File

@@ -1,41 +1,25 @@
from urllib import request
from rest_framework.permissions import BasePermission, SAFE_METHODS
from rest_framework.permissions import IsAuthenticated
from rest_framework_api_key.permissions import HasAPIKey
#Podle svého uvážení (NEPOUŽÍVAT!!!)
class RolePermission(BasePermission):
allowed_roles = []
def has_permission(self, request, view):
# Je uživatel přihlášený a má roli z povolených?
user_has_role = (
request.user and
request.user.is_authenticated and
getattr(request.user, "role", None) in self.allowed_roles
)
# Má API klíč?
has_api_key = HasAPIKey().has_permission(request, view)
return user_has_role or has_api_key
#TOHLE POUŽÍT!!!
#Prostě stačí vložit: RoleAllowed('seller','cityClerk')
def RoleAllowed(*roles):
"""
Allows safe methods for any authenticated user.
Allows unsafe methods only for users with specific roles.
Allows access if a valid API key is provided.
Args:
RolerAllowed('admin', 'user')
RoleAllowed('admin', 'user')
"""
class SafeOrRolePermission(BasePermission):
def has_permission(self, request, view):
# Má API klíč?
has_api_key = HasAPIKey().has_permission(request, view)
# Allow safe methods for any authenticated user
if request.method in SAFE_METHODS:
return IsAuthenticated().has_permission(request, view)
@@ -71,3 +55,23 @@ class AdminOnly(BasePermission):
def has_permission(self, request, view):
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
# Commerce-specific permissions
class AdminWriteOnlyOrReadOnly(BasePermission):
"""Allow read for anyone, write only for admins"""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
class AdminOnlyForPatchOtherwisePublic(BasePermission):
"""Allow GET/POST for anyone, PATCH/PUT/DELETE only for admins"""
def has_permission(self, request, view):
if request.method in SAFE_METHODS or request.method == "POST":
return True
if request.method in ["PATCH", "PUT", "DELETE"]:
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
# Default to admin for other unsafe methods
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'

View File

@@ -27,21 +27,16 @@ class CustomUserSerializer(serializers.ModelSerializer):
"last_name",
"email",
"role",
"account_type",
"email_verified",
"phone_number",
"create_time",
"var_symbol",
"bank_account",
"ICO",
"RC",
"city",
"street",
"PSC",
"GDPR",
"postal_code",
"gdpr",
"is_active",
]
read_only_fields = ["id", "create_time", "GDPR", "username"] # <-- removed "account_type"
read_only_fields = ["id", "create_time", "gdpr", "username"] # <-- removed "account_type"
def update(self, instance, validated_data):
user = self.context["request"].user

View File

@@ -10,76 +10,112 @@ from .models import CustomUser
logger = get_task_logger(__name__)
@shared_task
def send_password_reset_email_task(user_id):
try:
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
error_msg = f"Task send_password_reset_email has failed. Invalid User ID was sent."
logger.error(error_msg)
raise Exception(error_msg)
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = password_reset_token.make_token(user)
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
html_message = render_to_string(
'emails/password_reset.html',
{'reset_url': reset_url}
)
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
send_email_with_context(
recipients=user.email,
subject="Obnova hesla",
message=None,
html_message=html_message
)
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
"""
Send emails rendering a single HTML template.
- `template_name` is a simple base name without extension, e.g. "email/test".
- Renders only HTML (".html"), no ".txt" support.
- Converts `user` in context to a plain dict to avoid passing models to templates.
"""
if isinstance(recipients, str):
recipients = [recipients]
# Only email verification for user registration
html_message = None
if template_path:
ctx = dict(context or {})
# Render base layout and include the provided template as the main content.
# The included template receives the same context as the base.
html_message = render_to_string(
"email/components/base.html",
{"content_template": template_path, **ctx},
)
try:
send_mail(
subject=subject,
message=message or "",
from_email=None,
recipient_list=recipients,
fail_silently=False,
html_message=html_message,
)
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend' and message:
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
return True
except Exception as e:
logger.error(f"E-mail se neodeslal: {e}")
return False
#----------------------------------------------------------------------------------------------------
# This function sends an email to the user for email verification after registration.
@shared_task
def send_email_verification_task(user_id):
try:
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
error_msg = f"Task send_email_verification_task has failed. Invalid User ID was sent."
logger.error(error_msg)
raise Exception(error_msg)
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
return 0
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = account_activation_token.make_token(user)
verification_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
html_message = render_to_string(
'emails/email_verification.html',
{'verification_url': verification_url}
)
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
# {changed} generate and store a per-user token
token = user.generate_email_verification_token()
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
context = {
"user": user,
"action_url": verify_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Ověřit email",
}
send_email_with_context(
recipients=user.email,
subject="Ověření e-mailu",
message=None,
html_message=html_message
subject="Ověření emailu",
template_path="email/email_verification.html",
context=context,
)
def send_email_with_context(recipients, subject, message=None, html_message=None):
"""
General function to send emails with a specific context.
"""
if isinstance(recipients, str):
recipients = [recipients]
@shared_task
def send_email_test_task(email):
context = {
"action_url": settings.FRONTEND_URL,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Otevřít aplikaci",
}
send_email_with_context(
recipients=email,
subject="Testovací email",
template_path="email/test.html",
context=context,
)
@shared_task
def send_password_reset_email_task(user_id):
try:
send_mail(
subject=subject,
message=message if message else '',
from_email=None,
recipient_list=recipients,
fail_silently=False,
html_message=html_message
)
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
logger.debug("\nEMAIL OBSAH:\n", html_message if html_message else message, "\nKONEC OBSAHU")
return True
except Exception as e:
logger.error(f"E-mail se neodeslal: {e}")
return False
user = CustomUser.objects.get(pk=user_id)
except CustomUser.DoesNotExist:
logger.info(f"Task send_password_reset_email has failed. Invalid User ID was sent.")
return 0
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = password_reset_token.make_token(user)
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
context = {
"user": user,
"action_url": reset_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Obnovit heslo",
}
send_email_with_context(
recipients=user.email,
subject="Obnova hesla",
template_path="email/password_reset.html",
context=context,
)

View 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í emailu</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 emailovou 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>

View 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 email, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento email 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>

View 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í email</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í email z aplikace etrž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>

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Ověření e-mailu</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<div class="card">
<div class="card-body">
<h2 class="card-title">Ověření e-mailu</h2>
<p class="card-text">Ověřte svůj e-mail kliknutím na odkaz níže:</p>
<a href="{{ verification_url }}" class="btn btn-success">Ověřit e-mail</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Obnova hesla</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<div class="card">
<div class="card-body">
<h2 class="card-title">Obnova hesla</h2>
<p class="card-text">Pro obnovu hesla klikněte na následující odkaz:</p>
<a href="{{ reset_url }}" class="btn btn-primary">Obnovit heslo</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,3 +1,28 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
# Create your tests here.
class UserViewAnonymousTests(TestCase):
def setUp(self):
self.client = APIClient()
User = get_user_model()
self.target_user = User.objects.create_user(
username="target",
email="target@example.com",
password="pass1234",
is_active=True,
)
def test_anonymous_update_user_is_forbidden_and_does_not_crash(self):
url = f"/api/account/users/{self.target_user.id}/"
payload = {"username": "newname", "email": self.target_user.email}
resp = self.client.put(url, data=payload, format="json")
# Expect 403 Forbidden (permission denied), but most importantly no 500 error
self.assertEqual(resp.status_code, 403, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")
def test_anonymous_retrieve_user_is_unauthorized(self):
url = f"/api/account/users/{self.target_user.id}/"
resp = self.client.get(url)
# Retrieve requires authentication per view; expect 401 Unauthorized
self.assertEqual(resp.status_code, 401, msg=f"Unexpected status: {resp.status_code}, body={getattr(resp, 'data', resp.content)}")

View File

@@ -18,16 +18,28 @@ password_reset_token = PasswordResetTokenGenerator()
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
#NEMĚNIT CUSTOM SBÍRANÍ COOKIE TOKENU
#COOKIE + AUTHORIZATION HEADER JWT AUTHENTICATION FOR AXIOS COMPATIBILITY
class CookieJWTAuthentication(JWTAuthentication):
def authenticate(self, request):
# First try Authorization header (standard axios pattern)
header_token = self.get_header(request)
if header_token is not None:
validated_token = self.get_validated_token(header_token)
return self.get_user(validated_token), validated_token
# Fallback to cookie-based authentication
raw_token = request.COOKIES.get('access_token')
if not raw_token:
return None
validated_token = self.get_validated_token(raw_token)
return self.get_user(validated_token), validated_token
try:
validated_token = self.get_validated_token(raw_token)
return self.get_user(validated_token), validated_token
except (InvalidToken, TokenError):
# Invalid/expired token - return None instead of raising exception
# This allows AllowAny endpoints to work even with bad cookies!!
return None

View File

@@ -16,7 +16,6 @@ urlpatterns = [
# Registration & email endpoints
path('register/', views.UserRegistrationViewSet.as_view({'post': 'create'}), name='register'),
path('verify-email/<uidb64>/<token>/', views.EmailVerificationView.as_view(), name='verify-email'),
path('activate/', views.UserActivationViewSet.as_view(), name='activate-user'),
# Password reset endpoints
path('password-reset/', views.PasswordResetRequestView.as_view(), name='password-reset-request'),

View File

@@ -6,7 +6,7 @@ from .serializers import *
from .permissions import *
from .models import CustomUser
from .tokens import *
from .tasks import send_password_reset_email_task
from .tasks import send_password_reset_email_task, send_email_verification_task
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
@@ -38,7 +38,7 @@ from rest_framework_simplejwt.views import TokenObtainPairView
# Custom Token obtaining view
@extend_schema(
tags=["Authentication"],
tags=["account", "public"],
summary="Obtain JWT access and refresh tokens (cookie-based)",
description="Authenticate user and obtain JWT access and refresh tokens. You can use either email or username.",
request=CustomTokenObtainPairSerializer,
@@ -107,7 +107,7 @@ class CookieTokenObtainPairView(TokenObtainPairView):
return super().validate(attrs)
@extend_schema(
tags=["Authentication"],
tags=["account", "public"],
summary="Refresh JWT token using cookie",
description="Refresh JWT access and refresh tokens using the refresh token stored in cookie.",
responses={
@@ -160,10 +160,10 @@ class CookieTokenRefreshView(APIView):
except TokenError:
return Response({"detail": "Invalid refresh token."}, status=status.HTTP_401_UNAUTHORIZED)
#---------------------------------------------LOGIN/LOGOUT------------------------------------------------
#---------------------------------------------LOGOUT------------------------------------------------
@extend_schema(
tags=["Authentication"],
tags=["account", "public"],
summary="Logout user (delete access and refresh token cookies)",
description="Logs out the user by deleting access and refresh token cookies.",
responses={
@@ -185,7 +185,7 @@ class LogoutView(APIView):
#--------------------------------------------------------------------------------------------------------------
@extend_schema(
tags=["User"],
tags=["account"],
summary="List, retrieve, update, and delete users.",
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
responses={
@@ -222,6 +222,15 @@ class UserView(viewsets.ModelViewSet):
"is_active": {"help_text": "Stav aktivace uživatele."},
}
@extend_schema(
tags=["account"],
summary="Get permissions based on user role and action.",
description="Determines permissions for various actions based on user role and ownership.",
responses={
200: OpenApiResponse(description="Permissions determined successfully."),
403: OpenApiResponse(description="Permission denied."),
},
)
def get_permissions(self):
# Only admin can list or create users
if self.action in ['list', 'create']:
@@ -229,15 +238,20 @@ class UserView(viewsets.ModelViewSet):
# Only admin or the user themselves can update or delete
elif self.action in ['update', 'partial_update', 'destroy']:
if self.request.user.role == 'admin':
user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
# Admins can modify any user
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
return [OnlyRolesAllowed("admin")()]
elif self.kwargs.get('pk') and str(self.request.user.id) == self.kwargs['pk']:
# Users can modify their own record
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
return [IsAuthenticated()]
else:
# fallback - deny access
return [OnlyRolesAllowed("admin")()]
# Fallback - deny access (prevents AttributeError for AnonymousUser)
return [OnlyRolesAllowed("admin")()]
# Any authenticated user can retrieve (view) any user's profile
#FIXME: popřemýšlet co vše může získat
elif self.action == 'retrieve':
return [IsAuthenticated()]
@@ -247,7 +261,7 @@ class UserView(viewsets.ModelViewSet):
# Get current user data
@extend_schema(
tags=["User"],
tags=["account"],
summary="Get current authenticated user",
description="Returns details of the currently authenticated user based on JWT token or session.",
responses={
@@ -267,7 +281,7 @@ class CurrentUserView(APIView):
#1. registration API
@extend_schema(
tags=["User Registration"],
tags=["account", "public"],
summary="Register a new user (company or individual)",
description="Register a new user (company or individual). The user will receive an email with a verification link.",
request=UserRegistrationSerializer,
@@ -299,7 +313,7 @@ class UserRegistrationViewSet(ModelViewSet):
#2. confirming email
@extend_schema(
tags=["User Registration"],
tags=["account", "public"],
summary="Verify user email via link",
description="Verify user email using the link with uid and token.",
parameters=[
@@ -321,45 +335,18 @@ class EmailVerificationView(APIView):
if account_activation_token.check_token(user, token):
user.email_verified = True
user.is_active = True # Aktivace uživatele po ověření e-mailu
user.save()
return Response({"detail": "E-mail byl úspěšně ověřen. Účet čeká na schválení."})
return Response({"detail": "E-mail byl úspěšně ověřen. Účet je aktivován."})
else:
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
#3. seller activation API (var_symbol)
@extend_schema(
tags=["User Registration"],
summary="Activate user and set variable symbol (admin/cityClerk only)",
description="Activate user and set variable symbol. Only accessible by admin or cityClerk.",
request=UserActivationSerializer,
responses={
200: OpenApiResponse(response=UserActivationSerializer, description="User activated successfully."),
400: OpenApiResponse(description="Invalid activation data."),
404: OpenApiResponse(description="User not found."),
},
)
class UserActivationViewSet(APIView):
permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')]
def patch(self, request, *args, **kwargs):
serializer = UserActivationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
try:
send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK
except Exception as e:
logger.error(f"Celery not available, using fallback. Error: {e}")
send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol
return Response(serializer.to_representation(user), status=status.HTTP_200_OK)
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
#1. PasswordReset + send Email
@extend_schema(
tags=["User password reset"],
tags=["account", "public"],
summary="Request password reset (send email)",
description="Request password reset by providing registered email. An email with instructions will be sent.",
request=PasswordResetRequestSerializer,
@@ -389,7 +376,7 @@ class PasswordResetRequestView(APIView):
#2. Confirming reset
@extend_schema(
tags=["User password reset"],
tags=["account", "public"],
summary="Confirm password reset via token",
description="Confirm password reset using token from email.",
request=PasswordResetConfirmSerializer,
@@ -418,4 +405,7 @@ class PasswordResetConfirmView(APIView):
user.set_password(serializer.validated_data['password'])
user.save()
return Response({"detail": "Heslo bylo úspěšně změněno."})
return Response(serializer.errors, status=400)
return Response(serializer.errors, status=400)

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AdvertisementConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'advertisement'

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.9 on 2025-12-14 02:23
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='ContactMe',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('client_email', models.EmailField(max_length=254)),
('content', models.TextField()),
('sent_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@@ -0,0 +1,14 @@
from django.db import models
# Create your models here.
class ContactMe(models.Model):
client_email = models.EmailField()
content = models.TextField()
sent_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Email to {self.client_email} sent at {self.sent_at}"

View 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"]

View File

@@ -0,0 +1,49 @@
from venv import create
from account.tasks import send_email_with_context
from configuration.models import SiteConfiguration
from celery import shared_task
from celery.schedules import crontab
from commerce.models import Product
import datetime
@shared_task
def send_contact_me_email_task(client_email, message_content):
context = {
"client_email": client_email,
"message_content": message_content
}
send_email_with_context(
recipients=SiteConfiguration.get_solo().contact_email,
subject="Poptávka z kontaktního formuláře!!!",
template_path="email/contact_me.html",
context=context,
)
@shared_task
def send_newly_added_items_to_store_email_task_last_week():
last_week_date = datetime.datetime.now() - datetime.timedelta(days=7)
"""
__lte -> Less than or equal
__gte -> Greater than or equal
__lt -> Less than
__gt -> Greater than
"""
products_of_week = Product.objects.filter(
include_in_week_summary_email=True,
created_at__gte=last_week_date
)
send_email_with_context(
recipients=SiteConfiguration.get_solo().contact_email,
subject="Nový produkt přidán do obchodu",
template_path="email/advertisement/commerce/new_items_added_this_week.html",
context={
"products_of_week": products_of_week,
}
)

View File

@@ -0,0 +1,83 @@
<style>
.summary {
background-color: #e3f2fd;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
text-align: center;
font-family: Arial, sans-serif;
}
.product-item {
border-bottom: 1px solid #eee;
padding: 15px 0;
font-family: Arial, sans-serif;
}
.product-item:last-child {
border-bottom: none;
}
.product-name {
font-weight: bold;
font-size: 16px;
color: #333;
margin-bottom: 8px;
}
.product-price {
font-size: 14px;
color: #007bff;
font-weight: bold;
margin-bottom: 5px;
}
.product-description {
color: #666;
font-size: 13px;
margin-bottom: 8px;
}
.product-date {
color: #999;
font-size: 12px;
}
.no-products {
text-align: center;
color: #666;
font-style: italic;
padding: 30px;
font-family: Arial, sans-serif;
}
</style>
<h2 style="color: #007bff; margin: 0 0 20px 0;">🆕 Nové produkty v obchodě</h2>
<p style="margin: 0 0 20px 0;">Týdenní přehled nově přidaných produktů</p>
<div class="summary">
<h3 style="margin: 0 0 10px 0;">📊 Celkem nových produktů: {{ products_of_week|length }}</h3>
<p style="margin: 0;">Přehled produktů přidaných za posledních 7 dní</p>
</div>
{% if products_of_week %}
{% for product in products_of_week %}
<div class="product-item">
<div class="product-name">{{ product.name }}</div>
{% if product.price %}
<div class="product-price">
{{ product.price|floatformat:0 }} {{ product.currency|default:"Kč" }}
</div>
{% endif %}
{% if product.short_description %}
<div class="product-description">
{{ product.short_description|truncatewords:20 }}
</div>
{% endif %}
<div class="product-date">
Přidáno: {{ product.created_at|date:"d.m.Y H:i" }}
</div>
</div>
{% endfor %}
{% else %}
<div class="no-products">
<h3 style="margin: 0 0 15px 0;">🤷‍♂️ Žádné nové produkty</h3>
<p style="margin: 0;">Za posledních 7 dní nebyly přidány žádné nové produkty, které by měly být zahrnuty do týdenního přehledu.</p>
</div>
{% endif %}

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

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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"),
]

View File

@@ -0,0 +1,86 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, viewsets
from rest_framework.permissions import AllowAny, IsAdminUser
from rest_framework.authentication import SessionAuthentication
from rest_framework.decorators import api_view, permission_classes
from drf_spectacular.utils import extend_schema, extend_schema_view
from .models import ContactMe
from .serializer import ContactMeSerializer
from .tasks import send_contact_me_email_task, send_newly_added_items_to_store_email_task_last_week
@extend_schema(tags=["advertisement", "public"])
class ContactMePublicView(APIView):
permission_classes = [AllowAny]
# Avoid CSRF for public endpoint by disabling SessionAuthentication
authentication_classes = []
def post(self, request):
email = request.data.get("email")
message = request.data.get("message")
honeypot = request.data.get("hp") # hidden honeypot field
# If honeypot is filled, pretend success without processing
if honeypot:
return Response({"status": "ok"}, status=status.HTTP_200_OK)
if not email or not message:
return Response({"detail": "Missing email or message."}, status=status.HTTP_400_BAD_REQUEST)
# Save to DB
cm = ContactMe.objects.create(client_email=email, content=message)
# Send email via Celery task
try:
send_contact_me_email_task.delay(email, message)
except Exception:
# Fallback to direct call if Celery is not running in DEV
send_contact_me_email_task(email, message)
return Response({"id": cm.id, "status": "queued"}, status=status.HTTP_201_CREATED)
@extend_schema_view(
list=extend_schema(tags=["advertisement"], summary="List contact messages (admin)"),
retrieve=extend_schema(tags=["advertisement"], summary="Retrieve contact message (admin)"),
create=extend_schema(tags=["advertisement"], summary="Create contact message (admin)"),
partial_update=extend_schema(tags=["advertisement"], summary="Update contact message (admin)"),
update=extend_schema(tags=["advertisement"], summary="Replace contact message (admin)"),
destroy=extend_schema(tags=["advertisement"], summary="Delete contact message (admin)"),
)
class ContactMeAdminViewSet(viewsets.ModelViewSet):
queryset = ContactMe.objects.all().order_by("-sent_at")
serializer_class = ContactMeSerializer
permission_classes = [IsAdminUser]
@extend_schema(
tags=["advertisement"],
summary="Manually trigger weekly new items email",
description="Triggers the weekly email task that sends a summary of newly added products from the last week. Only accessible by admin users.",
methods=["POST"]
)
@api_view(['POST'])
@permission_classes([IsAdminUser])
def trigger_weekly_email(request):
"""
Manually trigger the weekly new items email task.
Only accessible by admin users.
"""
try:
# Trigger the task asynchronously
task = send_newly_added_items_to_store_email_task_last_week.delay()
return Response({
'success': True,
'message': 'Weekly email task triggered successfully',
'task_id': task.id
}, status=status.HTTP_200_OK)
except Exception as e:
return Response({
'success': False,
'message': f'Failed to trigger weekly email task: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)

117
backend/commerce/admin.py Normal file
View File

@@ -0,0 +1,117 @@
from django.contrib import admin
from .models import (
Category, Product, ProductImage, Order, OrderItem,
Carrier, Payment, DiscountCode, Refund, Invoice, Cart, CartItem, Wishlist
)
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ("name", "url", "parent")
search_fields = ("name", "description")
prepopulated_fields = {"url": ("name",)}
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "price", "stock", "is_active", "category", "created_at")
search_fields = ("name", "description", "code")
list_filter = ("is_active", "category", "created_at")
prepopulated_fields = {"url": ("name",)}
@admin.register(ProductImage)
class ProductImageAdmin(admin.ModelAdmin):
list_display = ("product", "is_main", "alt_text")
list_filter = ("is_main",)
search_fields = ("product__name", "alt_text")
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
readonly_fields = ("product", "quantity")
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "user", "email", "status", "total_price", "currency", "created_at")
list_filter = ("status", "created_at", "country")
search_fields = ("email", "first_name", "last_name", "phone")
readonly_fields = ("created_at", "updated_at", "total_price")
inlines = [OrderItemInline]
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
list_display = ("order", "product", "quantity")
search_fields = ("order__id", "product__name")
@admin.register(Carrier)
class CarrierAdmin(admin.ModelAdmin):
list_display = ("id", "shipping_method", "state", "shipping_price", "weight")
list_filter = ("shipping_method", "state", "returning")
search_fields = ("id",)
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ("id", "payment_method", "created_at")
list_filter = ("payment_method", "created_at")
@admin.register(DiscountCode)
class DiscountCodeAdmin(admin.ModelAdmin):
list_display = ("code", "percent", "amount", "active", "valid_from", "valid_to", "used_count", "usage_limit")
list_filter = ("active", "valid_from", "valid_to")
search_fields = ("code", "description")
@admin.register(Refund)
class RefundAdmin(admin.ModelAdmin):
list_display = ("order", "reason_choice", "verified", "created_at")
list_filter = ("verified", "reason_choice", "created_at")
search_fields = ("order__id", "order__email", "reason_text")
@admin.register(Invoice)
class InvoiceAdmin(admin.ModelAdmin):
list_display = ("invoice_number", "issued_at", "due_date")
search_fields = ("invoice_number",)
readonly_fields = ("issued_at",)
class CartItemInline(admin.TabularInline):
model = CartItem
extra = 0
readonly_fields = ("product", "quantity", "added_at")
@admin.register(Cart)
class CartAdmin(admin.ModelAdmin):
list_display = ("id", "user", "session_key", "created_at", "updated_at")
list_filter = ("created_at", "updated_at")
search_fields = ("user__email", "session_key")
readonly_fields = ("created_at", "updated_at")
inlines = [CartItemInline]
@admin.register(CartItem)
class CartItemAdmin(admin.ModelAdmin):
list_display = ("cart", "product", "quantity", "added_at")
list_filter = ("added_at",)
search_fields = ("cart__id", "product__name")
readonly_fields = ("added_at",)
@admin.register(Wishlist)
class WishlistAdmin(admin.ModelAdmin):
list_display = ("user", "product_count", "created_at", "updated_at")
search_fields = ("user__email", "user__username")
readonly_fields = ("created_at", "updated_at")
filter_horizontal = ("products",)
def product_count(self, obj):
return obj.products.count()
product_count.short_description = "Products Count"

View File

@@ -0,0 +1,506 @@
"""
E-commerce Analytics Module
Provides comprehensive business intelligence for the e-commerce platform.
All analytics functions return data structures suitable for frontend charts/graphs.
"""
from django.db.models import Sum, Count, Avg, Q, F
from django.utils import timezone
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Dict, List, Any, Optional
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek
from .models import Order, Product, OrderItem, Payment, Carrier, Review, Cart, CartItem
from configuration.models import SiteConfiguration
class SalesAnalytics:
"""Sales and revenue analytics"""
@staticmethod
def revenue_overview(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
period: str = "daily"
) -> Dict[str, Any]:
"""
Get revenue overview with configurable date range and period
Args:
start_date: Start date for analysis (default: last 30 days)
end_date: End date for analysis (default: today)
period: "daily", "weekly", "monthly" (default: daily)
Returns:
Dict with total_revenue, order_count, avg_order_value, and time_series data
"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
# Base queryset for completed orders
orders = Order.objects.filter(
status=Order.OrderStatus.COMPLETED,
created_at__range=(start_date, end_date)
)
# Aggregate totals
totals = orders.aggregate(
total_revenue=Sum('total_price'),
order_count=Count('id'),
avg_order_value=Avg('total_price')
)
# Time series data based on period
trunc_function = {
'daily': TruncDate,
'weekly': TruncWeek,
'monthly': TruncMonth,
}.get(period, TruncDate)
time_series = (
orders
.annotate(period=trunc_function('created_at'))
.values('period')
.annotate(
revenue=Sum('total_price'),
orders=Count('id')
)
.order_by('period')
)
return {
'total_revenue': totals['total_revenue'] or Decimal('0'),
'order_count': totals['order_count'] or 0,
'avg_order_value': totals['avg_order_value'] or Decimal('0'),
'time_series': list(time_series),
'period': period,
'date_range': {
'start': start_date.isoformat(),
'end': end_date.isoformat()
}
}
@staticmethod
def payment_methods_breakdown(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""Get breakdown of payment methods usage"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
payment_stats = (
Payment.objects
.filter(order__created_at__range=(start_date, end_date))
.values('payment_method')
.annotate(
count=Count('id'),
revenue=Sum('order__total_price')
)
.order_by('-revenue')
)
return [
{
'method': item['payment_method'],
'method_display': dict(Payment.PAYMENT.choices).get(item['payment_method'], item['payment_method']),
'count': item['count'],
'revenue': item['revenue'] or Decimal('0'),
'percentage': 0 # Will be calculated in the view
}
for item in payment_stats
]
class ProductAnalytics:
"""Product performance analytics"""
@staticmethod
def top_selling_products(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get top selling products by quantity and revenue"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
top_products = (
OrderItem.objects
.filter(order__created_at__range=(start_date, end_date))
.select_related('product')
.values('product__id', 'product__name', 'product__price')
.annotate(
total_quantity=Sum('quantity'),
total_revenue=Sum(F('quantity') * F('product__price')),
order_count=Count('order', distinct=True)
)
.order_by('-total_revenue')[:limit]
)
return [
{
'product_id': item['product__id'],
'product_name': item['product__name'],
'unit_price': item['product__price'],
'total_quantity': item['total_quantity'],
'total_revenue': item['total_revenue'],
'order_count': item['order_count'],
'avg_quantity_per_order': round(item['total_quantity'] / item['order_count'], 2)
}
for item in top_products
]
@staticmethod
def category_performance(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""Get category performance breakdown"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
category_stats = (
OrderItem.objects
.filter(order__created_at__range=(start_date, end_date))
.select_related('product__category')
.values('product__category__id', 'product__category__name')
.annotate(
total_quantity=Sum('quantity'),
total_revenue=Sum(F('quantity') * F('product__price')),
product_count=Count('product', distinct=True),
order_count=Count('order', distinct=True)
)
.order_by('-total_revenue')
)
return [
{
'category_id': item['product__category__id'],
'category_name': item['product__category__name'],
'total_quantity': item['total_quantity'],
'total_revenue': item['total_revenue'],
'product_count': item['product_count'],
'order_count': item['order_count']
}
for item in category_stats
]
@staticmethod
def inventory_analysis() -> Dict[str, Any]:
"""Get inventory status and low stock alerts"""
total_products = Product.objects.filter(is_active=True).count()
out_of_stock = Product.objects.filter(is_active=True, stock=0).count()
low_stock = Product.objects.filter(
is_active=True,
stock__gt=0,
stock__lte=10 # Consider configurable threshold
).count()
low_stock_products = (
Product.objects
.filter(is_active=True, stock__lte=10)
.select_related('category')
.values('id', 'name', 'stock', 'category__name')
.order_by('stock')[:20]
)
return {
'total_products': total_products,
'out_of_stock_count': out_of_stock,
'low_stock_count': low_stock,
'in_stock_count': total_products - out_of_stock,
'low_stock_products': list(low_stock_products),
'stock_distribution': {
'out_of_stock': out_of_stock,
'low_stock': low_stock,
'in_stock': total_products - out_of_stock - low_stock
}
}
class CustomerAnalytics:
"""Customer behavior and demographics analytics"""
@staticmethod
def customer_overview(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""Get customer acquisition and behavior overview"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
# New vs returning customers
period_orders = Order.objects.filter(created_at__range=(start_date, end_date))
# First-time customers (users with their first order in this period)
first_time_customers = period_orders.filter(
user__orders__created_at__lt=start_date
).values('user').distinct().count()
# Returning customers
total_customers = period_orders.values('user').distinct().count()
returning_customers = total_customers - first_time_customers
# Customer lifetime value (simplified)
customer_stats = (
Order.objects
.filter(user__isnull=False)
.values('user')
.annotate(
total_orders=Count('id'),
total_spent=Sum('total_price'),
avg_order_value=Avg('total_price')
)
)
avg_customer_ltv = customer_stats.aggregate(
avg_ltv=Avg('total_spent')
)['avg_ltv'] or Decimal('0')
return {
'total_customers': total_customers,
'new_customers': first_time_customers,
'returning_customers': returning_customers,
'avg_customer_lifetime_value': avg_customer_ltv,
'date_range': {
'start': start_date.isoformat(),
'end': end_date.isoformat()
}
}
@staticmethod
def cart_abandonment_analysis() -> Dict[str, Any]:
"""Analyze cart abandonment rates"""
# Active carts (updated in last 7 days)
week_ago = timezone.now() - timedelta(days=7)
active_carts = Cart.objects.filter(updated_at__gte=week_ago)
# Completed orders from carts
completed_orders = Order.objects.filter(
user__cart__in=active_carts,
created_at__gte=week_ago
).count()
total_carts = active_carts.count()
abandoned_carts = max(0, total_carts - completed_orders)
abandonment_rate = (abandoned_carts / total_carts * 100) if total_carts > 0 else 0
return {
'total_active_carts': total_carts,
'completed_orders': completed_orders,
'abandoned_carts': abandoned_carts,
'abandonment_rate': round(abandonment_rate, 2),
'analysis_period': '7 days'
}
class ShippingAnalytics:
"""Shipping and logistics analytics"""
@staticmethod
def shipping_methods_breakdown(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""Get breakdown of shipping methods usage"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
shipping_stats = (
Carrier.objects
.filter(order__created_at__range=(start_date, end_date))
.values('shipping_method', 'state')
.annotate(
count=Count('id'),
total_shipping_cost=Sum('shipping_price')
)
.order_by('-count')
)
return [
{
'shipping_method': item['shipping_method'],
'method_display': dict(Carrier.SHIPPING.choices).get(item['shipping_method'], item['shipping_method']),
'state': item['state'],
'state_display': dict(Carrier.STATE.choices).get(item['state'], item['state']),
'count': item['count'],
'total_cost': item['total_shipping_cost'] or Decimal('0')
}
for item in shipping_stats
]
@staticmethod
def deutsche_post_analytics() -> Dict[str, Any]:
"""Get Deutsche Post shipping analytics and pricing info"""
try:
# Import Deutsche Post models
from thirdparty.deutschepost.models import DeutschePostOrder
# Get Deutsche Post orders statistics
dp_orders = DeutschePostOrder.objects.all()
total_dp_orders = dp_orders.count()
# Get configuration for pricing
config = SiteConfiguration.get_solo()
dp_default_price = config.deutschepost_shipping_price
# Status breakdown (if available in the model)
# Note: This depends on actual DeutschePostOrder model structure
return {
'total_deutsche_post_orders': total_dp_orders,
'default_shipping_price': dp_default_price,
'api_configured': bool(config.deutschepost_client_id and config.deutschepost_client_secret),
'api_endpoint': config.deutschepost_api_url,
'analysis_note': 'Detailed Deutsche Post analytics require API integration'
}
except ImportError:
return {
'error': 'Deutsche Post module not available',
'total_deutsche_post_orders': 0,
'default_shipping_price': Decimal('0')
}
class ReviewAnalytics:
"""Product review and rating analytics"""
@staticmethod
def review_overview(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""Get review statistics and sentiment overview"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
reviews = Review.objects.filter(created_at__range=(start_date, end_date))
rating_distribution = (
reviews
.values('rating')
.annotate(count=Count('id'))
.order_by('rating')
)
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg'] or 0
total_reviews = reviews.count()
# Top rated products
top_rated_products = (
Review.objects
.filter(created_at__range=(start_date, end_date))
.select_related('product')
.values('product__id', 'product__name')
.annotate(
avg_rating=Avg('rating'),
review_count=Count('id')
)
.filter(review_count__gte=3) # At least 3 reviews
.order_by('-avg_rating')[:10]
)
return {
'total_reviews': total_reviews,
'average_rating': round(avg_rating, 2),
'rating_distribution': [
{
'rating': item['rating'],
'count': item['count'],
'percentage': round(item['count'] / total_reviews * 100, 1) if total_reviews > 0 else 0
}
for item in rating_distribution
],
'top_rated_products': list(top_rated_products),
'date_range': {
'start': start_date.isoformat(),
'end': end_date.isoformat()
}
}
class AnalyticsAggregator:
"""Main analytics aggregator for dashboard views"""
@staticmethod
def dashboard_overview(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""Get comprehensive dashboard data"""
return {
'sales': SalesAnalytics.revenue_overview(start_date, end_date),
'products': {
'top_selling': ProductAnalytics.top_selling_products(start_date, end_date, limit=5),
'inventory': ProductAnalytics.inventory_analysis()
},
'customers': CustomerAnalytics.customer_overview(start_date, end_date),
'shipping': {
'methods': ShippingAnalytics.shipping_methods_breakdown(start_date, end_date),
'deutsche_post': ShippingAnalytics.deutsche_post_analytics()
},
'reviews': ReviewAnalytics.review_overview(start_date, end_date),
'generated_at': timezone.now().isoformat()
}
def get_predefined_date_ranges() -> Dict[str, Dict[str, datetime]]:
"""Get predefined date ranges for easy frontend integration"""
now = timezone.now()
return {
'today': {
'start': now.replace(hour=0, minute=0, second=0, microsecond=0),
'end': now
},
'yesterday': {
'start': (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0),
'end': (now - timedelta(days=1)).replace(hour=23, minute=59, second=59)
},
'last_7_days': {
'start': now - timedelta(days=7),
'end': now
},
'last_30_days': {
'start': now - timedelta(days=30),
'end': now
},
'last_90_days': {
'start': now - timedelta(days=90),
'end': now
},
'this_month': {
'start': now.replace(day=1, hour=0, minute=0, second=0, microsecond=0),
'end': now
},
'last_month': {
'start': (now.replace(day=1) - timedelta(days=1)).replace(day=1, hour=0, minute=0, second=0, microsecond=0),
'end': (now.replace(day=1) - timedelta(days=1)).replace(hour=23, minute=59, second=59)
},
'this_year': {
'start': now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0),
'end': now
},
'last_year': {
'start': (now.replace(month=1, day=1) - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0),
'end': (now.replace(month=1, day=1) - timedelta(days=1)).replace(hour=23, minute=59, second=59)
}
}

6
backend/commerce/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CommerceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'commerce'

View File

@@ -0,0 +1,162 @@
# Generated by Django 5.2.7 on 2025-12-19 08:55
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('stripe', '0001_initial'),
('zasilkovna', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invoice_number', models.CharField(max_length=50, unique=True)),
('issued_at', models.DateTimeField(auto_now_add=True)),
('due_date', models.DateTimeField()),
('pdf_file', models.FileField(upload_to='invoices/')),
],
),
migrations.CreateModel(
name='Carrier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('shipping_method', models.CharField(choices=[('packeta', 'cz#Zásilkovna'), ('store', 'cz#Osobní odběr')], default='store', max_length=20)),
('state', models.CharField(choices=[('ordered', 'cz#Objednávka se připravuje'), ('shipped', 'cz#Odesláno'), ('delivered', 'cz#Doručeno'), ('ready_to_pickup', 'cz#Připraveno k vyzvednutí')], default='ordered', max_length=20)),
('weight', models.DecimalField(blank=True, decimal_places=2, help_text='Hmotnost zásilky v kg', max_digits=10, null=True)),
('returning', models.BooleanField(default=False, help_text='Zda je tato zásilka na vrácení')),
('shipping_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('zasilkovna', models.ManyToManyField(blank=True, related_name='carriers', to='zasilkovna.zasilkovnapacket')),
],
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('url', models.SlugField(unique=True)),
('description', models.TextField(blank=True)),
('image', models.ImageField(blank=True, upload_to='categories/')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='commerce.category')),
],
options={
'verbose_name_plural': 'Categories',
},
),
migrations.CreateModel(
name='DiscountCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, unique=True)),
('description', models.CharField(blank=True, max_length=255)),
('percent', models.PositiveIntegerField(blank=True, help_text='Procento sleva 0-100', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
('amount', models.DecimalField(blank=True, decimal_places=2, help_text='Fixní sleva v CZK', max_digits=10, null=True)),
('valid_from', models.DateTimeField(default=django.utils.timezone.now)),
('valid_to', models.DateTimeField(blank=True, null=True)),
('active', models.BooleanField(default=True)),
('usage_limit', models.PositiveIntegerField(blank=True, null=True)),
('used_count', models.PositiveIntegerField(default=0)),
('specific_categories', models.ManyToManyField(blank=True, related_name='discount_codes', to='commerce.category')),
],
),
migrations.CreateModel(
name='Payment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('payment_method', models.CharField(choices=[('Site', 'cz#Platba v obchodě'), ('stripe', 'cz#Bankovní převod'), ('cash_on_delivery', 'cz#Dobírka')], default='Site', max_length=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('stripe', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payment', to='stripe.stripemodel')),
],
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(blank=True, choices=[('created', 'cz#Vytvořeno'), ('cancelled', 'cz#Zrušeno'), ('completed', 'cz#Dokončeno'), ('refunding', 'cz#Vrácení v procesu'), ('refunded', 'cz#Vráceno')], default='created', max_length=20, null=True)),
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('currency', models.CharField(default='CZK', max_length=10)),
('first_name', models.CharField(max_length=100)),
('last_name', models.CharField(max_length=100)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('address', models.CharField(max_length=255)),
('city', models.CharField(max_length=100)),
('postal_code', models.CharField(max_length=20)),
('country', models.CharField(default='Czech Republic', max_length=100)),
('note', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('carrier', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='commerce.carrier')),
('discount', models.ManyToManyField(blank=True, related_name='orders', to='commerce.discountcode')),
('invoice', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='commerce.invoice')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='orders', to=settings.AUTH_USER_MODEL)),
('payment', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='commerce.payment')),
],
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('code', models.CharField(blank=True, max_length=100, null=True, unique=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('url', models.SlugField(unique=True)),
('stock', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('limited_to', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='commerce.category')),
('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')),
('variants', models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', related_name='variant_of', to='commerce.product')),
],
),
migrations.CreateModel(
name='OrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.order')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.product')),
],
),
migrations.AddField(
model_name='discountcode',
name='specific_products',
field=models.ManyToManyField(blank=True, related_name='discount_codes', to='commerce.product'),
),
migrations.CreateModel(
name='ProductImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='products/')),
('alt_text', models.CharField(blank=True, max_length=150)),
('is_main', models.BooleanField(default=False)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='commerce.product')),
],
),
migrations.CreateModel(
name='Refund',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason_choice', models.CharField(choices=[('retuning_before_fourteen_day_period', 'cz#Vrácení před uplynutím 14-ti denní lhůty'), ('damaged_product', 'cz#Poškozený produkt'), ('wrong_item', 'cz#Špatná položka'), ('other', 'cz#Jiný důvod')], max_length=40)),
('reason_text', models.TextField(blank=True)),
('verified', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refunds', to='commerce.order')),
],
),
]

View File

@@ -0,0 +1,83 @@
# Generated by Django 5.2.7 on 2026-01-17 01:37
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('commerce', '0001_initial'),
('deutschepost', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='productimage',
options={'ordering': ['order', '-is_main', 'id']},
),
migrations.AddField(
model_name='carrier',
name='deutschepost',
field=models.ManyToManyField(blank=True, related_name='carriers', to='deutschepost.deutschepostorder'),
),
migrations.AddField(
model_name='productimage',
name='order',
field=models.PositiveIntegerField(default=0, help_text='Display order (lower numbers first)'),
),
migrations.AlterField(
model_name='carrier',
name='shipping_method',
field=models.CharField(choices=[('packeta', 'cz#Zásilkovna'), ('deutschepost', 'cz#Deutsche Post'), ('store', 'cz#Osobní odběr')], default='store', max_length=20),
),
migrations.AlterField(
model_name='product',
name='variants',
field=models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', to='commerce.product'),
),
migrations.CreateModel(
name='Cart',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(blank=True, help_text='Session key for anonymous users', max_length=40, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Cart',
'verbose_name_plural': 'Carts',
},
),
migrations.CreateModel(
name='Review',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
('comment', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='commerce.product')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('added_at', models.DateTimeField(auto_now_add=True)),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.cart')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='commerce.product')),
],
options={
'verbose_name': 'Cart Item',
'verbose_name_plural': 'Cart Items',
'unique_together': {('cart', 'product')},
},
),
]

905
backend/commerce/models.py Normal file
View File

@@ -0,0 +1,905 @@
from ast import Or
import dis
from django.db import models
from django.conf import settings
from django.utils import timezone
from django.core.exceptions import ValidationError
from decimal import Decimal
from django.template.loader import render_to_string
from django.core.files.base import ContentFile
from django.core.validators import MaxValueValidator, MinValueValidator, validate_email
try:
from weasyprint import HTML
except ImportError:
HTML = None
import os
from configuration.models import SiteConfiguration
from thirdparty.zasilkovna.models import ZasilkovnaPacket
from thirdparty.stripe.models import StripeModel
from .tasks import notify_refund_accepted, notify_Ready_to_pickup, notify_zasilkovna_sended
#FIXME: přidat soft delete pro všchny modely !!!!
class Category(models.Model):
name = models.CharField(max_length=100)
#adresa kategorie např: /category/elektronika/mobily/
url = models.SlugField(unique=True)
#kategorie se můžou skládat pod sebe
parent = models.ForeignKey(
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='subcategories'
)
description = models.TextField(blank=True)
#ikona
image = models.ImageField(upload_to='categories/', blank=True)
class Meta:
verbose_name_plural = "Categories"
def __str__(self):
return self.name
#TODO: přidate brand model pro produkty (značky)
class Product(models.Model):
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
code = models.CharField(max_length=100, unique=True, blank=True, null=True)
variants = models.ManyToManyField(
"self",
symmetrical=True,
blank=True,
help_text=(
"Symetrické varianty produktu: pokud přidáte variantu A → B, "
"Django automaticky přidá i variantu B → A. "
"Všechny varianty jsou rovnocenné a zobrazí se vzájemně."
),
)
category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT)
# -- CENA --
price = models.DecimalField(max_digits=10, decimal_places=2, help_text="Net price (without VAT)")
currency = models.CharField(max_length=3, default="CZK")
# VAT rate - configured by business owner in configuration app!!!
vat_rate = models.ForeignKey(
'configuration.VATRate',
on_delete=models.PROTECT,
null=True,
blank=True,
help_text="VAT rate for this product. Leave empty to use default rate."
)
url = models.SlugField(unique=True)
stock = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
#časový limit (volitelné)
limited_to = models.DateTimeField(null=True, blank=True)
#TODO: delete
default_carrier = models.ForeignKey(
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
)
include_in_week_summary_email = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def available(self):
return self.is_active and self.stock > 0
def get_vat_rate(self):
"""Get the VAT rate for this product (from configuration or default)"""
if self.vat_rate:
return self.vat_rate
# Import here to avoid circular imports
from configuration.models import VATRate
return VATRate.get_default()
def get_price_with_vat(self):
"""Get price including VAT"""
vat_rate = self.get_vat_rate()
if not vat_rate:
return self.price # No VAT configured
return self.price * (Decimal('1') + vat_rate.rate_decimal)
def get_vat_amount(self):
"""Get the VAT amount for this product"""
vat_rate = self.get_vat_rate()
if not vat_rate:
return Decimal('0')
return self.price * vat_rate.rate_decimal
def __str__(self):
return f"{self.name} ({self.get_price_with_vat()} {self.currency.upper()} inkl. MwSt)"
#obrázek pro produkty
class ProductImage(models.Model):
product = models.ForeignKey(Product, related_name='images', on_delete=models.CASCADE)
image = models.ImageField(upload_to='products/')
alt_text = models.CharField(max_length=150, blank=True)
is_main = models.BooleanField(default=False)
order = models.PositiveIntegerField(default=0, help_text="Display order (lower numbers first)")
class Meta:
ordering = ['order', '-is_main', 'id']
def __str__(self):
return f"{self.product.name} image"
# ------------------ OBJEDNÁVKY ------------------
class Order(models.Model):
class OrderStatus(models.TextChoices):
CREATED = "created", "Vytvořeno"
CANCELLED = "cancelled", "Zrušeno"
COMPLETED = "completed", "Dokončeno"
REFUNDING = "refunding", "Vrácení v procesu"
REFUNDED = "refunded", "Vráceno"
status = models.CharField(
max_length=20, choices=OrderStatus.choices, null=True, blank=True, default=OrderStatus.CREATED
)
# Stored order grand total; recalculated on save
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
currency = models.CharField(max_length=10, default="CZK")
# fakturační údaje (zkopírované z user profilu při objednávce)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True
)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
email = models.EmailField()
phone = models.CharField(max_length=20, blank=True)
address = models.CharField(max_length=255)
city = models.CharField(max_length=100)
postal_code = models.CharField(max_length=20)
country = models.CharField(max_length=100, default="Czech Republic")
note = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
carrier = models.OneToOneField(
"Carrier",
on_delete=models.CASCADE,
related_name="order",
null=True,
blank=True
)
payment = models.OneToOneField(
"Payment",
on_delete=models.CASCADE,
related_name="order",
null=True,
blank=True
)
invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", null=True, blank=True)
#FIXME: změnnit název na discount_code
discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders")
def calculate_total_price(self):
carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0")
if self.discount.exists():
discounts = list(self.discount.all())
total = Decimal('0.0')
# getting all prices from order items (with discount applied if valid)
for item in self.items.all():
total = total + item.get_total_price(discounts)
return total + carrier_price
else:
total = Decimal('0.0')
# getting all prices from order items (without discount) - using VAT-inclusive prices
for item in self.items.all():
total = total + (item.product.get_price_with_vat() * item.quantity)
return total + carrier_price
def import_data_from_user(self):
"""Import user data into order for billing purposes."""
self.first_name = self.user.first_name
self.last_name = self.user.last_name
self.email = self.user.email
self.phone = self.user.phone
self.address = f"{self.user.street} {self.user.street_number}"
self.city = self.user.city
self.postal_code = self.user.postal_code
self.country = self.user.country
def clean(self):
"""Validate order data"""
# Validate required fields
required_fields = ['first_name', 'last_name', 'email', 'address', 'city', 'postal_code']
for field in required_fields:
if not getattr(self, field):
raise ValidationError(f"{field.replace('_', ' ').title()} is required.")
# Validate email format
try:
validate_email(self.email)
except ValidationError:
raise ValidationError("Invalid email format.")
# Validate order has items
if self.pk and not self.items.exists():
raise ValidationError("Order must have at least one item.")
def save(self, *args, **kwargs):
# Keep total_price always in sync with items and discount
self.total_price = self.calculate_total_price()
is_new = self.pk is None
if self.user and is_new:
self.import_data_from_user()
super().save(*args, **kwargs)
# Send email notification for new orders
if is_new and self.user:
from .tasks import notify_order_successfuly_created
notify_order_successfuly_created.delay(order=self, user=self.user)
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
class Carrier(models.Model):
class SHIPPING(models.TextChoices):
ZASILKOVNA = "packeta", "Zásilkovna"
DEUTSCHEPOST = "deutschepost", "Deutsche Post"
STORE = "store", "Osobní odběr"
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
class STATE(models.TextChoices):
PREPARING = "ordered", "Objednávka se připravuje"
SHIPPED = "shipped", "Odesláno"
DELIVERED = "delivered", "Doručeno"
READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí"
#RETURNING = "returning", "Vracení objednávky"
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING)
# prodejce to přidá později
zasilkovna = models.ManyToManyField(
ZasilkovnaPacket, blank=True, related_name="carriers"
)
# Deutsche Post integration (same pattern as zasilkovna)
deutschepost = models.ManyToManyField(
"deutschepost.DeutschePostOrder", blank=True, related_name="carriers"
)
weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Hmotnost zásilky v kg")
returning = models.BooleanField(default=False, help_text="Zda je tato zásilka na vrácení")
shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
def save(self, *args, **kwargs):
# Set shipping price for new carriers
if self.pk is None and self.shipping_price is None:
# For new carriers, we might not have an order yet
self.shipping_price = self.get_price(order=None)
# Check if state changed to ready for pickup
old_state = None
if self.pk:
old_carrier = Carrier.objects.filter(pk=self.pk).first()
old_state = old_carrier.state if old_carrier else None
super().save(*args, **kwargs)
# Send notification if state changed to ready for pickup
if (old_state != self.STATE.READY_TO_PICKUP and
self.state == self.STATE.READY_TO_PICKUP and
self.shipping_method == self.SHIPPING.STORE):
if hasattr(self, 'order') and self.order:
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
def get_price(self, order=None):
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
return SiteConfiguration.get_solo().zasilkovna_shipping_price
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
return SiteConfiguration.get_solo().deutschepost_shipping_price
elif self.shipping_method == self.SHIPPING.STORE:
# Store pickup is always free
return Decimal('0.0')
else:
# Check for free shipping based on order total
if order is None:
order = Order.objects.filter(carrier=self).first()
if order and order.total_price >= SiteConfiguration.get_solo().free_shipping_over:
return Decimal('0.0')
else:
return SiteConfiguration.get_solo().default_shipping_price or Decimal('50.0') # fallback price
#tohle bude vyvoláno pomocí admina přes api!!!
def start_ordering_shipping(self):
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
# Uživatel může objednat více zásilek pokud potřeba
self.zasilkovna.add(ZasilkovnaPacket.objects.create())
self.returning = False
self.save()
notify_zasilkovna_sended.delay(order=self.order, user=self.order.user)
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
# Import here to avoid circular imports
from thirdparty.deutschepost.models import DeutschePostOrder
# Create new Deutsche Post order and add to carrier (same pattern as zasilkovna)
dp_order = DeutschePostOrder.objects.create()
self.deutschepost.add(dp_order)
self.returning = False
self.save()
# Order shipping through Deutsche Post API
dp_order.order_shippment()
elif self.shipping_method == self.SHIPPING.STORE:
self.state = self.STATE.READY_TO_PICKUP
self.save()
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
else:
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.")
#... další logika pro jiné způsoby dopravy (do budoucna!)
def ready_to_pickup(self):
if self.shipping_method == self.SHIPPING.STORE:
self.state = self.STATE.READY_TO_PICKUP
self.save()
else:
raise ValidationError("Tato metoda dopravy nepodporuje připravení k vyzvednutí.")
# def returning_shipping(self, int:id):
# self.returning = True
# if self.shipping_method == self.SHIPPING.ZASILKOVNA:
# #volá se na api Zásilkovny
# self.zasilkovna.get(id=id).returning_packet()
# ------------------ PLATEBNÍ MODELY ------------------
class Payment(models.Model):
class PAYMENT(models.TextChoices):
SHOP = "shop", "Platba v obchodě"
STRIPE = "stripe", "Platební Brána"
CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
#FIXME: potvrdit že logika platby funguje správně
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
stripe = models.OneToOneField(
StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
)
payed_at_shop = models.BooleanField(default=False, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def clean(self):
"""Validate payment and shipping method combinations"""
# Validate payment method consistency
if self.payment_method == self.PAYMENT.STRIPE and not self.stripe:
raise ValidationError("Stripe payment method requires a linked StripeModel instance.")
elif self.payment_method == self.PAYMENT.SHOP and self.stripe:
raise ValidationError("Shop payment method should not have a linked StripeModel instance.")
# Validate payment and shipping compatibility
if self.payment_method == self.PAYMENT.SHOP:
# SHOP payment only works with STORE pickup - customer pays at physical store
if Order.objects.filter(payment=self).exists():
order = Order.objects.get(payment=self)
if order.carrier and order.carrier.shipping_method != Carrier.SHIPPING.STORE:
raise ValidationError(
"Shop payment is only compatible with store pickup. "
"For shipping orders, use Stripe or Cash on Delivery payment methods."
)
elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY:
# Cash on delivery only works with shipping methods (not store pickup)
if Order.objects.filter(payment=self).exists():
order = Order.objects.get(payment=self)
if order.carrier and order.carrier.shipping_method == Carrier.SHIPPING.STORE:
raise ValidationError(
"Cash on delivery is not compatible with store pickup. "
"For store pickup, use shop payment method."
)
# STRIPE payment works with all shipping methods - no additional validation needed
super().clean()
def payed_manually(self):
"""Mark payment as completed"""
if self.payment_method == self.PAYMENT.SHOP:
self.payed_at_shop = True
self.save()
else:
raise ValidationError("Manuální platba je povolena pouze pro platbu v obchodě.")
# ------------------ SLEVOVÉ KÓDY ------------------
class DiscountCode(models.Model):
code = models.CharField(max_length=50, unique=True)
description = models.CharField(max_length=255, blank=True)
# sleva v procentech (0100)
percent = models.PositiveIntegerField(
validators=[MinValueValidator(0), MaxValueValidator(100)],
help_text="Procento sleva 0-100",
null=True,
blank=True
)
# nebo fixní částka
amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK")
valid_from = models.DateTimeField(default=timezone.now)
valid_to = models.DateTimeField(null=True, blank=True)
active = models.BooleanField(default=True)
#max počet použití
usage_limit = models.PositiveIntegerField(null=True, blank=True)
used_count = models.PositiveIntegerField(default=0)
specific_products = models.ManyToManyField(
Product, blank=True, related_name="discount_codes"
)
specific_categories = models.ManyToManyField(
Category, blank=True, related_name="discount_codes"
)
def is_valid(self):
now = timezone.now()
if not self.active:
return False
if self.valid_to and self.valid_to < now:
return False
if self.usage_limit and self.used_count >= self.usage_limit:
return False
return True
def __str__(self):
return f"{self.code} ({self.percent}% or {self.amount} CZK)"
# ------------------ OBJEDNANÉ POLOŽKY ------------------
class OrderItem(models.Model):
order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
product = models.ForeignKey("commerce.Product", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
def get_total_price(self, discounts: list[DiscountCode] = list()):
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
Logika dle SiteConfiguration:
- multiplying_coupons=True: procentuální slevy se násobí (sekvenčně)
P * (1 - p1) -> výsledné * (1 - p2) ...
jinak se použije pouze nejlepší (nejvyšší procento).
- addition_of_coupons_amount=True: fixní částky (amount) se sčítají,
jinak se použije pouze nejvyšší částka.
- Kombinace: nejprve procentuální část, poté odečtení fixní částky.
- Sleva se nikdy nesmí dostat pod 0.
"""
# Use VAT-inclusive price for customer-facing calculations
base_price = self.product.get_price_with_vat() * self.quantity
if not discounts or discounts == []:
return base_price
config = SiteConfiguration.get_solo()
#seznám slev
applicable_percent_discounts: list[int] = []
applicable_amount_discounts: list[Decimal] = []
#procházení kupónů a určení, které se aplikují
for discount in set(discounts):
if not discount:
continue
if not discount.is_valid():
raise ValueError("Invalid discount code.")
#defaulting
applies = False
# Určení, zda kupon platí pro produkt/kategorii
# prázdný produkt a kategorie = globální kupon
if discount.specific_products.exists() or discount.specific_categories.exists():
if (self.product in discount.specific_products.all() or self.product.category in discount.specific_categories.all()):
applies = True
else:
applies = True #global
if not applies:
continue
if discount.percent is not None:
applicable_percent_discounts.append(discount.percent)
elif discount.amount is not None:
applicable_amount_discounts.append(discount.amount)
final_price = base_price
# Procentuální slevy
if applicable_percent_discounts:
if config.multiplying_coupons:
for pct in applicable_percent_discounts:
factor = (Decimal('1') - (Decimal(pct) / Decimal('100')))
final_price = final_price * factor
else:
best_pct = max(applicable_percent_discounts)
factor = (Decimal('1') - (Decimal(best_pct) / Decimal('100')))
final_price = final_price * factor
# Fixní částky
if applicable_amount_discounts:
if config.addition_of_coupons_amount:
total_amount = sum(applicable_amount_discounts)
else:
total_amount = max(applicable_amount_discounts)
final_price = final_price - total_amount
if final_price < Decimal('0'):
final_price = Decimal('0')
return final_price.quantize(Decimal('0.01'))
def __str__(self):
return f"{self.product.name} x{self.quantity}"
def save(self, *args, **kwargs):
if self.pk is None:
# Check if order already has a processed payment
if (self.order.payment and
self.order.payment.payment_method and
self.order.payment.payment_method != Payment.PAYMENT.SHOP):
raise ValueError("Cannot modify items from order with processed payment method.")
# Validate stock availability
if self.product.stock < self.quantity:
raise ValueError(f"Insufficient stock for product {self.product.name}. Available: {self.product.stock}")
# Reduce stock
self.product.stock -= self.quantity
self.product.save(update_fields=["stock"])
super().save(*args, **kwargs)
class Refund(models.Model):
order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE)
class Reason(models.TextChoices):
RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty"
DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt"
WRONG_ITEM = "wrong_item", "Špatná položka"
OTHER = "other", "Jiný důvod"
reason_choice = models.CharField(max_length=40, choices=Reason.choices)
reason_text = models.TextField(blank=True)
verified = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
#VRACENÍ ZÁSILKY, LOGIKA (DISABLED FOR NOW)
# def save(self, *args, **kwargs):
# # Automaticky aktualizovat stav objednávky na "vráceno"
# if self.pk is None:
# self.order.status = Order.Status.REFUNDING
# self.order.save(update_fields=["status", "updated_at"])
# shipping_method = self.order.carrier.shipping_method
# if shipping_method == Carrier.SHIPPING.ZASILKOVNA:
# carrier = self.order.carrier;
# # poslední odeslána/vytvořená zásilka
# # Iniciovat vrácení přes Zásilkovnu
# carrier.zasilkovna.latest('created_at').returning_packet()
# carrier.save()
# else:
# # Logika pro jiné způsoby dopravy
# pass
# super().save(*args, **kwargs)
def save(self, *args, **kwargs):
# Automaticky aktualizovat stav objednávky na "vráceno"
if self.pk is None:
if self.order.status != Order.OrderStatus.REFUNDING:
self.order.status = Order.OrderStatus.REFUNDING
self.order.save(update_fields=["status", "updated_at"])
super().save(*args, **kwargs)
def refund_completed(self):
# Aktualizovat stav objednávky na "vráceno"
if self.order.payment and self.order.payment.payment_method == Payment.PAYMENT.STRIPE:
self.order.payment.stripe.refund() # Vrácení pěnez přes stripe
self.order.status = Order.OrderStatus.REFUNDED
self.order.save(update_fields=["status", "updated_at"])
notify_refund_accepted.delay(order=self.order, user=self.order.user)
def generate_refund_pdf_for_customer(self):
"""Vygeneruje PDF formulář k vrácení zboží pro zákazníka.
Šablona refund/customer_in_package_returning_form.html očekává:
- order: objekt objednávky
- items: seznam položek (dict) s klíči product_name, sku, quantity, variant, options, reason
- return_reason: textový důvod vrácení (kombinace reason_text / reason_choice)
Návratová hodnota: bytes (PDF obsah). Uložení necháváme na volající logice.
"""
order = self.order
# Připravíme položky pro šablonu (důvody per položku zatím None lze rozšířit)
prepared_items: list[dict] = []
for item in order.items.select_related('product'):
prepared_items.append({
"product_name": getattr(item.product, "name", "Item"),
"name": getattr(item.product, "name", "Item"), # fallbacky pro různé názvy v šabloně
"sku": getattr(item.product, "code", None),
"quantity": item.quantity,
"variant": None, # lze doplnit pokud existují varianty
"options": None, # lze doplnit pokud existují volby
"reason": None, # per-item reason (zatím nepodporováno)
})
return_reason = self.reason_text or self.get_reason_choice_display()
context = {
"order": order,
"items": prepared_items,
"return_reason": return_reason,
}
html_string = render_to_string("refund/customer_in_package_returning_form.html", context)
# Import WeasyPrint lazily to avoid startup failures when system
# libraries (Pango/GObject) are not present on Windows.
if HTML is None:
raise RuntimeError(
"WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker."
)
pdf_bytes = HTML(string=html_string).write_pdf()
return pdf_bytes
class Invoice(models.Model):
invoice_number = models.CharField(max_length=50, unique=True)
issued_at = models.DateTimeField(auto_now_add=True)
due_date = models.DateTimeField()
pdf_file = models.FileField(upload_to='invoices/')
def __str__(self):
return f"Invoice {self.invoice_number} for Order {self.order.id}"
def generate_invoice_pdf(self):
order = Order.objects.get(invoice=self)
# Render HTML
html_string = render_to_string("invoice/Order.html", {"invoice": self, "order": order})
# Import WeasyPrint lazily to avoid startup failures when system
# libraries (Pango/GObject) are not present on Windows.
if HTML is None:
raise RuntimeError(
"WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker."
)
pdf_bytes = HTML(string=html_string).write_pdf()
# Save directly to FileField
self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes))
self.save()
class Review(models.Model):
product = models.ForeignKey(Product, related_name="reviews", on_delete=models.CASCADE)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
)
rating = models.PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
comment = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('product', 'user') # Prevent multiple reviews per user per product
indexes = [
models.Index(fields=['product', 'rating']),
models.Index(fields=['created_at']),
]
def clean(self):
"""Validate that user hasn't already reviewed this product"""
if self.pk is None: # Only for new reviews
if Review.objects.filter(product=self.product, user=self.user).exists():
raise ValidationError("User has already reviewed this product.")
def __str__(self):
return f"Review for {self.product.name} by {self.user.username}"
# ------------------ SHOPPING CART ------------------
class Cart(models.Model):
"""Shopping cart for both authenticated and anonymous users"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="cart"
)
session_key = models.CharField(
max_length=40,
null=True,
blank=True,
help_text="Session key for anonymous users"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Cart"
verbose_name_plural = "Carts"
def __str__(self):
if self.user:
return f"Cart for {self.user.email}"
return f"Anonymous cart ({self.session_key})"
def get_total(self):
"""Calculate total price of all items in cart including VAT"""
total = Decimal('0.0')
for item in self.items.all():
total += item.get_subtotal()
return total
def get_items_count(self):
"""Get total number of items in cart"""
return sum(item.quantity for item in self.items.all())
class CartItem(models.Model):
"""Individual items in a shopping cart"""
cart = models.ForeignKey(Cart, related_name='items', on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Cart Item"
verbose_name_plural = "Cart Items"
unique_together = ('cart', 'product') # Prevent duplicate products in same cart
def __str__(self):
return f"{self.quantity}x {self.product.name} in cart"
def get_subtotal(self):
"""Calculate subtotal for this cart item including VAT"""
return self.product.get_price_with_vat() * self.quantity
def clean(self):
"""Validate that product has enough stock"""
if self.product.stock < self.quantity:
raise ValidationError(f"Not enough stock for {self.product.name}. Available: {self.product.stock}")
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
# ------------------ WISHLIST ------------------
class Wishlist(models.Model):
"""User's wishlist for saving favorite products"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="wishlist"
)
products = models.ManyToManyField(
Product,
blank=True,
related_name="wishlisted_by",
help_text="Products saved by the user"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Wishlist"
verbose_name_plural = "Wishlists"
def __str__(self):
return f"Wishlist for {self.user.email}"
def add_product(self, product):
"""Add a product to wishlist"""
self.products.add(product)
def remove_product(self, product):
"""Remove a product from wishlist"""
self.products.remove(product)
def has_product(self, product):
"""Check if product is in wishlist"""
return self.products.filter(pk=product.pk).exists()
def get_products_count(self):
"""Get count of products in wishlist"""
return self.products.count()

View File

@@ -0,0 +1,561 @@
from rest_framework import serializers
from thirdparty.stripe.client import StripeClient
from .models import Refund, Order, Invoice, Review
class RefundCreatePublicSerializer(serializers.Serializer):
email = serializers.EmailField()
invoice_number = serializers.CharField(required=False, allow_blank=True)
order_id = serializers.IntegerField(required=False)
# Optional reason fields
reason_choice = serializers.ChoiceField(
choices=Refund.Reason.choices, required=False
)
reason_text = serializers.CharField(required=False, allow_blank=True)
def validate(self, attrs):
email = attrs.get("email")
invoice_number = (attrs.get("invoice_number") or "").strip()
order_id = attrs.get("order_id")
if not invoice_number and not order_id:
raise serializers.ValidationError(
"Provide either invoice_number or order_id."
)
order = None
if invoice_number:
try:
invoice = Invoice.objects.get(invoice_number=invoice_number)
order = invoice.order
except Invoice.DoesNotExist:
raise serializers.ValidationError({"invoice_number": "Invoice not found."})
except Order.DoesNotExist:
raise serializers.ValidationError({"invoice_number": "Order for invoice not found."})
if order_id and order is None:
try:
order = Order.objects.get(id=order_id)
except Order.DoesNotExist:
raise serializers.ValidationError({"order_id": "Order not found."})
# Verify email matches order's email or user's email
if not order:
raise serializers.ValidationError("Order could not be resolved.")
order_email = (order.email or "").strip().lower()
user_email = (getattr(order.user, "email", "") or "").strip().lower()
provided = email.strip().lower()
if provided not in {order_email, user_email}:
raise serializers.ValidationError({"email": "Email does not match the order."})
attrs["order"] = order
return attrs
def create(self, validated_data):
order = validated_data["order"]
reason_choice = validated_data.get("reason_choice") or Refund.Reason.OTHER
reason_text = validated_data.get("reason_text", "")
refund = Refund.objects.create(
order=order,
reason_choice=reason_choice,
reason_text=reason_text,
)
return refund
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.db import transaction
from django.core.exceptions import ValidationError
from .models import (
Category,
Product,
ProductImage,
DiscountCode,
Refund,
Order,
OrderItem,
Carrier,
Payment,
Cart,
CartItem,
Wishlist,
)
from thirdparty.stripe.models import StripeModel
from thirdparty.zasilkovna.serializers import ZasilkovnaPacketSerializer
from thirdparty.zasilkovna.models import ZasilkovnaPacket
User = get_user_model()
# ----------------- CREATING ORDER SERIALIZER -----------------
#correct
# -- CARRIER --
class OrderCarrierSerializer(serializers.ModelSerializer):
# vstup: jen ID adresy z widgetu (write-only)
packeta_address_id = serializers.IntegerField(required=False, write_only=True)
# výstup: serializovaný packet
zasilkovna = ZasilkovnaPacketSerializer(many=True, read_only=True)
class Meta:
model = Carrier
fields = ["shipping_method", "state", "zasilkovna", "shipping_price", "packeta_address_id"]
read_only_fields = ["state", "shipping_price", "zasilkovna"]
def create(self, validated_data):
packeta_address_id = validated_data.pop("packeta_address_id", None)
carrier = Carrier.objects.create(**validated_data)
if packeta_address_id is not None:
# vytvoříme nový packet s danou addressId
packet = ZasilkovnaPacket.objects.create(addressId=packeta_address_id)
carrier.zasilkovna.add(packet)
return carrier
#correct
# -- ORDER ITEMs --
class OrderItemCreateSerializer(serializers.Serializer):
product_id = serializers.IntegerField()
quantity = serializers.IntegerField(min_value=1, default=1)
def validate(self, attrs):
product_id = attrs.get("product_id")
try:
product = Product.objects.get(pk=product_id)
except Product.DoesNotExist:
raise serializers.ValidationError({"product_id": "Product not found."})
attrs["product"] = product
return attrs
# -- PAYMENT --
class PaymentSerializer(serializers.ModelSerializer):
stripe_session_id = serializers.CharField(source='stripe.stripe_session_id', read_only=True)
stripe_payment_intent = serializers.CharField(source='stripe.stripe_payment_intent', read_only=True)
stripe_session_url = serializers.URLField(source='stripe.stripe_session_url', read_only=True)
class Meta:
model = Payment
fields = [
"id",
"payment_method",
"stripe",
"stripe_session_id",
"stripe_payment_intent",
"stripe_session_url",
]
read_only_fields = [
"id",
"stripe",
"stripe_session_id",
"stripe_payment_intent",
"stripe_session_url",
]
def create(self, validated_data):
order = self.context.get("order") # musíš ho předat při inicializaci serializeru
carrier = self.context.get("carrier")
with transaction.atomic():
payment = Payment.objects.create(
order=order,
carrier=carrier,
**validated_data
)
# pokud je Stripe, vytvoříme checkout session
if payment.payment_method == Payment.PAYMENT.SHOP and carrier.shipping_method != Carrier.SHIPPING.STORE:
raise serializers.ValidationError("Platba v obchodě je možná pouze pro osobní odběr.")
elif payment.payment_method == Payment.PAYMENT.CASH_ON_DELIVERY and carrier.shipping_method == Carrier.SHIPPING.STORE:
raise ValidationError("Dobírka není možná pro osobní odběr.")
if payment.payment_method == Payment.PAYMENT.STRIPE:
session = StripeClient.create_checkout_session(order)
stripe_instance = StripeModel.objects.create(
stripe_session_id=session.id,
stripe_payment_intent=session.payment_intent,
stripe_session_url=session.url,
status=StripeModel.STATUS_CHOICES.PENDING
)
payment.stripe = stripe_instance
payment.save(update_fields=["stripe"])
return payment
# -- ORDER CREATE SERIALIZER --
class OrderCreateSerializer(serializers.Serializer):
# Customer/billing data (optional when authenticated)
first_name = serializers.CharField(required=False)
last_name = serializers.CharField(required=False)
email = serializers.EmailField(required=False)
phone = serializers.CharField(required=False, allow_blank=True)
address = serializers.CharField(required=False)
city = serializers.CharField(required=False)
postal_code = serializers.CharField(required=False)
country = serializers.CharField(required=False, default="Czech Republic")
note = serializers.CharField(required=False, allow_blank=True)
# Nested structures
#produkty
items = OrderItemCreateSerializer(many=True)
#doprava/vyzvednutí + zasilkovna input (serializer)
carrier = OrderCarrierSerializer()
payment = PaymentSerializer()
#slevové kódy
discount_codes = serializers.ListField(
child=serializers.CharField(), required=False, allow_empty=True
)
def validate(self, attrs):
request = self.context.get("request")
#kontrola jestli je uzivatel valid/prihlasen
is_auth = bool(getattr(getattr(request, "user", None), "is_authenticated", False))
# pokud není, tak se musí vyplnit povinné údaje
required_fields = [
"first_name",
"last_name",
"email",
"address",
"city",
"postal_code",
]
if not is_auth:
missing_fields = []
# přidame fieldy, které nejsou vyplněné
for field in required_fields:
if attrs.get(field) not in required_fields:
missing_fields.append(field)
if missing_fields:
raise serializers.ValidationError({"billing": f"Missing fields: {', '.join(missing_fields)}"})
# pokud chybí itemy:
if not attrs.get("items"):
raise serializers.ValidationError({"items": "At least one item is required."})
return attrs
def create(self, validated_data):
items_data = validated_data.pop("items", [])
carrier_data = validated_data.pop("carrier")
payment_data = validated_data.pop("payment")
codes = validated_data.pop("discount_codes", [])
request = self.context.get("request")
user = getattr(request, "user", None)
is_auth = bool(getattr(user, "is_authenticated", False))
with transaction.atomic():
# Create Order (user data imported on save if user is set)
order = Order(
user=user if is_auth else None,
first_name=validated_data.get("first_name", ""),
last_name=validated_data.get("last_name", ""),
email=validated_data.get("email", ""),
phone=validated_data.get("phone", ""),
address=validated_data.get("address", ""),
city=validated_data.get("city", ""),
postal_code=validated_data.get("postal_code", ""),
country=validated_data.get("country", "Czech Republic"),
note=validated_data.get("note", ""),
)
# Order.save se postara o to jestli má doplnit data z usera
order.save()
# Vytvoření Carrier skrz serializer
carrier = OrderCarrierSerializer(data=carrier_data)
carrier.is_valid(raise_exception=True)
carrier = carrier.save()
order.carrier = carrier
order.save(update_fields=["carrier", "updated_at"]) # will recalc total later
# Vytvořit Order Items individualně, aby se spustila kontrola položek na skladu
for item in items_data:
product = item["product"] # OrderItemCreateSerializer.validate
quantity = int(item.get("quantity", 1))
OrderItem.objects.create(order=order, product=product, quantity=quantity)
# -- Slevové kódy --
if codes:
discounts = list(DiscountCode.objects.filter(code__in=codes))
if discounts:
order.discount.add(*discounts)
# -- Payment --
payment_serializer = PaymentSerializer(
data=payment_data,
context={"order": order, "carrier": carrier}
)
payment_serializer.is_valid(raise_exception=True)
payment = payment_serializer.save()
# přiřadíme k orderu
order.payment = payment
order.save(update_fields=["payment"])
return order
# ----------------- ADMIN/READ MODELS -----------------
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = [
"id",
"name",
"url",
"parent",
"description",
"image",
]
class ProductImageSerializer(serializers.ModelSerializer):
class Meta:
model = ProductImage
fields = [
"id",
"product",
"image",
"alt_text",
"is_main",
]
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = "__all__"
read_only_fields = ["created_at", "updated_at"]
class DiscountCodeSerializer(serializers.ModelSerializer):
class Meta:
model = DiscountCode
fields = "__all__"
read_only_fields = ["used_count"]
class RefundSerializer(serializers.ModelSerializer):
class Meta:
model = Refund
fields = "__all__"
read_only_fields = ["id", "verified", "created_at"]
# ----------------- READ SERIALIZERS USED BY VIEWS -----------------
class ZasilkovnaPacketReadSerializer(ZasilkovnaPacketSerializer):
class Meta(ZasilkovnaPacketSerializer.Meta):
fields = getattr(ZasilkovnaPacketSerializer.Meta, "fields", None)
class CarrierReadSerializer(serializers.ModelSerializer):
zasilkovna = ZasilkovnaPacketReadSerializer(many=True, read_only=True)
class Meta:
model = Carrier
fields = ["shipping_method", "state", "zasilkovna", "shipping_price"]
read_only_fields = fields
class ProductMiniSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ["id", "name", "price"]
class OrderItemReadSerializer(serializers.ModelSerializer):
product = ProductMiniSerializer(read_only=True)
class Meta:
model = OrderItem
fields = ["id", "product", "quantity"]
read_only_fields = fields
class PaymentReadSerializer(serializers.ModelSerializer):
class Meta:
model = Payment
fields = ["payment_method"]
read_only_fields = fields
class OrderMiniSerializer(serializers.ModelSerializer):
status = serializers.ChoiceField(
choices=Order.OrderStatus.choices,
read_only=True
)
class Meta:
model = Order
fields = ["id", "status", "total_price", "created_at"]
read_only_fields = fields
class OrderReadSerializer(serializers.ModelSerializer):
status = serializers.ChoiceField(
choices=Order.OrderStatus.choices,
read_only=True
)
items = OrderItemReadSerializer(many=True, read_only=True)
carrier = CarrierReadSerializer(read_only=True)
payment = PaymentReadSerializer(read_only=True)
discount_codes = serializers.SerializerMethodField()
class Meta:
model = Order
fields = [
"id",
"status",
"total_price",
"currency",
"user",
"first_name",
"last_name",
"email",
"phone",
"address",
"city",
"postal_code",
"country",
"note",
"created_at",
"updated_at",
"items",
"carrier",
"payment",
"discount_codes",
]
read_only_fields = fields
def get_discount_codes(self, obj: Order):
return list(obj.discount.values_list("code", flat=True))
class ReviewSerializerPublic(serializers.ModelSerializer):
class Meta:
model = Review
fields = "__all__"
read_only_fields = ['user', 'created_at', 'updated_at']
# ----------------- CART SERIALIZERS -----------------
class CartItemSerializer(serializers.ModelSerializer):
product_name = serializers.CharField(source='product.name', read_only=True)
product_price = serializers.DecimalField(source='product.price', max_digits=10, decimal_places=2, read_only=True)
subtotal = serializers.SerializerMethodField()
class Meta:
model = CartItem
fields = ['id', 'product', 'product_name', 'product_price', 'quantity', 'subtotal', 'added_at']
read_only_fields = ['id', 'added_at']
def get_subtotal(self, obj):
return obj.get_subtotal()
def validate_quantity(self, value):
if value < 1:
raise serializers.ValidationError("Quantity must be at least 1")
return value
class CartItemCreateSerializer(serializers.Serializer):
product_id = serializers.IntegerField()
quantity = serializers.IntegerField(min_value=1, default=1)
def validate_product_id(self, value):
try:
Product.objects.get(pk=value, is_active=True)
except Product.DoesNotExist:
raise serializers.ValidationError("Product not found or inactive.")
return value
class CartSerializer(serializers.ModelSerializer):
items = CartItemSerializer(many=True, read_only=True)
total = serializers.SerializerMethodField()
items_count = serializers.SerializerMethodField()
class Meta:
model = Cart
fields = ['id', 'user', 'items', 'total', 'items_count', 'created_at', 'updated_at']
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
def get_total(self, obj):
return obj.get_total()
def get_items_count(self, obj):
return obj.get_items_count()
# ----------------- WISHLIST SERIALIZERS -----------------
class ProductMiniForWishlistSerializer(serializers.ModelSerializer):
"""Minimal product info for wishlist display"""
class Meta:
model = Product
fields = ['id', 'name', 'price', 'is_active', 'stock']
class WishlistSerializer(serializers.ModelSerializer):
products = ProductMiniForWishlistSerializer(many=True, read_only=True)
products_count = serializers.SerializerMethodField()
class Meta:
model = Wishlist
fields = ['id', 'user', 'products', 'products_count', 'created_at', 'updated_at']
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
def get_products_count(self, obj):
return obj.get_products_count()
class WishlistProductActionSerializer(serializers.Serializer):
"""For adding/removing products from wishlist"""
product_id = serializers.IntegerField()
def validate_product_id(self, value):
try:
Product.objects.get(pk=value, is_active=True)
except Product.DoesNotExist:
raise serializers.ValidationError("Product not found or inactive.")
return value

168
backend/commerce/tasks.py Normal file
View File

@@ -0,0 +1,168 @@
from account.tasks import send_email_with_context
from celery import shared_task
from django.apps import apps
from django.utils import timezone
# -- CLEANUP TASKS --
# Delete expired/cancelled orders (older than 24 hours)
@shared_task
def delete_expired_orders():
Order = apps.get_model('commerce', 'Order')
expired_orders = Order.objects.filter(status=Order.OrderStatus.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24))
count = expired_orders.count()
expired_orders.delete()
return count
# -- NOTIFICATIONS CARRIER --
# Zásilkovna
@shared_task
def notify_zasilkovna_sended(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_sended:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Your order has been shipped",
template_path="email/order_sended.html",
context={
"user": user,
"order": order,
})
pass
# Shop
@shared_task
def notify_Ready_to_pickup(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_sended:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Your order is ready for pickup",
template_path="email/order_ready_pickup.html",
context={
"user": user,
"order": order,
})
pass
# -- NOTIFICATIONS ORDER --
@shared_task
def notify_order_successfuly_created(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_successfuly_created:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Your order has been successfully created",
template_path="email/order_created.html",
context={
"user": user,
"order": order,
})
pass
@shared_task
def notify_order_payed(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_paid:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Your order has been paid",
template_path="email/order_paid.html",
context={
"user": user,
"order": order,
})
pass
@shared_task
def notify_about_missing_payment(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_about_missing_payment:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Payment missing for your order",
template_path="email/order_missing_payment.html",
context={
"user": user,
"order": order,
})
pass
# -- NOTIFICATIONS REFUND --
@shared_task
def notify_refund_items_arrived(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_refund_items_arrived:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Your refund items have arrived",
template_path="email/order_refund_items_arrived.html",
context={
"user": user,
"order": order,
})
pass
# Refund accepted, retuning money
@shared_task
def notify_refund_accepted(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_refund_accepted:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Your refund has been accepted",
template_path="email/order_refund_accepted.html",
context={
"user": user,
"order": order,
})
pass
#

View File

@@ -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 %}&nbsp;{% 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>

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

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

36
backend/commerce/urls.py Normal file
View File

@@ -0,0 +1,36 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
OrderViewSet,
ProductViewSet,
CategoryViewSet,
ProductImageViewSet,
DiscountCodeViewSet,
RefundViewSet,
RefundPublicView,
ReviewPostPublicView,
ReviewPublicViewSet,
CartViewSet,
WishlistViewSet,
AdminWishlistViewSet,
AnalyticsView,
)
router = DefaultRouter()
router.register(r'orders', OrderViewSet)
router.register(r'products', ProductViewSet, basename='product')
router.register(r'categories', CategoryViewSet, basename='category')
router.register(r'product-images', ProductImageViewSet, basename='product-image')
router.register(r'discount-codes', DiscountCodeViewSet, basename='discount-code')
router.register(r'refunds', RefundViewSet, basename='refund')
router.register(r'reviews', ReviewPublicViewSet, basename='review')
router.register(r'cart', CartViewSet, basename='cart')
router.register(r'wishlist', WishlistViewSet, basename='wishlist')
router.register(r'admin/wishlists', AdminWishlistViewSet, basename='admin-wishlist')
urlpatterns = [
path('', include(router.urls)),
path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'),
path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'),
path('analytics/', AnalyticsView.as_view(), name='analytics'),
]

1097
backend/commerce/views.py Normal file

File diff suppressed because it is too large Load Diff

View File

View 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"

View 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)

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.7 on 2025-12-18 15:11
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='SiteConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Shop name', max_length=100, unique=True)),
('logo', models.ImageField(blank=True, null=True, upload_to='shop_logos/')),
('favicon', models.ImageField(blank=True, null=True, upload_to='shop_favicons/')),
('contact_email', models.EmailField(blank=True, max_length=254, null=True)),
('contact_phone', models.CharField(blank=True, max_length=20, null=True)),
('contact_address', models.TextField(blank=True, null=True)),
('opening_hours', models.JSONField(blank=True, null=True)),
('facebook_url', models.URLField(blank=True, null=True)),
('instagram_url', models.URLField(blank=True, null=True)),
('youtube_url', models.URLField(blank=True, null=True)),
('tiktok_url', models.URLField(blank=True, null=True)),
('whatsapp_number', models.CharField(blank=True, max_length=20, null=True)),
('zasilkovna_shipping_price', models.DecimalField(decimal_places=2, default=50, max_digits=10)),
('zasilkovna_api_key', models.CharField(blank=True, help_text='API klíč pro přístup k Zásilkovna API (zatím není využito)', max_length=255, null=True)),
('zasilkovna_api_password', models.CharField(blank=True, help_text='API heslo pro přístup k Zásilkovna API (zatím není využito)', max_length=255, null=True)),
('free_shipping_over', models.DecimalField(decimal_places=2, default=2000, max_digits=10)),
('multiplying_coupons', models.BooleanField(default=True, help_text='Násobení kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón')),
('addition_of_coupons_amount', models.BooleanField(default=False, help_text='Sčítání slevových kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón')),
('currency', models.CharField(choices=[('CZK', 'cz#Czech Koruna'), ('EUR', 'cz#Euro')], default='CZK', max_length=10)),
],
options={
'verbose_name': 'Shop Configuration',
'verbose_name_plural': 'Shop Configuration',
},
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2026-01-17 01:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('configuration', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='siteconfiguration',
name='deutschepost_api_url',
field=models.URLField(default='https://gw.sandbox.deutschepost.com', help_text='Deutsche Post API URL (sandbox/production)', max_length=255),
),
migrations.AddField(
model_name='siteconfiguration',
name='deutschepost_client_id',
field=models.CharField(blank=True, help_text='Deutsche Post OAuth Client ID', max_length=255, null=True),
),
migrations.AddField(
model_name='siteconfiguration',
name='deutschepost_client_secret',
field=models.CharField(blank=True, help_text='Deutsche Post OAuth Client Secret', max_length=255, null=True),
),
migrations.AddField(
model_name='siteconfiguration',
name='deutschepost_customer_ekp',
field=models.CharField(blank=True, help_text='Deutsche Post Customer EKP number', max_length=20, null=True),
),
migrations.AddField(
model_name='siteconfiguration',
name='deutschepost_shipping_price',
field=models.DecimalField(decimal_places=2, default=150, help_text='Default Deutsche Post shipping price', max_digits=10),
),
]

View File

@@ -0,0 +1,125 @@
import decimal
from django.db import models
from decimal import Decimal
from django.core.validators import MinValueValidator, MaxValueValidator
# Create your models here.
class SiteConfiguration(models.Model):
name = models.CharField(max_length=100, default="Shop name", unique=True)
logo = models.ImageField(upload_to='shop_logos/', blank=True, null=True)
favicon = models.ImageField(upload_to='shop_favicons/', blank=True, null=True)
contact_email = models.EmailField(max_length=254, blank=True, null=True)
contact_phone = models.CharField(max_length=20, blank=True, null=True)
contact_address = models.TextField(blank=True, null=True)
opening_hours = models.JSONField(blank=True, null=True) #FIXME: vytvoř JSON tvar pro otvírací dobu, přes validátory
#Social
facebook_url = models.URLField(max_length=200, blank=True, null=True)
instagram_url = models.URLField(max_length=200, blank=True, null=True)
youtube_url = models.URLField(max_length=200, blank=True, null=True)
tiktok_url = models.URLField(max_length=200, blank=True, null=True)
whatsapp_number = models.CharField(max_length=20, blank=True, null=True)
#zasilkovna settings
zasilkovna_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("50.00"))
#FIXME: není implementováno ↓↓↓
zasilkovna_api_key = models.CharField(max_length=255, blank=True, null=True, help_text="API klíč pro přístup k Zásilkovna API (zatím není využito)")
#FIXME: není implementováno ↓↓↓
zasilkovna_api_password = models.CharField(max_length=255, blank=True, null=True, help_text="API heslo pro přístup k Zásilkovna API (zatím není využito)")
#FIXME: není implementováno ↓↓↓
free_shipping_over = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("2000.00"))
# Deutsche Post settings
deutschepost_api_url = models.URLField(max_length=255, default="https://gw.sandbox.deutschepost.com", help_text="Deutsche Post API URL (sandbox/production)")
deutschepost_client_id = models.CharField(max_length=255, blank=True, null=True, help_text="Deutsche Post OAuth Client ID")
deutschepost_client_secret = models.CharField(max_length=255, blank=True, null=True, help_text="Deutsche Post OAuth Client Secret")
deutschepost_customer_ekp = models.CharField(max_length=20, blank=True, null=True, help_text="Deutsche Post Customer EKP number")
deutschepost_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("6.00"), help_text="Default Deutsche Post shipping price in EUR")
#coupon settings
multiplying_coupons = models.BooleanField(default=True, help_text="Násobení kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón")
addition_of_coupons_amount = models.BooleanField(default=False, help_text="Sčítání slevových kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón")
class CURRENCY(models.TextChoices):
CZK = "CZK", "Czech Koruna"
EUR = "EUR", "Euro"
currency = models.CharField(max_length=10, default=CURRENCY.CZK, choices=CURRENCY.choices)
class Meta:
verbose_name = "Shop Configuration"
verbose_name_plural = "Shop Configuration"
def save(self, *args, **kwargs):
# zajištění singletonu
self.pk = 1
super().save(*args, **kwargs)
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
class VATRate(models.Model):
"""Business owner configurable VAT rates"""
name = models.CharField(
max_length=100,
help_text="E.g. 'German Standard', 'German Reduced', 'Czech Standard'"
)
description = models.TextField(
blank=True,
help_text="Optional description: 'Standard rate for most products', 'Books and food', etc."
)
rate = models.DecimalField(
max_digits=5,
decimal_places=4, # Allows rates like 19.5000%
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('100'))],
help_text="VAT rate as percentage (e.g. 19.00 for 19%)"
)
is_default = models.BooleanField(
default=False,
help_text="Default rate for new products"
)
is_active = models.BooleanField(
default=True,
help_text="Whether this VAT rate is active and available for use"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "VAT Rate"
verbose_name_plural = "VAT Rates"
ordering = ['-is_default', 'rate', 'name']
def __str__(self):
return f"{self.name} ({self.rate}%)"
def save(self, *args, **kwargs):
# Ensure only one default rate
if self.is_default:
VATRate.objects.filter(is_default=True).update(is_default=False)
super().save(*args, **kwargs)
# If no default exists, make first active one default
if not VATRate.objects.filter(is_default=True).exists():
first_active = VATRate.objects.filter(is_active=True).first()
if first_active:
first_active.is_default = True
first_active.save()
@property
def rate_decimal(self):
"""Returns rate as decimal for calculations (19.00% -> 0.19)"""
return self.rate / Decimal('100')
@classmethod
def get_default(cls):
"""Get the default VAT rate"""
return cls.objects.filter(is_default=True, is_active=True).first()

View 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

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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

View 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
})

View File

@@ -0,0 +1 @@
filler

View File

@@ -45,6 +45,12 @@ daphne
gunicorn
# -- THIRD PARTY APIS --
# Deutsche Post International Shipping API client (local package)
httpx>=0.23.0,<0.29.0 # Required by Deutsche Post client
attrs>=22.2.0 # Required by Deutsche Post client
python-dateutil>=2.8.0 # Required by Deutsche Post client
# -- REST API --
djangorestframework #REST Framework
@@ -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.)
django-celery-beat #slouží k plánování úkolů pro Celery
django-silk
django-silk[formatting]
# -- EDITING photos, gifs, videos --
@@ -78,7 +86,7 @@ django-celery-beat #slouží k plánování úkolů pro Celery
#opencv-python #moviepy use this better instead of pillow
#moviepy
#yt-dlp
yt-dlp
weasyprint #tvoření PDFek z html dokumentu + css styly
@@ -86,6 +94,14 @@ weasyprint #tvoření PDFek z html dokumentu + css styly
faker #generates fake data for testing purposes
## -- api --
zeep #SOAP tool
## -- API --
#generates api (if schema exists (ONLY USE OPENAPI NO SWAGGER))
openapi-python-client
stripe
gopay

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'social.chat'
label = "chat"

View File

@@ -0,0 +1,151 @@
# chat/consumers.py
import json
from account.models import UserProfile
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import sync_to_async, async_to_sync
class ChatConsumer(AsyncWebsocketConsumer):
# -- CONNECT --
async def connect(self):
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
self.chat_name = f"chat_{self.chat_id}"
user = self.scope["user"]
if not user.is_authenticated:
await self.close(code=4401) # unauthorized
return
#join chat group
async_to_sync(self.channel_layer.group_add)(
self.chat_name,
)
await self.accept()
# -- DISCONNECT --
async def disconnect(self, close_code):
async_to_sync(self.channel_layer.group_discard)(
self.chat_name
)
self.disconnect()
pass
# -- RECIVE --
async def receive(self, data):
if data["type"] == "new_chat_message":
message = data["message"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "chat.message", "message": message}
)
elif data["type"] == "new_reply_chat_message":
message = data["message"]
reply_to_id = data["reply_to_id"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "reply.chat.message", "message": message, "reply_to_id": reply_to_id}
)
elif data["type"] == "edit_chat_message":
message = data["message"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "edit.message", "message": message}
)
elif data["type"] == "delete_chat_message":
message_id = data["message_id"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "delete.message", "message_id": message_id}
)
elif data["type"] == "typing":
is_typing = data["is_typing"]
# Send typing status to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "typing.status", "user": self.scope["user"].username, "is_typing": is_typing}
)
elif data["type"] == "stop_typing":
# Send stop typing status to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "stop.typing", "user": self.scope["user"].username}
)
elif data["type"] == "reaction":
message_id = data["message_id"]
emoji = data["emoji"]
# Send reaction to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "message.reaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
)
elif data["type"] == "unreaction":
message_id = data["message_id"]
emoji = data["emoji"]
# Send unreaction to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "message.unreaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
)
else:
self.close(reason="Unsupported message type")
# -- CUSTOM METHODS --
def send_message_to_chat_group(self, event):
message = event["message"]
create_new_message()
self.send(text_data=json.dumps({"message": message}))
def edit_message_in_chat_group(self, event):
message = event["message"]
self.send(text_data=json.dumps({"message": message}))
# -- MESSAGES --
@database_sync_to_async
def create_new_message():
return None
@database_sync_to_async
def create_new_reply_message():
return None
@database_sync_to_async
def edit_message():
return None
@database_sync_to_async
def delete_message():
return None
# -- REACTIONS --
@database_sync_to_async
def react_to_message():
return None
@database_sync_to_async
def unreact_to_message():
return None

View File

@@ -0,0 +1,77 @@
# Generated by Django 5.2.7 on 2026-01-17 01:37
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Chat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('members', models.ManyToManyField(blank=True, related_name='chats', to=settings.AUTH_USER_MODEL)),
('moderators', models.ManyToManyField(blank=True, related_name='moderated_chats', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_chats', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True)),
('is_edited', models.BooleanField(default=False)),
('edited_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat')),
('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replies', to='chat.message')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='MessageFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='chat_uploads/%Y/%m/%d/')),
('media_type', models.CharField(choices=[('IMAGE', 'Image'), ('VIDEO', 'Video'), ('FILE', 'File')], default='FILE', max_length=20)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='chat.message')),
],
),
migrations.CreateModel(
name='MessageHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('old_content', models.TextField()),
('archived_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edit_history', to='chat.message')),
],
options={
'ordering': ['-archived_at'],
},
),
migrations.CreateModel(
name='MessageReaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('emoji', models.CharField(max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='chat.message')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('message', 'user')},
},
),
]

View File

@@ -0,0 +1,187 @@
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils import timezone
class Chat(models.Model):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='owned_chats'
)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='chats',
blank=True
)
moderators = models.ManyToManyField(
settings.AUTH_USER_MODEL,
related_name='moderated_chats',
blank=True
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
is_new = self._state.adding
super().save(*args, **kwargs)
# LOGIC: Ensure owner is always a member and moderator
if is_new and self.owner:
self.members.add(self.owner)
self.moderators.add(self.owner)
def __str__(self):
return f"Chat {self.id}"
class Message(models.Model):
chat = models.ForeignKey(Chat, related_name='messages', on_delete=models.CASCADE)
sender = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='sent_messages'
)
#odpověď na jinou zprávu
reply_to = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='replies'
)
content = models.TextField(blank=True)
# --- TRACKING EDIT STATUS ---
# We add these so the frontend doesn't need to check MessageHistory table
is_edited = models.BooleanField(default=False)
edited_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def clean(self):
# VALIDATION: Ensure sender is actually in the chat
# Note: We check self.id to avoid running this on creation if logic depends on M2M
# But generally, a sender must be a member.
if self.chat and self.sender:
if not self.chat.members.filter(id=self.sender.id).exists():
raise ValidationError("Sender is not a member of this chat.")
def save(self, *args, **kwargs):
# Optional: Run validation before saving
# self.full_clean()
super().save(*args, **kwargs)
# --- HELPER METHODS FOR WEBSOCKETS / VIEWS ---
def edit_content(self, new_text):
"""
Handles the complex logic of editing:
1. Checks if text actually changed.
2. Saves old text to History.
3. Updates current text and timestamps.
"""
if self.content == new_text:
return False # No change happened
# 1. Save History
MessageHistory.objects.create(
message=self,
old_content=self.content
)
# 2. Update Self
self.content = new_text
self.is_edited = True
self.edited_at = timezone.now()
self.save()
return True
def toggle_reaction(self, user, emoji):
"""
Handles Add/Remove/Switch logic.
Returns a tuple: (action, reaction_object)
action can be: 'added', 'removed', 'switched'
"""
try:
reaction = MessageReaction.objects.get(message=self, user=user)
if reaction.emoji == emoji:
# Same emoji -> Remove it (Toggle)
reaction.delete()
return 'removed', None
else:
# Different emoji -> Switch it
reaction.emoji = emoji
reaction.save()
return 'switched', reaction
except MessageReaction.DoesNotExist:
# New reaction -> Create it
reaction = MessageReaction.objects.create(
message=self,
user=user,
emoji=emoji
)
return 'added', reaction
def __str__(self):
return f"Message {self.id} from {self.sender}"
class MessageHistory(models.Model):
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name='edit_history'
)
old_content = models.TextField()
archived_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-archived_at']
class MessageReaction(models.Model):
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name='reactions'
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
emoji = models.CharField(max_length=10)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('message', 'user')
def __str__(self):
return f"{self.user} reacted {self.emoji}"
class MessageFile(models.Model):
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name='media_files'
)
file = models.FileField(upload_to='chat_uploads/%Y/%m/%d/')
media_type = models.CharField(max_length=20, choices=[
('IMAGE', 'Image'),
('VIDEO', 'Video'),
('FILE', 'File')
], default='FILE')
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Media {self.id} for Message {self.message.id}"

View File

@@ -0,0 +1,8 @@
# chat/routing.py
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"ws/chat/(?P<chat_id>\w+)/$", consumers.ChatConsumer.as_asgi()),
]

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,25 @@
from django.shortcuts import render
# Create your views here.
def get_users_chats(request):
return None
def create_chat(request):
return None
def invite_user_to_chat(request, chat_id: int, user_ids: list):
return None
def delete_chat(request, chat_id: int):
return None
def leave_chat(request, chat_id: int):
return None
def edit_chat(request, chat_object):
return None
def get_chat_messages(request, chat_id: int, limit: int = 50, offset: int = 0):
return None

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PagesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pages'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class PostsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'posts'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

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

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

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

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

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

View File

183
backend/thirdparty/deutschepost/admin.py vendored Normal file
View 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'

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DeutschepostConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'thirdparty.deutschepost'

View File

@@ -0,0 +1,23 @@
__pycache__/
build/
dist/
*.egg-info/
.pytest_cache/
# pyenv
.python-version
# Environments
.env
.venv
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# JetBrains
.idea/
/coverage.xml
/.coverage

View File

@@ -0,0 +1,124 @@
# deutsche-post-international-shipping-api-client
A client library for accessing Deutsche Post International Shipping API
## Usage
First, create a client:
```python
from deutsche_post_international_shipping_api_client import Client
client = Client(base_url="https://api.example.com")
```
If the endpoints you're going to hit require authentication, use `AuthenticatedClient` instead:
```python
from deutsche_post_international_shipping_api_client import AuthenticatedClient
client = AuthenticatedClient(base_url="https://api.example.com", token="SuperSecretToken")
```
Now call your endpoint and use your models:
```python
from deutsche_post_international_shipping_api_client.models import MyDataModel
from deutsche_post_international_shipping_api_client.api.my_tag import get_my_data_model
from deutsche_post_international_shipping_api_client.types import Response
with client as client:
my_data: MyDataModel = get_my_data_model.sync(client=client)
# or if you need more info (e.g. status_code)
response: Response[MyDataModel] = get_my_data_model.sync_detailed(client=client)
```
Or do the same thing with an async version:
```python
from deutsche_post_international_shipping_api_client.models import MyDataModel
from deutsche_post_international_shipping_api_client.api.my_tag import get_my_data_model
from deutsche_post_international_shipping_api_client.types import Response
async with client as client:
my_data: MyDataModel = await get_my_data_model.asyncio(client=client)
response: Response[MyDataModel] = await get_my_data_model.asyncio_detailed(client=client)
```
By default, when you're calling an HTTPS API it will attempt to verify that SSL is working correctly. Using certificate verification is highly recommended most of the time, but sometimes you may need to authenticate to a server (especially an internal server) using a custom certificate bundle.
```python
client = AuthenticatedClient(
base_url="https://internal_api.example.com",
token="SuperSecretToken",
verify_ssl="/path/to/certificate_bundle.pem",
)
```
You can also disable certificate validation altogether, but beware that **this is a security risk**.
```python
client = AuthenticatedClient(
base_url="https://internal_api.example.com",
token="SuperSecretToken",
verify_ssl=False
)
```
Things to know:
1. Every path/method combo becomes a Python module with four functions:
1. `sync`: Blocking request that returns parsed data (if successful) or `None`
1. `sync_detailed`: Blocking request that always returns a `Request`, optionally with `parsed` set if the request was successful.
1. `asyncio`: Like `sync` but async instead of blocking
1. `asyncio_detailed`: Like `sync_detailed` but async instead of blocking
1. All path/query params, and bodies become method arguments.
1. If your endpoint had any tags on it, the first tag will be used as a module name for the function (my_tag above)
1. Any endpoint which did not have a tag will be in `deutsche_post_international_shipping_api_client.api.default`
## Advanced customizations
There are more settings on the generated `Client` class which let you control more runtime behavior, check out the docstring on that class for more info. You can also customize the underlying `httpx.Client` or `httpx.AsyncClient` (depending on your use-case):
```python
from deutsche_post_international_shipping_api_client import Client
def log_request(request):
print(f"Request event hook: {request.method} {request.url} - Waiting for response")
def log_response(response):
request = response.request
print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}")
client = Client(
base_url="https://api.example.com",
httpx_args={"event_hooks": {"request": [log_request], "response": [log_response]}},
)
# Or get the underlying httpx client to modify directly with client.get_httpx_client() or client.get_async_httpx_client()
```
You can even set the httpx client directly, but beware that this will override any existing settings (e.g., base_url):
```python
import httpx
from deutsche_post_international_shipping_api_client import Client
client = Client(
base_url="https://api.example.com",
)
# Note that base_url needs to be re-set, as would any shared cookies, headers, etc.
client.set_httpx_client(httpx.Client(base_url="https://api.example.com", proxies="http://localhost:8030"))
```
## Building / publishing this package
This project uses [Poetry](https://python-poetry.org/) to manage dependencies and packaging. Here are the basics:
1. Update the metadata in pyproject.toml (e.g. authors, version)
1. If you're using a private repository, configure it with Poetry
1. `poetry config repositories.<your-repository-name> <url-to-your-repository>`
1. `poetry config http-basic.<your-repository-name> <username> <password>`
1. Publish the client with `poetry publish --build -r <your-repository-name>` or, if for public PyPI, just `poetry publish --build`
If you want to install this client into another project without publishing it (e.g. for development) then:
1. If that project **is using Poetry**, you can simply do `poetry add <path-to-this-client>` from that project
1. If that project is not using Poetry:
1. Build a wheel with `poetry build -f wheel`
1. Install that wheel from the other project `pip install <path-to-wheel>`

View File

@@ -0,0 +1,8 @@
"""A client library for accessing Deutsche Post International Shipping API"""
from .client import AuthenticatedClient, Client
__all__ = (
"AuthenticatedClient",
"Client",
)

Some files were not shown because too many files have changed in this diff Show More