Compare commits

...

58 Commits

Author SHA1 Message Date
f9636d1464 Add custom exception handler and improve order logic
Introduced a custom DRF exception handler to convert Django ValidationError to DRF ValidationError and registered it in settings. Improved Order model's calculate_total_price method to avoid accessing related fields before the object is saved. Updated OrderCreateSerializer to save the order after adding discounts and payment, ensuring total price is recalculated. Added utility functions and a rounded DateTime field in a new utils.py.
2026-02-01 03:17:49 +01:00
3e4d58f80d Update URLs, Docker config, and Tailwind setup
Added static and media file serving and Silk profiling in Django URLs. Updated docker-compose to mount backend/frontend code, adjust commands, and switch to 'trznice' app references. Enhanced Tailwind config to extend default colors and import defaultTheme.
2026-01-29 01:31:33 +01:00
David Bruno Vontor
304194d7ec Update generate-choice-labels.cjs 2026-01-26 09:54:56 +01:00
7c768c9be3 Update CSP, dependencies, and add choice label generator
Replaces nginx.conf CSP map with inline policy and updates the policy for development. Adds new dependencies including Mantine, Radix, Tabler, FontAwesome, and others. Removes the fetch-openapi.js script and introduces generate-choice-labels.cjs to auto-generate TypeScript choice label constants from Orval enums, updating the api:gen script to run this generator. Also updates orval and other dev dependencies, and makes minor formatting changes in orval.config.ts.
2026-01-26 00:10:47 +01:00
ed1b7de7a7 Update views.py 2026-01-25 23:19:56 +01:00
ca62e8895a Add order status email notifications and templates
Introduces email notifications for order status changes (created, cancelled, completed, paid, missing payment, refund events) and adds corresponding HTML email templates. Refactors notification tasks to use only the order object and updates model logic to trigger notifications on relevant status changes.
2026-01-25 22:21:00 +01:00
679cff2366 Consolidate and update initial migrations and models
Regenerated initial migrations for account, advertisement, commerce, configuration, and social apps to include recent schema changes and remove obsolete migration files. Added new migrations for Deutsche Post and Zasilkovna integrations, including new fields and choices. Updated commerce models to improve currency handling and discount code logic. This unifies the database schema and prepares for new features and integrations.
2026-01-25 00:40:52 +01:00
775709bd08 Migrate to global currency system in commerce app
Removed per-product currency in favor of a global site currency managed via SiteConfiguration. Updated models, views, templates, and Stripe integration to use the global currency. Added migration, management command for migration, and API endpoint for currency info. Improved permissions and filtering for orders, reviews, and carts. Expanded supported currencies in configuration.
2026-01-24 21:51:56 +01:00
8f6d864b4b Merge branch 'bruno' of https://git.vontor.cz/Brunobrno/vontor-cz into bruno 2026-01-23 00:47:22 +01:00
3a7044d551 Enhance Deutsche Post integration with API and label support
Expanded DeutschePostOrder and DeutschePostBulkOrder models to support full Deutsche Post API integration, including authentication, order creation, finalization, tracking, cancellation, and label/document generation. Added new fields for label PDFs and bulk paperwork, improved country mapping, and implemented comprehensive validation and utility methods. Updated serializers to expose new fields and computed properties. Added HTML templates for individual and bulk shipping labels.
2026-01-23 00:47:19 +01:00
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
535 changed files with 47352 additions and 2604 deletions

7
.dockerignore Normal file
View File

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

View File

@@ -33,6 +33,10 @@ This monorepo contains a Django backend and a Vite/React frontend, orchestrated
- Static/media files: S3 in production, local in dev (see `settings.py`).
- API versioning and docs: DRF Spectacular config in `settings.py`.
- Custom permissions, filters, and serializers in each app.
- **Serializer Best Practices**:
- **Prevent Duplicate Schemas**: When the same `ChoiceField` or complex field appears in multiple serializers, define it once as a reusable field class and use it everywhere instead of repeated definitions.
- Example: Create `OrderStatusField(serializers.ChoiceField)` with `choices=Order.OrderStatus.choices` and reuse it in all serializers that need order status.
- This ensures consistent OpenAPI schema generation and reduces maintenance overhead.
- **Frontend**
- Use React Router layouts for shared UI (see `src/layouts/`, `LAYOUTS.md`).
- API calls and JWT handling in `src/api/`.
@@ -107,6 +111,109 @@ Notes
- **Task queue**: Celery + Redis for async/background jobs.
- **API**: REST endpoints, JWT auth, API key support.
### OpenAPI Client Generation (Orval)
This project uses **Orval** to auto-generate TypeScript API clients from the Django OpenAPI schema.
#### Configuration
- **Orval config**: `frontend/src/orval.config.ts`
- **Schema URL**: `/api/schema/` (DRF Spectacular endpoint)
- **Fetch script**: `frontend/scripts/fetch-openapi.js`
- **Commands**:
- `npm run api:update` — fetches schema + generates client
- Runs: `node scripts/fetch-openapi.js && npx orval`
#### Generated Output
- **Location**: `frontend/src/api/generated/`
- **Files**: TypeScript interfaces, Axios-based API hooks
- Uses custom mutators: `publicMutator` and `privateMutator`
#### Custom Mutators
Two Axios clients handle public/private API requests:
**Public Client** (`frontend/src/api/publicClient.ts`):
```ts
import axios, { type AxiosRequestConfig } from "axios";
const backendUrl = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000";
export const publicApi = axios.create({
baseURL: backendUrl + "/api/",
withCredentials: false, // no cookies for public endpoints
});
export const publicMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
const response = await publicApi.request<T>(config);
return response.data;
};
```
**Private Client** (`frontend/src/api/privateClient.ts`):
```ts
import axios, { type AxiosRequestConfig } from "axios";
const backendUrl = import.meta.env.VITE_BACKEND_URL || "http://localhost:8000";
export const privateApi = axios.create({
baseURL: backendUrl + "/api/",
withCredentials: true, // sends HttpOnly cookies (access/refresh tokens)
});
// Auto-refresh on 401
privateApi.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
try {
await privateApi.post("/auth/refresh/");
return privateApi(original);
} catch {
// optional: logout
}
}
return Promise.reject(error);
}
);
export const privateMutator = async <T>(config: AxiosRequestConfig): Promise<T> => {
const response = await privateApi.request<T>(config);
return response.data;
};
```
#### Environment Variables (Vite)
- **IMPORTANT**: Use `import.meta.env.VITE_*` instead of `process.env` in browser code
- **NEVER** import `dotenv/config` in frontend files (causes "process is not defined" error)
- **Available vars**:
- `VITE_BACKEND_URL` (default: `http://localhost:8000`)
- `VITE_API_BASE_URL` (if using Client.ts wrapper)
- `VITE_API_REFRESH_URL` (default: `/api/token/refresh/`)
- `VITE_LOGIN_PATH` (default: `/login`)
#### Usage Example
```ts
import { useGetOrders } from "@/api/generated/orders";
function OrdersList() {
const { data, isLoading, error } = useGetOrders();
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{data?.map(order => <li key={order.id}>{order.status}</li>)}
</ul>
);
}
```
#### Helpers
- **Choices helper**: `frontend/src/api/get_choices.ts`
- Function: `getChoices(requests, lang)`
- Returns: `{ "Model.field": [{ value, label }] }`
## References
- [frontend/REACT.md](../frontend/REACT.md): Frontend structure, workflows, and conventions.
- [frontend/src/layouts/LAYOUTS.md](../frontend/src/layouts/LAYOUTS.md): Layout/component patterns.

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.13 (vontor-cz)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (vontor-cz)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/vontor-cz.iml" filepath="$PROJECT_DIR$/.idea/vontor-cz.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

35
.idea/vontor-cz.iml generated Normal file
View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$/backend" />
<option name="settingsModule" value="vontor_cz/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/backend/manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (vontor-cz)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="GOOGLE" />
<option name="myDocStringFormat" value="Google" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Jinja2" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/backend/account/templates" />
</list>
</option>
</component>
</module>

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

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

View File

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

View File

@@ -1,4 +1,5 @@
from django.apps import AppConfig
from django.contrib.auth import get_user_model
class AccountConfig(AppConfig):

View File

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

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-31 07:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='email_verification_sent_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='customuser',
name='email_verification_token',
field=models.CharField(blank=True, db_index=True, max_length=128, null=True),
),
]

View File

@@ -28,32 +28,29 @@ class ActiveUserManager(CustomUserManager):
class CustomUser(SoftDeleteModel, AbstractUser):
groups = models.ManyToManyField(
Group,
related_name="customuser_set", # <- přidáš related_name
related_name="customuser_set",
blank=True,
help_text="The groups this user belongs to.",
related_query_name="customuser",
)
user_permissions = models.ManyToManyField(
Permission,
related_name="customuser_set", # <- přidáš related_name
related_name="customuser_set",
blank=True,
help_text="Specific permissions for this user.",
related_query_name="customuser",
)
class Role(models.TextChoices):
ADMIN = "admin", "Admin"
MANAGER = "mod", "Moderator"
CUSTOMER = "regular", "Regular"
ADMIN = "admin", "cz#Administrátor"
MANAGER = "mod", "cz#Moderator"
CUSTOMER = "regular", "cz#Regular"
role = models.CharField(max_length=20, choices=Role.choices, default=Role.CUSTOMER)
phone_number = models.CharField(
null=True,
blank=True,
unique=True,
max_length=16,
validators=[RegexValidator(r'^\+?\d{9,15}$', message="Zadejte platné telefonní číslo.")]
@@ -66,14 +63,25 @@ class CustomUser(SoftDeleteModel, AbstractUser):
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
newsletter = models.BooleanField(default=True)
#misc
gdpr = models.BooleanField(default=False)
is_active = models.BooleanField(default=False)
create_time = models.DateTimeField(auto_now_add=True)
#adresa
postal_code = models.CharField(max_length=20, blank=True)
city = models.CharField(null=True, blank=True, max_length=100)
street = models.CharField(null=True, blank=True, max_length=200)
street_number = models.PositiveIntegerField(null=True, blank=True)
country = models.CharField(null=True, blank=True, max_length=100)
# firemní fakturační údaje
company_name = models.CharField(max_length=255, blank=True)
ico = models.CharField(max_length=20, blank=True)
dic = models.CharField(max_length=20, blank=True)
postal_code = models.CharField(
blank=True,
@@ -94,6 +102,7 @@ class CustomUser(SoftDeleteModel, AbstractUser):
"email"
]
# Ensure default manager has get_by_natural_key
objects = CustomUserManager()
# Optional convenience manager for active users only
@@ -158,3 +167,9 @@ class CustomUser(SoftDeleteModel, AbstractUser):
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

@@ -55,3 +55,23 @@ class AdminOnly(BasePermission):
def has_permission(self, request, view):
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
# Commerce-specific permissions
class AdminWriteOnlyOrReadOnly(BasePermission):
"""Allow read for anyone, write only for admins"""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
class AdminOnlyForPatchOtherwisePublic(BasePermission):
"""Allow GET/POST for anyone, PATCH/PUT/DELETE only for admins"""
def has_permission(self, request, view):
if request.method in SAFE_METHODS or request.method == "POST":
return True
if request.method in ["PATCH", "PUT", "DELETE"]:
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'
# Default to admin for other unsafe methods
return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'

View File

@@ -10,33 +10,25 @@ from .models import CustomUser
logger = get_task_logger(__name__)
def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
def send_email_with_context(recipients, subject, template_path=None, context=None, message: str | None = None):
"""
General function to send emails with a specific context.
Supports rendering plain text and HTML templates.
Converts `user` in context to a plain dict to avoid template access to the model.
Send emails rendering a single HTML template.
- `template_name` is a simple base name without extension, e.g. "email/test".
- Renders only HTML (".html"), no ".txt" support.
- Converts `user` in context to a plain dict to avoid passing models to templates.
"""
if isinstance(recipients, str):
recipients = [recipients]
html_message = None
if template_name or html_template_name:
# Best effort to resolve both templates if only one provided
if not template_name and html_template_name:
template_name = html_template_name.replace(".html", ".txt")
if not html_template_name and template_name:
html_template_name = template_name.replace(".txt", ".html")
if template_path:
ctx = dict(context or {})
# Sanitize user if someone passes the model by mistake
if "user" in ctx and not isinstance(ctx["user"], dict):
try:
ctx["user"] = _build_user_template_ctx(ctx["user"])
except Exception:
ctx["user"] = {}
message = render_to_string(template_name, ctx)
html_message = render_to_string(html_template_name, ctx)
# Render base layout and include the provided template as the main content.
# The included template receives the same context as the base.
html_message = render_to_string(
"email/components/base.html",
{"content_template": template_path, **ctx},
)
try:
send_mail(
@@ -47,7 +39,7 @@ def send_email_with_context(recipients, subject, message=None, template_name=Non
fail_silently=False,
html_message=html_message,
)
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend' and message:
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
return True
except Exception as e:
@@ -55,26 +47,6 @@ def send_email_with_context(recipients, subject, message=None, template_name=Non
return False
def _build_user_template_ctx(user: CustomUser) -> dict:
"""
Return a plain dict for templates instead of passing the DB model.
Provides aliases to avoid template errors (firstname vs first_name).
Adds a backward-compatible key 'get_full_name' for templates using `user.get_full_name`.
"""
first_name = getattr(user, "first_name", "") or ""
last_name = getattr(user, "last_name", "") or ""
full_name = f"{first_name} {last_name}".strip()
return {
"id": user.pk,
"email": getattr(user, "email", "") or "",
"first_name": first_name,
"firstname": first_name, # alias for templates using `firstname`
"last_name": last_name,
"lastname": last_name, # alias for templates using `lastname`
"full_name": full_name,
"get_full_name": full_name, # compatibility for templates using method-style access
}
#----------------------------------------------------------------------------------------------------
# This function sends an email to the user for email verification after registration.
@@ -92,7 +64,7 @@ def send_email_verification_task(user_id):
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
context = {
"user": _build_user_template_ctx(user),
"user": user,
"action_url": verify_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Ověřit email",
@@ -101,8 +73,7 @@ def send_email_verification_task(user_id):
send_email_with_context(
recipients=user.email,
subject="Ověření emailu",
template_name="email/email_verification.txt",
html_template_name="email/email_verification.html",
template_path="email/email_verification.html",
context=context,
)
@@ -118,8 +89,7 @@ def send_email_test_task(email):
send_email_with_context(
recipients=email,
subject="Testovací email",
template_name="email/test.txt",
html_template_name="email/test.html",
template_path="email/test.html",
context=context,
)
@@ -137,7 +107,7 @@ def send_password_reset_email_task(user_id):
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
context = {
"user": _build_user_template_ctx(user),
"user": user,
"action_url": reset_url,
"frontend_url": settings.FRONTEND_URL,
"cta_label": "Obnovit heslo",
@@ -146,7 +116,6 @@ def send_password_reset_email_task(user_id):
send_email_with_context(
recipients=user.email,
subject="Obnova hesla",
template_name="email/password_reset.txt",
html_template_name="email/password_reset.html",
template_path="email/password_reset.html",
context=context,
)

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,46 +0,0 @@
<!doctype html>
<html lang="cs">
<body style="margin:0; padding:0; background-color:#f5f7fb;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<tr>
<td align="center" style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<tr>
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
Ověření emailu
</td>
</tr>
<tr>
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
{% endwith %}
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou 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 %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,7 +0,0 @@
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
Děkujeme za registraci. Prosíme, ověřte svou emailovou adresu kliknutím na následující odkaz:
{{ action_url }}
Pokud jste účet nevytvořili vy, tento email ignorujte.

View File

@@ -1,46 +0,0 @@
<!doctype html>
<html lang="cs">
<body style="margin:0; padding:0; background-color:#f5f7fb;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<tr>
<td align="center" style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<tr>
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
Obnova hesla
</td>
</tr>
<tr>
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
{% endwith %}
<p style="margin:0 0 12px 0;">Obdrželi jste tento 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 %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,7 +0,0 @@
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
Obdrželi jste tento email, protože byla požádána obnova hesla k vašemu účtu.
Pokud jste o změnu nepožádali, tento email ignorujte.
Pro nastavení nového hesla použijte tento odkaz:
{{ action_url }}

View File

@@ -1,44 +0,0 @@
<!doctype html>
<html lang="cs">
<body style="margin:0; padding:0; background-color:#f5f7fb;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
<tr>
<td align="center" style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
<tr>
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
Testovací email
</td>
</tr>
<tr>
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
<p style="margin:0 0 12px 0;">Dobrý den,</p>
<p style="margin:0 0 16px 0;">Toto je testovací 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 %}
</td>
</tr>
</table>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
<tr>
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
Tento email byl odeslán z aplikace etržnice.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -1,6 +0,0 @@
Dobrý den,
Toto je testovací email z aplikace etržnice.
Odkaz na aplikaci:
{{ action_url }}

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
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={
@@ -163,7 +163,7 @@ class CookieTokenRefreshView(APIView):
#---------------------------------------------LOGOUT------------------------------------------------
@extend_schema(
tags=["Authentication"],
tags=["account", "public"],
summary="Logout user (delete access and refresh token cookies)",
description="Logs out the user by deleting access and refresh token cookies.",
responses={
@@ -185,7 +185,7 @@ class LogoutView(APIView):
#--------------------------------------------------------------------------------------------------------------
@extend_schema(
tags=["User"],
tags=["account"],
summary="List, retrieve, update, and delete users.",
description="Displays all users with filtering and ordering options. Requires authentication and appropriate role.",
responses={
@@ -222,6 +222,15 @@ class UserView(viewsets.ModelViewSet):
"is_active": {"help_text": "Stav aktivace uživatele."},
}
@extend_schema(
tags=["account"],
summary="Get permissions based on user role and action.",
description="Determines permissions for various actions based on user role and ownership.",
responses={
200: OpenApiResponse(description="Permissions determined successfully."),
403: OpenApiResponse(description="Permission denied."),
},
)
def get_permissions(self):
# Only admin can list or create users
if self.action in ['list', 'create']:
@@ -241,17 +250,27 @@ class UserView(viewsets.ModelViewSet):
# Fallback - deny access (prevents AttributeError for AnonymousUser)
return [OnlyRolesAllowed("admin")()]
# Any authenticated user can retrieve (view) any user's profile
# Users can only view their own profile, admins can view any profile
elif self.action == 'retrieve':
user = getattr(self, 'request', None) and getattr(self.request, 'user', None)
# Admins can view any user profile
if user and getattr(user, 'is_authenticated', False) and getattr(user, 'role', None) == 'admin':
return [IsAuthenticated()]
# Users can view their own profile
if user and getattr(user, 'is_authenticated', False) and self.kwargs.get('pk') and str(getattr(user, 'id', '')) == self.kwargs['pk']:
return [IsAuthenticated()]
# Deny access to other users' profiles
return [OnlyRolesAllowed("admin")()]
return super().get_permissions()
# Get current user data
@extend_schema(
tags=["User"],
tags=["account"],
summary="Get current authenticated user",
description="Returns details of the currently authenticated user based on JWT token or session.",
responses={
@@ -271,7 +290,7 @@ class CurrentUserView(APIView):
#1. registration API
@extend_schema(
tags=["User Registration"],
tags=["account", "public"],
summary="Register a new user (company or individual)",
description="Register a new user (company or individual). The user will receive an email with a verification link.",
request=UserRegistrationSerializer,
@@ -303,7 +322,7 @@ class UserRegistrationViewSet(ModelViewSet):
#2. confirming email
@extend_schema(
tags=["User Registration"],
tags=["account", "public"],
summary="Verify user email via link",
description="Verify user email using the link with uid and token.",
parameters=[
@@ -325,45 +344,18 @@ class EmailVerificationView(APIView):
if account_activation_token.check_token(user, token):
user.email_verified = True
user.is_active = True # Aktivace uživatele po ověření e-mailu
user.save()
return Response({"detail": "E-mail byl úspěšně ověřen. Účet čeká na schválení."})
return Response({"detail": "E-mail byl úspěšně ověřen. Účet je aktivován."})
else:
return Response({"error": "Token je neplatný nebo expirovaný."}, status=400)
#3. seller activation API (var_symbol)
@extend_schema(
tags=["User Registration"],
summary="Activate user and set variable symbol (admin/cityClerk only)",
description="Activate user and set variable symbol. Only accessible by admin or cityClerk.",
request=UserActivationSerializer,
responses={
200: OpenApiResponse(response=UserActivationSerializer, description="User activated successfully."),
400: OpenApiResponse(description="Invalid activation data."),
404: OpenApiResponse(description="User not found."),
},
)
class UserActivationViewSet(APIView):
permission_classes = [OnlyRolesAllowed('cityClerk', 'admin')]
def patch(self, request, *args, **kwargs):
serializer = UserActivationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
try:
send_email_clerk_accepted_task.delay(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol - CELERY TASK
except Exception as e:
logger.error(f"Celery not available, using fallback. Error: {e}")
send_email_clerk_accepted_task(user.id) # posílaní emailu pro informování uživatele o dokončení registrace, uředník doplnil variabilní symbol
return Response(serializer.to_representation(user), status=status.HTTP_200_OK)
#-------------------------------------------------END REGISTRACE-------------------------------------------------------------
#1. PasswordReset + send Email
@extend_schema(
tags=["User password reset"],
tags=["account", "public"],
summary="Request password reset (send email)",
description="Request password reset by providing registered email. An email with instructions will be sent.",
request=PasswordResetRequestSerializer,
@@ -393,7 +385,7 @@ class PasswordResetRequestView(APIView):
#2. Confirming reset
@extend_schema(
tags=["User password reset"],
tags=["account", "public"],
summary="Confirm password reset via token",
description="Confirm password reset using token from email.",
request=PasswordResetConfirmSerializer,
@@ -423,3 +415,6 @@ class PasswordResetConfirmView(APIView):
user.save()
return Response({"detail": "Heslo bylo úspěšně změněno."})
return Response(serializer.errors, status=400)

View File

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

View File

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

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

@@ -1,2 +1,52 @@
#udělat zasílaní reklamních emailů uživatelům.
#newletter --> když se vytvoří nový record s reklamou email se uloží pomocí zaškrtnutí tlačítka v záznamu
from venv import create
from account.tasks import send_email_with_context
from configuration.models import SiteConfiguration
from celery import shared_task
from celery.schedules import crontab
from commerce.models import Product
import datetime
@shared_task
def send_contact_me_email_task(client_email, message_content):
context = {
"client_email": client_email,
"message_content": message_content
}
send_email_with_context(
recipients=SiteConfiguration.get_solo().contact_email,
subject="Poptávka z kontaktního formuláře!!!",
template_path="email/contact_me.html",
context=context,
)
@shared_task
def send_newly_added_items_to_store_email_task_last_week():
last_week_date = datetime.datetime.now() - datetime.timedelta(days=7)
"""
__lte -> Less than or equal
__gte -> Greater than or equal
__lt -> Less than
__gt -> Greater than
"""
products_of_week = Product.objects.filter(
include_in_week_summary_email=True,
created_at__gte=last_week_date
)
config = SiteConfiguration.get_solo()
send_email_with_context(
recipients=config.contact_email,
subject="Nový produkt přidán do obchodu",
template_path="email/advertisement/commerce/new_items_added_this_week.html",
context={
"products_of_week": products_of_week,
"site_currency": config.currency,
}
)

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 }} {{ site_currency|default:"€" }}
</div>
{% endif %}
{% if product.short_description %}
<div class="product-description">
{{ product.short_description|truncatewords:20 }}
</div>
{% endif %}
<div class="product-date">
Přidáno: {{ product.created_at|date:"d.m.Y H:i" }}
</div>
</div>
{% endfor %}
{% else %}
<div class="no-products">
<h3 style="margin: 0 0 15px 0;">🤷‍♂️ Žádné nové produkty</h3>
<p style="margin: 0;">Za posledních 7 dní nebyly přidány žádné nové produkty, které by měly být zahrnuty do týdenního přehledu.</p>
</div>
{% endif %}

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

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

View File

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

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

View File

@@ -0,0 +1,45 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from drf_spectacular.utils import extend_schema
from configuration.models import SiteConfiguration
class CurrencyInfoView(APIView):
"""
Get current site currency and display information.
"""
@extend_schema(
summary="Get site currency information",
description="Returns the current site currency and available options",
tags=["configuration"]
)
def get(self, request):
config = SiteConfiguration.get_solo()
currency_symbols = {
'EUR': '',
'CZK': '',
'USD': '$',
'GBP': '£',
'PLN': '',
'HUF': 'Ft',
'SEK': 'kr',
'DKK': 'kr',
'NOK': 'kr',
'CHF': 'Fr'
}
return Response({
'current_currency': config.currency,
'currency_symbol': currency_symbols.get(config.currency, config.currency),
'currency_name': dict(SiteConfiguration.CURRENCY.choices)[config.currency],
'available_currencies': [
{
'code': choice[0],
'name': choice[1],
'symbol': currency_symbols.get(choice[0], choice[0])
}
for choice in SiteConfiguration.CURRENCY.choices
]
})

View File

@@ -0,0 +1 @@
# Management commands module

View File

@@ -0,0 +1 @@
# Commerce management commands

View File

@@ -0,0 +1,74 @@
"""
Management command to migrate from per-product currency to global currency system.
Usage: python manage.py migrate_to_global_currency
"""
from django.core.management.base import BaseCommand
from commerce.models import Product, Order
from configuration.models import SiteConfiguration
class Command(BaseCommand):
help = 'Migrate from per-product currency to global currency system'
def add_arguments(self, parser):
parser.add_argument(
'--target-currency',
type=str,
default='EUR',
help='Target currency to migrate to (default: EUR)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be changed without making changes'
)
def handle(self, *args, **options):
target_currency = options['target_currency']
dry_run = options['dry_run']
self.stdout.write(
self.style.SUCCESS(f"Migrating to global currency: {target_currency}")
)
# Check current state
config = SiteConfiguration.get_solo()
self.stdout.write(f"Current site currency: {config.currency}")
if hasattr(Product.objects.first(), 'currency'):
# Products still have currency field
product_currencies = Product.objects.values_list('currency', flat=True).distinct()
self.stdout.write(f"Product currencies found: {list(product_currencies)}")
if len(product_currencies) > 1:
self.stdout.write(
self.style.WARNING(
"Multiple currencies detected in products. "
"Consider currency conversion before migration."
)
)
order_currencies = Order.objects.values_list('currency', flat=True).distinct()
order_currencies = [c for c in order_currencies if c] # Remove empty strings
self.stdout.write(f"Order currencies found: {list(order_currencies)}")
if not dry_run:
# Update site configuration
config.currency = target_currency
config.save()
self.stdout.write(
self.style.SUCCESS(f"Updated site currency to {target_currency}")
)
# Update orders with empty currency
orders_updated = Order.objects.filter(currency='').update(currency=target_currency)
self.stdout.write(
self.style.SUCCESS(f"Updated {orders_updated} orders to use {target_currency}")
)
else:
self.stdout.write(self.style.WARNING("DRY RUN - No changes made"))
self.stdout.write(
self.style.SUCCESS("Migration completed successfully!")
)

View File

@@ -1,6 +1,10 @@
# Generated by Django 5.2.7 on 2025-10-28 22:28
# Generated by Django 5.2.7 on 2026-01-24 22:44
import django.core.validators
import django.db.models.deletion
import django.utils.timezone
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
@@ -9,19 +13,115 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('configuration', '0001_initial'),
('deutschepost', '0002_deutschepostbulkorder_bulk_label_pdf_and_more'),
('stripe', '0001_initial'),
('zasilkovna', '0002_alter_zasilkovnapacket_state'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invoice_number', models.CharField(max_length=50, unique=True)),
('issued_at', models.DateTimeField(auto_now_add=True)),
('due_date', models.DateTimeField()),
('pdf_file', models.FileField(upload_to='invoices/')),
],
),
migrations.CreateModel(
name='Carrier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('shipping_method', models.CharField(choices=[('packeta', 'Zásilkovna'), ('deutschepost', 'Deutsche Post'), ('store', 'Osobní odběr')], default='store', max_length=20)),
('state', models.CharField(choices=[('ordered', 'Objednávka se připravuje'), ('shipped', 'Odesláno'), ('delivered', 'Doručeno'), ('ready_to_pickup', 'Připraveno k vyzvednutí')], default='ordered', max_length=20)),
('weight', models.DecimalField(blank=True, decimal_places=2, help_text='Hmotnost zásilky v kg', max_digits=10, null=True)),
('returning', models.BooleanField(default=False, help_text='Zda je tato zásilka na vrácení')),
('shipping_price', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('deutschepost', models.ManyToManyField(blank=True, related_name='carriers', to='deutschepost.deutschepostorder')),
('zasilkovna', models.ManyToManyField(blank=True, related_name='carriers', to='zasilkovna.zasilkovnapacket')),
],
),
migrations.CreateModel(
name='Cart',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(blank=True, help_text='Session key for anonymous users', max_length=40, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Cart',
'verbose_name_plural': 'Carts',
},
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('base_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('delivery_time', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('logo', models.ImageField(blank=True, null=True, upload_to='carriers/')),
('external_id', models.CharField(blank=True, max_length=50, null=True)),
('url', models.SlugField(unique=True)),
('description', models.TextField(blank=True)),
('image', models.ImageField(blank=True, upload_to='categories/')),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='commerce.category')),
],
options={
'verbose_name_plural': 'Categories',
},
),
migrations.CreateModel(
name='DiscountCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, unique=True)),
('description', models.CharField(blank=True, max_length=255)),
('percent', models.PositiveIntegerField(blank=True, help_text='Procento sleva 0-100', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)])),
('amount', models.DecimalField(blank=True, decimal_places=2, help_text='Fixed discount amount in site currency', max_digits=10, null=True)),
('valid_from', models.DateTimeField(default=django.utils.timezone.now)),
('valid_to', models.DateTimeField(blank=True, null=True)),
('active', models.BooleanField(default=True)),
('usage_limit', models.PositiveIntegerField(blank=True, null=True)),
('used_count', models.PositiveIntegerField(default=0)),
('specific_categories', models.ManyToManyField(blank=True, related_name='discount_codes', to='commerce.category')),
],
),
migrations.CreateModel(
name='Payment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('payment_method', models.CharField(choices=[('shop', 'Platba v obchodě'), ('stripe', 'Platební Brána'), ('cash_on_delivery', 'Dobírka')], default='shop', max_length=30)),
('payed_at_shop', models.BooleanField(blank=True, default=False, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('stripe', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payment', to='stripe.stripemodel')),
],
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(blank=True, choices=[('created', 'Vytvořeno'), ('cancelled', 'Zrušeno'), ('completed', 'Dokončeno'), ('refunding', 'Vrácení v procesu'), ('refunded', 'Vráceno')], default='created', max_length=20, null=True)),
('total_price', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)),
('currency', models.CharField(default='', help_text='Order currency - captured from site configuration at order creation and never changes', max_length=10)),
('first_name', models.CharField(max_length=100)),
('last_name', models.CharField(max_length=100)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('address', models.CharField(max_length=255)),
('city', models.CharField(max_length=100)),
('postal_code', models.CharField(max_length=20)),
('country', models.CharField(default='Czech Republic', max_length=100)),
('note', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('carrier', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='commerce.carrier')),
('discount', models.ManyToManyField(blank=True, related_name='orders', to='commerce.discountcode')),
('invoice', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='commerce.invoice')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='orders', to=settings.AUTH_USER_MODEL)),
('payment', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order', to='commerce.payment')),
],
),
migrations.CreateModel(
@@ -30,12 +130,103 @@ class Migration(migrations.Migration):
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='czk', max_length=10)),
('code', models.CharField(blank=True, max_length=100, null=True, unique=True)),
('price', models.DecimalField(decimal_places=2, help_text='Net price (without VAT)', max_digits=10)),
('url', models.SlugField(unique=True)),
('stock', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('limited_to', models.DateTimeField(blank=True, null=True)),
('include_in_week_summary_email', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='products', to='commerce.category')),
('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')),
('variants', models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', to='commerce.product')),
('vat_rate', models.ForeignKey(blank=True, help_text='VAT rate for this product. Leave empty to use default rate.', null=True, on_delete=django.db.models.deletion.PROTECT, to='configuration.vatrate')),
],
),
migrations.CreateModel(
name='OrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.order')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='commerce.product')),
],
),
migrations.AddField(
model_name='discountcode',
name='specific_products',
field=models.ManyToManyField(blank=True, related_name='discount_codes', to='commerce.product'),
),
migrations.CreateModel(
name='ProductImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='products/')),
('alt_text', models.CharField(blank=True, max_length=150)),
('is_main', models.BooleanField(default=False)),
('order', models.PositiveIntegerField(default=0, help_text='Display order (lower numbers first)')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='commerce.product')),
],
options={
'ordering': ['order', '-is_main', 'id'],
},
),
migrations.CreateModel(
name='Refund',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason_choice', models.CharField(choices=[('retuning_before_fourteen_day_period', 'Vrácení před uplynutím 14-ti denní lhůty'), ('damaged_product', 'Poškozený produkt'), ('wrong_item', 'Špatná položka'), ('other', 'Jiný důvod')], max_length=40)),
('reason_text', models.TextField(blank=True)),
('verified', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refunds', to='commerce.order')),
],
),
migrations.CreateModel(
name='Wishlist',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('products', models.ManyToManyField(blank=True, help_text='Products saved by the user', related_name='wishlisted_by', to='commerce.product')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='wishlist', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Wishlist',
'verbose_name_plural': 'Wishlists',
},
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('added_at', models.DateTimeField(auto_now_add=True)),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.cart')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='commerce.product')),
],
options={
'verbose_name': 'Cart Item',
'verbose_name_plural': 'Cart Items',
'unique_together': {('cart', 'product')},
},
),
migrations.CreateModel(
name='Review',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rating', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])),
('comment', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='commerce.product')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
],
options={
'indexes': [models.Index(fields=['product', 'rating'], name='commerce_re_product_9cd1a8_idx'), models.Index(fields=['created_at'], name='commerce_re_created_fe14ef_idx')],
'unique_together': {('product', 'user')},
},
),
]

View File

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

View File

@@ -1,26 +1,564 @@
from rest_framework import serializers
from .models import Carrier
class CarrierSerializer(serializers.ModelSerializer):
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", "name", "base_price", "delivery_time",
"is_active", "logo", "external_id"
"id",
"payment_method",
"stripe",
"stripe_session_id",
"stripe_payment_intent",
"stripe_session_url",
]
read_only_fields = [
"id",
"stripe",
"stripe_session_id",
"stripe_payment_intent",
"stripe_session_url",
]
def create(self, validated_data):
order = self.context.get("order") # musíš ho předat při inicializaci serializeru
carrier = self.context.get("carrier")
with transaction.atomic():
payment = Payment.objects.create(
order=order,
carrier=carrier,
**validated_data
)
# pokud je Stripe, vytvoříme checkout session
if payment.payment_method == Payment.PAYMENT.SHOP and carrier.shipping_method != Carrier.SHIPPING.STORE:
raise serializers.ValidationError("Platba v obchodě je možná pouze pro osobní odběr.")
elif payment.payment_method == Payment.PAYMENT.CASH_ON_DELIVERY and carrier.shipping_method == Carrier.SHIPPING.STORE:
raise ValidationError("Dobírka není možná pro osobní odběr.")
if payment.payment_method == Payment.PAYMENT.STRIPE:
session = StripeClient.create_checkout_session(order)
stripe_instance = StripeModel.objects.create(
stripe_session_id=session.id,
stripe_payment_intent=session.payment_intent,
stripe_session_url=session.url,
status=StripeModel.STATUS_CHOICES.PENDING
)
payment.stripe = stripe_instance
payment.save(update_fields=["stripe"])
return payment
# -- ORDER CREATE SERIALIZER --
class OrderCreateSerializer(serializers.Serializer):
# Customer/billing data (optional when authenticated)
first_name = serializers.CharField(required=False)
last_name = serializers.CharField(required=False)
email = serializers.EmailField(required=False)
phone = serializers.CharField(required=False, allow_blank=True)
address = serializers.CharField(required=False)
city = serializers.CharField(required=False)
postal_code = serializers.CharField(required=False)
country = serializers.CharField(required=False, default="Czech Republic")
note = serializers.CharField(required=False, allow_blank=True)
# Nested structures
#produkty
items = OrderItemCreateSerializer(many=True)
#doprava/vyzvednutí + zasilkovna input (serializer)
carrier = OrderCarrierSerializer()
payment = PaymentSerializer()
#slevové kódy
discount_codes = serializers.ListField(
child=serializers.CharField(), required=False, allow_empty=True
)
def validate(self, attrs):
request = self.context.get("request")
#kontrola jestli je uzivatel valid/prihlasen
is_auth = bool(getattr(getattr(request, "user", None), "is_authenticated", False))
# pokud není, tak se musí vyplnit povinné údaje
required_fields = [
"first_name",
"last_name",
"email",
"address",
"city",
"postal_code",
]
if not is_auth:
missing_fields = []
# přidame fieldy, které nejsou vyplněné
for field in required_fields:
if not attrs.get(field):
missing_fields.append(field)
if missing_fields:
raise serializers.ValidationError({"billing": f"Missing fields: {', '.join(missing_fields)}"})
# pokud chybí itemy:
if not attrs.get("items"):
raise serializers.ValidationError({"items": "At least one item is required."})
return attrs
def create(self, validated_data):
items_data = validated_data.pop("items", [])
carrier_data = validated_data.pop("carrier")
payment_data = validated_data.pop("payment")
codes = validated_data.pop("discount_codes", [])
request = self.context.get("request")
user = getattr(request, "user", None)
is_auth = bool(getattr(user, "is_authenticated", False))
with transaction.atomic():
# Create Order (user data imported on save if user is set)
order = Order(
user=user if is_auth else None,
first_name=validated_data.get("first_name", ""),
last_name=validated_data.get("last_name", ""),
email=validated_data.get("email", ""),
phone=validated_data.get("phone", ""),
address=validated_data.get("address", ""),
city=validated_data.get("city", ""),
postal_code=validated_data.get("postal_code", ""),
country=validated_data.get("country", "Czech Republic"),
note=validated_data.get("note", ""),
)
# Order.save se postara o to jestli má doplnit data z usera
order.save()
# Vytvoření Carrier skrz serializer
carrier = OrderCarrierSerializer(data=carrier_data)
carrier.is_valid(raise_exception=True)
carrier = carrier.save()
order.carrier = carrier
order.save(update_fields=["carrier", "updated_at"]) # will recalc total later
# Vytvořit Order Items individualně, aby se spustila kontrola položek na skladu
for item in items_data:
product = item["product"] # OrderItemCreateSerializer.validate
quantity = int(item.get("quantity", 1))
OrderItem.objects.create(order=order, product=product, quantity=quantity)
# -- Slevové kódy --
# Discount codes need to be added before payment/final save because calculate_total_price uses them
if codes:
discounts = list(DiscountCode.objects.filter(code__in=codes))
if discounts:
order.discount.add(*discounts)
# Save to recalculate total with discounts
order.save(update_fields=["total_price", "updated_at"])
# -- Payment --
payment_serializer = PaymentSerializer(
data=payment_data,
context={"order": order, "carrier": carrier}
)
payment_serializer.is_valid(raise_exception=True)
payment = payment_serializer.save()
# přiřadíme k orderu
order.payment = payment
order.save(update_fields=["payment", "updated_at"])
return order
# ----------------- ADMIN/READ MODELS -----------------
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = [
"id",
"name",
"url",
"parent",
"description",
"image",
]
from rest_framework import serializers
from .models import Product, Carrier, Order
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 CarrierSerializer(serializers.ModelSerializer):
class DiscountCodeSerializer(serializers.ModelSerializer):
class Meta:
model = DiscountCode
fields = "__all__"
read_only_fields = ["used_count"]
class RefundSerializer(serializers.ModelSerializer):
class Meta:
model = Refund
fields = "__all__"
read_only_fields = ["id", "verified", "created_at"]
# ----------------- READ SERIALIZERS USED BY VIEWS -----------------
class ZasilkovnaPacketReadSerializer(ZasilkovnaPacketSerializer):
class Meta(ZasilkovnaPacketSerializer.Meta):
fields = getattr(ZasilkovnaPacketSerializer.Meta, "fields", None)
class CarrierReadSerializer(serializers.ModelSerializer):
zasilkovna = ZasilkovnaPacketReadSerializer(many=True, read_only=True)
class Meta:
model = Carrier
fields = ["shipping_method", "state", "zasilkovna", "shipping_price"]
read_only_fields = fields
class ProductMiniSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ["id", "name", "price"]
class OrderItemReadSerializer(serializers.ModelSerializer):
product = ProductMiniSerializer(read_only=True)
class Meta:
model = OrderItem
fields = ["id", "product", "quantity"]
read_only_fields = fields
class PaymentReadSerializer(serializers.ModelSerializer):
class Meta:
model = Payment
fields = ["payment_method"]
read_only_fields = fields
class OrderMiniSerializer(serializers.ModelSerializer):
status = serializers.ChoiceField(
choices=Order.OrderStatus.choices,
read_only=True
)
class Meta:
model = Order
fields = ["id", "status", "total_price", "created_at"]
read_only_fields = fields
class OrderReadSerializer(serializers.ModelSerializer):
status = serializers.ChoiceField(
choices=Order.OrderStatus.choices,
read_only=True
)
items = OrderItemReadSerializer(many=True, read_only=True)
carrier = CarrierReadSerializer(read_only=True)
payment = PaymentReadSerializer(read_only=True)
discount_codes = serializers.SerializerMethodField()
class Meta:
model = Order
fields = [
"id",
"status",
"total_price",
"currency",
"user",
"first_name",
"last_name",
"email",
"phone",
"address",
"city",
"postal_code",
"country",
"note",
"created_at",
"updated_at",
"items",
"carrier",
"payment",
"discount_codes",
]
read_only_fields = fields
def get_discount_codes(self, obj: Order):
return list(obj.discount.values_list("code", flat=True))
class ReviewSerializerPublic(serializers.ModelSerializer):
class Meta:
model = Review
fields = "__all__"
read_only_fields = ['user', 'created_at', 'updated_at']
# ----------------- CART SERIALIZERS -----------------
class CartItemSerializer(serializers.ModelSerializer):
product_name = serializers.CharField(source='product.name', read_only=True)
product_price = serializers.DecimalField(source='product.price', max_digits=10, decimal_places=2, read_only=True)
subtotal = serializers.SerializerMethodField()
class Meta:
model = CartItem
fields = ['id', 'product', 'product_name', 'product_price', 'quantity', 'subtotal', 'added_at']
read_only_fields = ['id', 'added_at']
def get_subtotal(self, obj):
return obj.get_subtotal()
def validate_quantity(self, value):
if value < 1:
raise serializers.ValidationError("Quantity must be at least 1")
return value
class CartItemCreateSerializer(serializers.Serializer):
product_id = serializers.IntegerField()
quantity = serializers.IntegerField(min_value=1, default=1)
def validate_product_id(self, value):
try:
Product.objects.get(pk=value, is_active=True)
except Product.DoesNotExist:
raise serializers.ValidationError("Product not found or inactive.")
return value
class CartSerializer(serializers.ModelSerializer):
items = CartItemSerializer(many=True, read_only=True)
total = serializers.SerializerMethodField()
items_count = serializers.SerializerMethodField()
class Meta:
model = Cart
fields = ['id', 'user', 'items', 'total', 'items_count', 'created_at', 'updated_at']
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
def get_total(self, obj):
return obj.get_total()
def get_items_count(self, obj):
return obj.get_items_count()
# ----------------- WISHLIST SERIALIZERS -----------------
class ProductMiniForWishlistSerializer(serializers.ModelSerializer):
"""Minimal product info for wishlist display"""
class Meta:
model = Product
fields = ['id', 'name', 'price', 'is_active', 'stock']
class WishlistSerializer(serializers.ModelSerializer):
products = ProductMiniForWishlistSerializer(many=True, read_only=True)
products_count = serializers.SerializerMethodField()
class Meta:
model = Wishlist
fields = ['id', 'user', 'products', 'products_count', 'created_at', 'updated_at']
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
def get_products_count(self, obj):
return obj.get_products_count()
class WishlistProductActionSerializer(serializers.Serializer):
"""For adding/removing products from wishlist"""
product_id = serializers.IntegerField()
def validate_product_id(self, value):
try:
Product.objects.get(pk=value, is_active=True)
except Product.DoesNotExist:
raise serializers.ValidationError("Product not found or inactive.")
return value

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

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

View File

@@ -0,0 +1,50 @@
<h3 style="color:#d9534f; font-size:18px; margin-top:0;">Order Cancelled</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>Your order has been cancelled.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Order ID:</td>
<td style="padding:8px;">{{ order.id }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Total Amount:</td>
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Cancellation Date:</td>
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if order.payment.status == 'paid' %}
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refund Information</h4>
<p>Since your order was already paid, you will receive a refund of {{ order.total_price }} {{ order.get_currency }}. The refund will be processed within 3-5 business days.</p>
{% endif %}
<p style="margin-top:20px; color:#666;">
If you have any questions, please contact our support team.
</p>

View File

@@ -0,0 +1,49 @@
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Order Completed</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>Great news! Your order has been completed and delivered. Thank you for your purchase!</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Order ID:</td>
<td style="padding:8px;">{{ order.id }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Total Amount:</td>
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Completed:</td>
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
<strong>We hope you enjoyed your purchase!</strong> If you have any feedback or need to return an item, please let us know.
</p>
<p style="margin-top:20px; color:#666;">
Thank you for shopping with us!
</p>

View File

@@ -0,0 +1,50 @@
<h3 style="color:#333; font-size:18px; margin-top:0;">Order Confirmation</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>Thank you for your order! Your order has been successfully created and is being prepared for shipment.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Details</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Summary</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr>
<td style="text-align:right; padding:8px;">Subtotal:</td>
<td style="text-align:right; padding:8px; font-weight:bold;">{{ order.total_price }} {{ order.get_currency }}</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Shipping Address</h4>
<p style="margin:0;">
{{ order.first_name }} {{ order.last_name }}<br>
{{ order.address }}<br>
{{ order.postal_code }} {{ order.city }}<br>
{{ order.country }}
</p>
{% if order.note %}
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Special Instructions</h4>
<p style="margin:0;">{{ order.note }}</p>
{% endif %}
<p style="margin-top:20px; color:#666;">
We will notify you as soon as your order ships. If you have any questions, please contact us.
</p>

View File

@@ -0,0 +1,50 @@
<h3 style="color:#d9534f; font-size:18px; margin-top:0;">⚠ Payment Reminder</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>We haven't received payment for your order yet. Your order is being held and may be cancelled if payment is not completed soon.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Details</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Order ID:</td>
<td style="padding:8px;">{{ order.id }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Amount Due:</td>
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Created:</td>
<td style="padding:8px;">{{ order.created_at|date:"d.m.Y H:i" }}</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #d9534f;">
<strong>Please complete your payment as soon as possible to avoid order cancellation.</strong>
If you have questions or need assistance, contact us right away.
</p>
<p style="margin-top:20px; color:#666;">
Thank you for your business!
</p>

View File

@@ -0,0 +1,45 @@
<h3 style="color:#333; font-size:18px; margin-top:0;">✓ Payment Received</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>Thank you! Your payment has been successfully received and processed. Your order is now confirmed and will be prepared for shipment.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Order ID:</td>
<td style="padding:8px;">{{ order.id }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Amount Paid:</td>
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
</tr>
<tr>
<td style="padding:8px; font-weight:bold;">Payment Date:</td>
<td style="padding:8px;">{{ order.payment.created_at|date:"d.m.Y H:i" }}</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:20px; color:#666;">
Your order will be prepared and shipped as soon as possible. You will receive a shipping notification with tracking details.
</p>

View File

@@ -0,0 +1,53 @@
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Refund Processed</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>Excellent! Your refund has been approved and processed. The funds will appear in your account within 3-5 business days, depending on your financial institution.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refund Details</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Original Order ID:</td>
<td style="padding:8px;">{{ order.id }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Refund Amount:</td>
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Processing Date:</td>
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
</tr>
<tr>
<td style="padding:8px; font-weight:bold;">Status:</td>
<td style="padding:8px; color:#5cb85c; font-weight:bold;">✓ Completed</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Refunded Items</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Refund</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
<strong>Timeline:</strong> Your refund should appear in your account within 3-5 business days. Some banks may take longer during weekends or holidays.
</p>
<p style="margin-top:20px; color:#666;">
Thank you for giving us the opportunity to serve you. If you need anything else, please don't hesitate to contact us.
</p>

View File

@@ -0,0 +1,49 @@
<h3 style="color:#333; font-size:18px; margin-top:0;">Return Items Received</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>Thank you! We have received your returned items from order #{{ order.id }}. Our team is now inspecting the items and processing your refund.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Information</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Order ID:</td>
<td style="padding:8px;">{{ order.id }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Total Refund Amount:</td>
<td style="padding:8px;">{{ order.total_price }} {{ order.get_currency }}</td>
</tr>
<tr>
<td style="padding:8px; font-weight:bold;">Received Date:</td>
<td style="padding:8px;">{{ order.updated_at|date:"d.m.Y H:i" }}</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Returned Items</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Refund</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #5bc0de;">
<strong>What's Next?</strong> We'll inspect the items and confirm the refund within 2-3 business days. You'll receive another confirmation email when your refund has been processed.
</p>
<p style="margin-top:20px; color:#666;">
If you have any questions about your return, please contact us.
</p>

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,49 @@
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">✓ Your Order is Ready for Pickup!</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>Excellent news! Your order is now ready for pickup. You can collect your package at your convenience during store hours.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Pickup Information</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Order ID:</td>
<td style="padding:8px;">{{ order.id }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Ready Since:</td>
<td style="padding:8px;">{{ order.carrier.updated_at|date:"d.m.Y H:i" }}</td>
</tr>
<tr>
<td style="padding:8px; font-weight:bold;">Pickup Location:</td>
<td style="padding:8px;">Our Store</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:20px; padding:15px; background-color:#f0f8f0; border-left:4px solid #5cb85c;">
<strong>What to Bring:</strong> Please bring a valid ID and your order confirmation (this email). Your package is being held for you and will be released upon presentation of these documents.
</p>
<p style="margin-top:20px; color:#666;">
Thank you for your business! If you have any questions, please don't hesitate to contact us.
</p>

View File

@@ -0,0 +1,55 @@
<h3 style="color:#5cb85c; font-size:18px; margin-top:0;">📦 Your Package is on its Way!</h3>
<p>Dear {{ order.first_name }} {{ order.last_name }},</p>
<p>Great news! Your order has been shipped via Zásilkovna and is on its way to you.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Shipping Information</h4>
<table width="100%" cellpadding="8" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Order ID:</td>
<td style="padding:8px;">{{ order.id }}</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Carrier:</td>
<td style="padding:8px;">Zásilkovna</td>
</tr>
<tr style="border-bottom:1px solid #ddd;">
<td style="padding:8px; font-weight:bold;">Shipped Date:</td>
<td style="padding:8px;">{{ order.carrier.updated_at|date:"d.m.Y H:i" }}</td>
</tr>
</table>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Delivery Instructions</h4>
<p>Your package will be delivered to your selected Zásilkovna pickup point. You will receive an SMS/email notification from Zásilkovna when the package arrives at the pickup point.</p>
<h4 style="color:#333; margin-top:20px; margin-bottom:10px;">Order Items</h4>
<table width="100%" cellpadding="10" cellspacing="0" border="1" style="border-collapse:collapse; border-color:#ddd;">
<thead>
<tr style="background-color:#f9f9f9;">
<th style="text-align:left; padding:10px; border:1px solid #ddd;">Product</th>
<th style="text-align:center; padding:10px; border:1px solid #ddd;">Qty</th>
<th style="text-align:right; padding:10px; border:1px solid #ddd;">Price</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:10px; border:1px solid #ddd;">{{ item.product.name }}</td>
<td style="text-align:center; padding:10px; border:1px solid #ddd;">{{ item.quantity }}</td>
<td style="text-align:right; padding:10px; border:1px solid #ddd;">{{ item.get_total_price }} {{ order.get_currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p style="margin-top:20px; padding:15px; background-color:#f9f9f9; border-left:4px solid #5cb85c;">
<strong>Delivery Address:</strong><br>
{{ order.first_name }} {{ order.last_name }}<br>
{{ order.address }}<br>
{{ order.postal_code }} {{ order.city }}
</p>
<p style="margin-top:20px; color:#666;">
You can track your package on the Zásilkovna website. If you have any questions, please contact us.
</p>

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>

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

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

File diff suppressed because it is too large Load Diff

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,67 @@
# Generated by Django 5.2.7 on 2026-01-24 22:44
import django.core.validators
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='SiteConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Shop name', max_length=100, unique=True)),
('logo', models.ImageField(blank=True, null=True, upload_to='shop_logos/')),
('favicon', models.ImageField(blank=True, null=True, upload_to='shop_favicons/')),
('contact_email', models.EmailField(blank=True, max_length=254, null=True)),
('contact_phone', models.CharField(blank=True, max_length=20, null=True)),
('contact_address', models.TextField(blank=True, null=True)),
('opening_hours', models.JSONField(blank=True, null=True)),
('facebook_url', models.URLField(blank=True, null=True)),
('instagram_url', models.URLField(blank=True, null=True)),
('youtube_url', models.URLField(blank=True, null=True)),
('tiktok_url', models.URLField(blank=True, null=True)),
('whatsapp_number', models.CharField(blank=True, max_length=20, null=True)),
('zasilkovna_shipping_price', models.DecimalField(decimal_places=2, default=Decimal('50.00'), max_digits=10)),
('zasilkovna_api_key', models.CharField(blank=True, help_text='API klíč pro přístup k Zásilkovna API (zatím není využito)', max_length=255, null=True)),
('zasilkovna_api_password', models.CharField(blank=True, help_text='API heslo pro přístup k Zásilkovna API (zatím není využito)', max_length=255, null=True)),
('free_shipping_over', models.DecimalField(decimal_places=2, default=Decimal('2000.00'), max_digits=10)),
('deutschepost_api_url', models.URLField(default='https://gw.sandbox.deutschepost.com', help_text='Deutsche Post API URL (sandbox/production)', max_length=255)),
('deutschepost_client_id', models.CharField(blank=True, help_text='Deutsche Post OAuth Client ID', max_length=255, null=True)),
('deutschepost_client_secret', models.CharField(blank=True, help_text='Deutsche Post OAuth Client Secret', max_length=255, null=True)),
('deutschepost_customer_ekp', models.CharField(blank=True, help_text='Deutsche Post Customer EKP number', max_length=20, null=True)),
('deutschepost_shipping_price', models.DecimalField(decimal_places=2, default=Decimal('6.00'), help_text='Default Deutsche Post shipping price in EUR', max_digits=10)),
('multiplying_coupons', models.BooleanField(default=True, help_text='Násobení kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón')),
('addition_of_coupons_amount', models.BooleanField(default=False, help_text='Sčítání slevových kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón')),
('currency', models.CharField(choices=[('EUR', 'Euro'), ('CZK', 'Czech Koruna'), ('USD', 'US Dollar'), ('GBP', 'British Pound'), ('PLN', 'Polish Zloty'), ('HUF', 'Hungarian Forint'), ('SEK', 'Swedish Krona'), ('DKK', 'Danish Krone'), ('NOK', 'Norwegian Krone'), ('CHF', 'Swiss Franc')], default='EUR', max_length=10)),
],
options={
'verbose_name': 'Shop Configuration',
'verbose_name_plural': 'Shop Configuration',
},
),
migrations.CreateModel(
name='VATRate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text="E.g. 'German Standard', 'German Reduced', 'Czech Standard'", max_length=100)),
('description', models.TextField(blank=True, help_text="Optional description: 'Standard rate for most products', 'Books and food', etc.")),
('rate', models.DecimalField(decimal_places=4, help_text='VAT rate as percentage (e.g. 19.00 for 19%)', max_digits=5, validators=[django.core.validators.MinValueValidator(Decimal('0')), django.core.validators.MaxValueValidator(Decimal('100'))])),
('is_default', models.BooleanField(default=False, help_text='Default rate for new products')),
('is_active', models.BooleanField(default=True, help_text='Whether this VAT rate is active and available for use')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'VAT Rate',
'verbose_name_plural': 'VAT Rates',
'ordering': ['-is_default', 'rate', 'name'],
},
),
]

View File

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

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

@@ -5,23 +5,147 @@ 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
async def receive(self, text_data):
text_data_json = json.loads(text_data)
message = text_data_json["message"]
# -- RECIVE --
async def receive(self, data):
if data["type"] == "new_chat_message":
await self.send(text_data=json.dumps({"message": message}))
message = data["message"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "chat.message", "message": message}
)
elif data["type"] == "new_reply_chat_message":
message = data["message"]
reply_to_id = data["reply_to_id"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "reply.chat.message", "message": message, "reply_to_id": reply_to_id}
)
elif data["type"] == "edit_chat_message":
message = data["message"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "edit.message", "message": message}
)
elif data["type"] == "delete_chat_message":
message_id = data["message_id"]
# Send message to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "delete.message", "message_id": message_id}
)
elif data["type"] == "typing":
is_typing = data["is_typing"]
# Send typing status to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "typing.status", "user": self.scope["user"].username, "is_typing": is_typing}
)
elif data["type"] == "stop_typing":
# Send stop typing status to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "stop.typing", "user": self.scope["user"].username}
)
elif data["type"] == "reaction":
message_id = data["message_id"]
emoji = data["emoji"]
# Send reaction to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "message.reaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
)
elif data["type"] == "unreaction":
message_id = data["message_id"]
emoji = data["emoji"]
# Send unreaction to room group
async_to_sync(self.channel_layer.group_send)(
self.chat_name, {"type": "message.unreaction", "message_id": message_id, "emoji": emoji, "user": self.scope["user"].username}
)
else:
self.close(reason="Unsupported message type")
# -- CUSTOM METHODS --
def send_message_to_chat_group(self, event):
message = event["message"]
create_new_message()
self.send(text_data=json.dumps({"message": message}))
def edit_message_in_chat_group(self, event):
message = event["message"]
self.send(text_data=json.dumps({"message": message}))
# -- MESSAGES --
@database_sync_to_async
def create_new_message():
return None
@database_sync_to_async
def get_user_profile(user_id):
return UserProfile.objects.get(pk=user_id)
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-24 22:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Chat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('members', models.ManyToManyField(blank=True, related_name='chats', to=settings.AUTH_USER_MODEL)),
('moderators', models.ManyToManyField(blank=True, related_name='moderated_chats', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_chats', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Message',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True)),
('is_edited', models.BooleanField(default=False)),
('edited_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat')),
('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replies', to='chat.message')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='MessageFile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='chat_uploads/%Y/%m/%d/')),
('media_type', models.CharField(choices=[('IMAGE', 'Image'), ('VIDEO', 'Video'), ('FILE', 'File')], default='FILE', max_length=20)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='chat.message')),
],
),
migrations.CreateModel(
name='MessageHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('old_content', models.TextField()),
('archived_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edit_history', to='chat.message')),
],
options={
'ordering': ['-archived_at'],
},
),
migrations.CreateModel(
name='MessageReaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('emoji', models.CharField(max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='chat.message')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('message', 'user')},
},
),
]

View File

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

View File

@@ -4,5 +4,5 @@ from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
re_path(r"ws/chat/(?P<chat_id>\w+)/$", consumers.ChatConsumer.as_asgi()),
]

View File

@@ -1,3 +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'

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