Add multi-image compositions, docker setup, tests, and docs
- Add CompositionImage model with multi-image upload and main image flag - Add composition endpoints: add-images, set-main-image, delete image, by-created-at filter - Make contact-us by_category public - Add 24 API tests covering all endpoints - Add Dockerfile, docker-compose, entrypoint and docker docs - Add Persian API docs and update README and Postman collection Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
74
.dockerignore
Normal file
74
.dockerignore
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Django
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
/media
|
||||||
|
/staticfiles
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.tox/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Postman
|
||||||
|
*.postman_collection.json
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
519
API_DOCS_FA.md
Normal file
519
API_DOCS_FA.md
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
<div dir="rtl" lang="fa">
|
||||||
|
|
||||||
|
# مستندات API (Zoneco ORG)
|
||||||
|
|
||||||
|
این سند نحوهٔ کارکرد APIهای بکاند پروژه Zoneco ORG را به زبان فارسی توضیح میدهد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## وضعیت تستها
|
||||||
|
|
||||||
|
```
|
||||||
|
Ran 24 tests — OK (0 failures)
|
||||||
|
```
|
||||||
|
|
||||||
|
| تعداد تست | گروه |
|
||||||
|
|-----------|------|
|
||||||
|
| ۶ تست | Contact Us |
|
||||||
|
| ۱۱ تست | Compositions |
|
||||||
|
| ۷ تست | Campaigns |
|
||||||
|
|
||||||
|
اجرای تستها:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python manage.py test api -v 2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اطلاعات پایه
|
||||||
|
|
||||||
|
| مقدار | مورد |
|
||||||
|
|--------|------|
|
||||||
|
| `{{base_url}}` | آدرس پایه |
|
||||||
|
| `/api/` | پیشوند API |
|
||||||
|
| JSON | فرمت پاسخ |
|
||||||
|
| ۲۰ آیتم در هر صفحه | صفحهبندی |
|
||||||
|
| `/admin/` | پنل ادمین |
|
||||||
|
|
||||||
|
### متغیر Postman
|
||||||
|
|
||||||
|
در فایل Postman از متغیر `{{base_url}}` استفاده شده است.
|
||||||
|
مقدار آن را مطابق محیط اجرا (لوکال، سرور تست یا پروداکشن) تنظیم کنید.
|
||||||
|
|
||||||
|
**نمونهٔ آدرس کامل یک endpoint:**
|
||||||
|
|
||||||
|
```
|
||||||
|
{{base_url}}/api/contact-us/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## سطح دسترسی
|
||||||
|
|
||||||
|
| دسترسی | عملیات |
|
||||||
|
|--------|--------|
|
||||||
|
| عمومی — بدون نیاز به ورود | لیست و جزئیات (متد `GET`) |
|
||||||
|
| عمومی — بدون نیاز به ورود | ایجاد (متد `POST`) |
|
||||||
|
| فقط ادمین — نیاز به احراز هویت | ویرایش و حذف (متدهای `PUT`، `PATCH`، `DELETE`) |
|
||||||
|
|
||||||
|
برای درخواستهای ادمین در Postman میتوانید از **Basic Auth** با نام کاربری و رمز ادمین استفاده کنید.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. تماس با ما (Contact Us)
|
||||||
|
|
||||||
|
**مسیر پایه:**
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/contact-us/
|
||||||
|
```
|
||||||
|
|
||||||
|
### فیلدها
|
||||||
|
|
||||||
|
| توضیح | الزامی | نوع | فیلد |
|
||||||
|
|-------|--------|-----|------|
|
||||||
|
| نام و نام خانوادگی | بله | string | `name` |
|
||||||
|
| ایمیل یا شماره تماس | بله | string | `email_or_phone` |
|
||||||
|
| متن پیام | بله | string | `description` |
|
||||||
|
| دستهبندی | بله | string | `category` |
|
||||||
|
|
||||||
|
**مقادیر مجاز برای `category`:**
|
||||||
|
|
||||||
|
- همکاری
|
||||||
|
- فروش
|
||||||
|
- پشتیبانی
|
||||||
|
- درخواست مشاور
|
||||||
|
- سایر
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### دریافت لیست تماسها
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/contact-us/
|
||||||
|
```
|
||||||
|
|
||||||
|
**پاسخ نمونه:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "احمد محمدی",
|
||||||
|
"email_or_phone": "ahmad@example.com",
|
||||||
|
"description": "سلام، سوالی دارم.",
|
||||||
|
"category": "پشتیبانی",
|
||||||
|
"created_at": "2025-12-17T12:00:00Z",
|
||||||
|
"updated_at": "2025-12-17T12:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### دریافت یک تماس
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/contact-us/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ثبت تماس جدید
|
||||||
|
|
||||||
|
```
|
||||||
|
POST {{base_url}}/api/contact-us/
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**بدنه درخواست:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "احمد محمدی",
|
||||||
|
"email_or_phone": "09121234567",
|
||||||
|
"description": "میخواهم در مورد محصولات اطلاعات بگیرم.",
|
||||||
|
"category": "فروش"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**کد پاسخ:** `201 Created`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فیلتر بر اساس دستهبندی
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/contact-us/by_category/?category=پشتیبانی
|
||||||
|
```
|
||||||
|
|
||||||
|
| توضیح | الزامی | پارامتر |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| یکی از دستههای مجاز | بله | `category` |
|
||||||
|
|
||||||
|
اگر پارامتر `category` ارسال نشود، خطای `400` برمیگردد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ویرایش و حذف (ادمین)
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT {{base_url}}/api/contact-us/{id}/
|
||||||
|
PATCH {{base_url}}/api/contact-us/{id}/
|
||||||
|
DELETE {{base_url}}/api/contact-us/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
نیاز به احراز هویت ادمین دارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. ترکیبات (Compositions)
|
||||||
|
|
||||||
|
**مسیر پایه:**
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/compositions/
|
||||||
|
```
|
||||||
|
|
||||||
|
هر ترکیب میتواند **چند تصویر** داشته باشد.
|
||||||
|
یکی از تصاویر با فیلد `is_main: true` بهعنوان **تصویر اصلی** مشخص میشود.
|
||||||
|
|
||||||
|
### فیلدها
|
||||||
|
|
||||||
|
| توضیح | الزامی | نوع | فیلد |
|
||||||
|
|-------|--------|-----|------|
|
||||||
|
| نام ترکیب | بله | string | `name` |
|
||||||
|
| توضیحات | بله | string | `description` |
|
||||||
|
| یک یا چند فایل تصویر (فقط در ایجاد/ویرایش) | خیر | file[] | `uploaded_images` |
|
||||||
|
| ایندکس تصویر اصلی (شمارش از ۰) | خیر | integer | `main_image_index` |
|
||||||
|
|
||||||
|
### محدودیت تصاویر
|
||||||
|
|
||||||
|
- فرمتهای مجاز: JPEG، PNG، WebP، GIF
|
||||||
|
- حداکثر حجم هر فایل: **۵ مگابایت**
|
||||||
|
- مسیر ذخیره: `media/compositions/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### دریافت لیست ترکیبات
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/compositions/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### دریافت یک ترکیب
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/compositions/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
**پاسخ نمونه:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "ترکیب A",
|
||||||
|
"description": "توضیحات ترکیب",
|
||||||
|
"image": null,
|
||||||
|
"image_url": null,
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"image": "/media/compositions/img2.jpg",
|
||||||
|
"image_url": "{{base_url}}/media/compositions/img2.jpg",
|
||||||
|
"is_main": true,
|
||||||
|
"created_at": "2025-12-17T12:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"image": "/media/compositions/img1.jpg",
|
||||||
|
"image_url": "{{base_url}}/media/compositions/img1.jpg",
|
||||||
|
"is_main": false,
|
||||||
|
"created_at": "2025-12-17T11:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"main_image": {
|
||||||
|
"id": 2,
|
||||||
|
"image_url": "{{base_url}}/media/compositions/img2.jpg",
|
||||||
|
"is_main": true,
|
||||||
|
"created_at": "2025-12-17T12:00:00Z"
|
||||||
|
},
|
||||||
|
"created_at": "2025-12-17T10:00:00Z",
|
||||||
|
"updated_at": "2025-12-17T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### فیلتر بر اساس تاریخ ایجاد
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/compositions/by-created-at/?from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z
|
||||||
|
```
|
||||||
|
|
||||||
|
| توضیح | الزامی | پارامتر |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| برگشت ترکیبهایی که `created_at >= from` | حداقل یکی | `from` |
|
||||||
|
| برگشت ترکیبهایی که `created_at <= to` | حداقل یکی | `to` |
|
||||||
|
|
||||||
|
فرمت تاریخ: ISO 8601 — مثال: `2026-06-01T00:00:00Z`
|
||||||
|
|
||||||
|
اگر هیچ پارامتری ارسال نشود، خطای `400` برمیگردد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ایجاد ترکیب (بدون تصویر)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST {{base_url}}/api/compositions/
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "ترکیب تست",
|
||||||
|
"description": "توضیحات ترکیب"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ایجاد ترکیب با چند تصویر
|
||||||
|
|
||||||
|
```
|
||||||
|
POST {{base_url}}/api/compositions/
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
| مقدار نمونه | نوع | فیلد |
|
||||||
|
|-------------|-----|------|
|
||||||
|
| ترکیب A | text | `name` |
|
||||||
|
| توضیحات | text | `description` |
|
||||||
|
| image1.jpg | file | `uploaded_images` |
|
||||||
|
| image2.jpg | file | `uploaded_images` |
|
||||||
|
| 1 | text | `main_image_index` |
|
||||||
|
|
||||||
|
**نکته:** فیلد `uploaded_images` را برای هر تصویر یکبار تکرار کنید.
|
||||||
|
|
||||||
|
**نکته:** اگر `main_image_index` ارسال نشود، **اولین تصویر** بهعنوان تصویر اصلی انتخاب میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### افزودن تصویر به ترکیب موجود (ادمین)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST {{base_url}}/api/compositions/{id}/add-images/
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
```
|
||||||
|
|
||||||
|
| نوع | فیلد |
|
||||||
|
|-----|------|
|
||||||
|
| file (یک یا چند فایل) | `uploaded_images` |
|
||||||
|
| text (اختیاری) | `main_image_index` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### تنظیم تصویر اصلی (ادمین)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST {{base_url}}/api/compositions/{id}/set-main-image/
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"image_id": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### حذف یک تصویر (ادمین)
|
||||||
|
|
||||||
|
```
|
||||||
|
DELETE {{base_url}}/api/compositions/{id}/images/{image_id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
**کد پاسخ:** `204 No Content`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ویرایش و حذف ترکیب (ادمین)
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT {{base_url}}/api/compositions/{id}/
|
||||||
|
PATCH {{base_url}}/api/compositions/{id}/
|
||||||
|
DELETE {{base_url}}/api/compositions/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. کمپینها (Campaigns)
|
||||||
|
|
||||||
|
**مسیر پایه:**
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/campaigns/
|
||||||
|
```
|
||||||
|
|
||||||
|
### فیلدها
|
||||||
|
|
||||||
|
| توضیح | الزامی | نوع | فیلد |
|
||||||
|
|-------|--------|-----|------|
|
||||||
|
| نام کمپین | بله | string | `name` |
|
||||||
|
| توضیحات | بله | string | `description` |
|
||||||
|
| زمان شروع (ISO 8601) | بله | datetime | `start_time` |
|
||||||
|
| زمان پایان (باید بعد از `start_time` باشد) | بله | datetime | `end_time` |
|
||||||
|
| تصویر کمپین | خیر | file | `image` |
|
||||||
|
|
||||||
|
### فیلدهای محاسباتی (فقط خواندنی)
|
||||||
|
|
||||||
|
| توضیح | فیلد |
|
||||||
|
|-------|------|
|
||||||
|
| آیا کمپین الان فعال است | `is_active` |
|
||||||
|
| آیا کمپین هنوز شروع نشده | `is_upcoming` |
|
||||||
|
| آیا کمپین تمام شده | `is_ended` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### دریافت لیست کمپینها
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/campaigns/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### دریافت یک کمپین
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/campaigns/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ایجاد کمپین
|
||||||
|
|
||||||
|
```
|
||||||
|
POST {{base_url}}/api/campaigns/
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "کمپین نوروز",
|
||||||
|
"description": "تخفیف ویژه نوروز",
|
||||||
|
"start_time": "2026-03-01T00:00:00Z",
|
||||||
|
"end_time": "2026-03-31T23:59:59Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### کمپینهای فعال
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/campaigns/active/
|
||||||
|
```
|
||||||
|
|
||||||
|
کمپینهایی که زمان فعلی بین `start_time` و `end_time` قرار دارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### کمپینهای آینده
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/campaigns/upcoming/
|
||||||
|
```
|
||||||
|
|
||||||
|
کمپینهایی که هنوز شروع نشدهاند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### کمپینهای پایانیافته
|
||||||
|
|
||||||
|
```
|
||||||
|
GET {{base_url}}/api/campaigns/ended/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ویرایش و حذف (ادمین)
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT {{base_url}}/api/campaigns/{id}/
|
||||||
|
PATCH {{base_url}}/api/campaigns/{id}/
|
||||||
|
DELETE {{base_url}}/api/campaigns/{id}/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## کدهای وضعیت HTTP
|
||||||
|
|
||||||
|
| معنی | کد |
|
||||||
|
|------|-----|
|
||||||
|
| موفق | `200` |
|
||||||
|
| ایجاد شد | `201` |
|
||||||
|
| حذف شد (بدون بدنه) | `204` |
|
||||||
|
| داده نامعتبر | `400` |
|
||||||
|
| دسترسی ندارید | `403` |
|
||||||
|
| یافت نشد | `404` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فایل Postman
|
||||||
|
|
||||||
|
مجموعه Postman در مسیر زیر قرار دارد:
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/Zoneco_ORG_API.postman_collection.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### نحوه import
|
||||||
|
|
||||||
|
1. Postman را باز کنید
|
||||||
|
2. گزینه **Import** را بزنید
|
||||||
|
3. فایل `Zoneco_ORG_API.postman_collection.json` را انتخاب کنید
|
||||||
|
4. متغیر `{{base_url}}` را مطابق محیط خود تنظیم کنید
|
||||||
|
5. برای درخواستهای ادمین، در تب **Authorization** گزینه **Basic Auth** را فعال کنید
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خلاصه endpointها
|
||||||
|
|
||||||
|
| دسترسی | Endpoint | Method | # |
|
||||||
|
|--------|----------|--------|---|
|
||||||
|
| عمومی | `/api/contact-us/` | GET | 1 |
|
||||||
|
| عمومی | `/api/contact-us/` | POST | 2 |
|
||||||
|
| عمومی | `/api/contact-us/{id}/` | GET | 3 |
|
||||||
|
| عمومی | `/api/contact-us/by_category/?category=...` | GET | 4 |
|
||||||
|
| ادمین | `/api/contact-us/{id}/` | PUT/PATCH/DELETE | 5 |
|
||||||
|
| عمومی | `/api/compositions/` | GET | 6 |
|
||||||
|
| عمومی | `/api/compositions/` | POST | 7 |
|
||||||
|
| عمومی | `/api/compositions/{id}/` | GET | 8 |
|
||||||
|
| عمومی | `/api/compositions/by-created-at/?from=...&to=...` | GET | 9 |
|
||||||
|
| ادمین | `/api/compositions/{id}/add-images/` | POST | 10 |
|
||||||
|
| ادمین | `/api/compositions/{id}/set-main-image/` | POST | 11 |
|
||||||
|
| ادمین | `/api/compositions/{id}/images/{image_id}/` | DELETE | 12 |
|
||||||
|
| ادمین | `/api/compositions/{id}/` | PUT/PATCH/DELETE | 13 |
|
||||||
|
| عمومی | `/api/campaigns/` | GET | 14 |
|
||||||
|
| عمومی | `/api/campaigns/` | POST | 15 |
|
||||||
|
| عمومی | `/api/campaigns/{id}/` | GET | 16 |
|
||||||
|
| عمومی | `/api/campaigns/active/` | GET | 17 |
|
||||||
|
| عمومی | `/api/campaigns/upcoming/` | GET | 18 |
|
||||||
|
| عمومی | `/api/campaigns/ended/` | GET | 19 |
|
||||||
|
| ادمین | `/api/campaigns/{id}/` | PUT/PATCH/DELETE | 20 |
|
||||||
|
|
||||||
|
> **توجه:** در Postman قبل از هر مسیر، مقدار `{{base_url}}` را قرار دهید.
|
||||||
|
> مثال: `{{base_url}}/api/campaigns/active/`
|
||||||
|
|
||||||
|
</div>
|
||||||
219
DOCKER_README.md
Normal file
219
DOCKER_README.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Docker Setup for Zoneco ORG Backend
|
||||||
|
|
||||||
|
This document provides instructions for running the Zoneco ORG backend using Docker and Docker Compose.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine 20.10 or higher
|
||||||
|
- Docker Compose 2.0 or higher
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. **Navigate to the backend directory:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create a `.env` file** (copy from the template below):
|
||||||
|
```bash
|
||||||
|
# Copy and modify as needed
|
||||||
|
SECRET_KEY=your-secret-key-here-change-in-production
|
||||||
|
DEBUG=False
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,185.208.172.158
|
||||||
|
POSTGRES_DB=Zoneco_ORG
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_HOST=db
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
DJANGO_PORT=8000
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000,http://185.208.172.158:9123,http://185.208.172.158
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Build and start the containers:**
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **The application will be available at:**
|
||||||
|
- API: `http://localhost:8000/api/`
|
||||||
|
- Admin: `http://localhost:8000/admin/`
|
||||||
|
|
||||||
|
## Docker Commands
|
||||||
|
|
||||||
|
### Start services
|
||||||
|
```bash
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start services in detached mode
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop services
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop services and remove volumes (⚠️ deletes database data)
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs for specific service
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f web
|
||||||
|
docker-compose logs -f db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild containers
|
||||||
|
```bash
|
||||||
|
docker-compose build --no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execute commands in running container
|
||||||
|
```bash
|
||||||
|
# Django shell
|
||||||
|
docker-compose exec web python manage.py shell
|
||||||
|
|
||||||
|
# Create superuser
|
||||||
|
docker-compose exec web python manage.py createsuperuser
|
||||||
|
|
||||||
|
# Run migrations manually
|
||||||
|
docker-compose exec web python manage.py migrate
|
||||||
|
|
||||||
|
# Collect static files
|
||||||
|
docker-compose exec web python manage.py collectstatic --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The Docker setup consists of:
|
||||||
|
|
||||||
|
1. **Web Service** (`web`):
|
||||||
|
- Django application running with Gunicorn
|
||||||
|
- Multi-stage Dockerfile for optimized image size
|
||||||
|
- Non-root user for security
|
||||||
|
- Automatic migrations and static file collection on startup
|
||||||
|
- Health checks enabled
|
||||||
|
|
||||||
|
2. **Database Service** (`db`):
|
||||||
|
- PostgreSQL 15 Alpine (lightweight)
|
||||||
|
- Persistent data volume
|
||||||
|
- Health checks to ensure readiness
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
- ✅ Multi-stage build for smaller image size
|
||||||
|
- ✅ Non-root user execution
|
||||||
|
- ✅ Environment variable configuration
|
||||||
|
- ✅ No hardcoded secrets
|
||||||
|
- ✅ Health checks for both services
|
||||||
|
|
||||||
|
### Production Ready
|
||||||
|
- ✅ Gunicorn WSGI server
|
||||||
|
- ✅ Automatic database migrations
|
||||||
|
- ✅ Static file collection
|
||||||
|
- ✅ Database connection retry logic
|
||||||
|
- ✅ Proper logging
|
||||||
|
|
||||||
|
### Development Friendly
|
||||||
|
- ✅ Volume mounts for media and static files
|
||||||
|
- ✅ Hot-reload capability (with proper setup)
|
||||||
|
- ✅ Easy access to Django management commands
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `SECRET_KEY` | Django secret key | (required in production) |
|
||||||
|
| `DEBUG` | Enable debug mode | `False` |
|
||||||
|
| `ALLOWED_HOSTS` | Comma-separated allowed hosts | `localhost,127.0.0.1` |
|
||||||
|
| `POSTGRES_DB` | Database name | `Zoneco_ORG` |
|
||||||
|
| `POSTGRES_USER` | Database user | `postgres` |
|
||||||
|
| `POSTGRES_PASSWORD` | Database password | `postgres` |
|
||||||
|
| `POSTGRES_HOST` | Database host | `db` |
|
||||||
|
| `POSTGRES_PORT` | Database port | `5432` |
|
||||||
|
| `DJANGO_PORT` | Django application port | `8000` |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | Comma-separated CORS origins | (see .env.example) |
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
- `postgres_data`: Persistent PostgreSQL data
|
||||||
|
- `./media`: Media files (images, uploads)
|
||||||
|
- `./staticfiles`: Collected static files
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database connection errors
|
||||||
|
```bash
|
||||||
|
# Check if database is healthy
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Check database logs
|
||||||
|
docker-compose logs db
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission issues
|
||||||
|
```bash
|
||||||
|
# Fix media/staticfiles permissions
|
||||||
|
sudo chown -R $USER:$USER media staticfiles
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port already in use
|
||||||
|
```bash
|
||||||
|
# Change port in .env file
|
||||||
|
DJANGO_PORT=8001
|
||||||
|
# Then update docker-compose.yml or restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear everything and start fresh
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose build --no-cache
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
For production deployment:
|
||||||
|
|
||||||
|
1. **Set strong SECRET_KEY:**
|
||||||
|
```bash
|
||||||
|
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set DEBUG=False** in `.env`
|
||||||
|
|
||||||
|
3. **Configure proper ALLOWED_HOSTS**
|
||||||
|
|
||||||
|
4. **Use strong database passwords**
|
||||||
|
|
||||||
|
5. **Consider using:**
|
||||||
|
- Reverse proxy (nginx)
|
||||||
|
- SSL/TLS certificates
|
||||||
|
- Separate database server
|
||||||
|
- Backup strategy
|
||||||
|
- Monitoring and logging
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── Dockerfile # Multi-stage production Dockerfile
|
||||||
|
├── docker-compose.yml # Service orchestration
|
||||||
|
├── .dockerignore # Files to exclude from build
|
||||||
|
├── entrypoint.sh # Startup script
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
└── DOCKER_README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
78
Dockerfile
Normal file
78
Dockerfile
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Multi-stage Dockerfile for Django application
|
||||||
|
# Stage 1: Builder stage - Install dependencies
|
||||||
|
FROM python:3.11-slim as builder
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
# Install system dependencies required for building Python packages
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
postgresql-client \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create and set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements file
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip install --user -r requirements.txt
|
||||||
|
|
||||||
|
# Stage 2: Production stage - Minimal runtime image
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PATH="/home/django/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# Install runtime dependencies only
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
postgresql-client \
|
||||||
|
libpq5 \
|
||||||
|
bash \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd -r django && useradd -r -g django django
|
||||||
|
|
||||||
|
# Create necessary directories
|
||||||
|
RUN mkdir -p /app/staticfiles /app/media && \
|
||||||
|
chown -R django:django /app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy Python dependencies from builder stage
|
||||||
|
COPY --from=builder /root/.local /home/django/.local
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY --chown=django:django . .
|
||||||
|
|
||||||
|
# Copy and set permissions for entrypoint script
|
||||||
|
COPY --chown=django:django entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER django
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/')" || exit 1
|
||||||
|
|
||||||
|
# Use entrypoint script
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|
||||||
|
# Default command (can be overridden in docker-compose)
|
||||||
|
CMD ["gunicorn", "zonco_backend.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
|
||||||
|
|
||||||
28
ENV_TEMPLATE.txt
Normal file
28
ENV_TEMPLATE.txt
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Environment Variables Template
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
# DO NOT commit .env to version control
|
||||||
|
|
||||||
|
# Django Settings
|
||||||
|
SECRET_KEY=your-secret-key-here-change-in-production
|
||||||
|
# Generate a secret key with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||||
|
DEBUG=False
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,185.208.172.158
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
POSTGRES_DB=Zoneco_ORG
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_HOST=db
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@db:5432/Zoneco_ORG
|
||||||
|
|
||||||
|
# Django Port
|
||||||
|
DJANGO_PORT=8000
|
||||||
|
|
||||||
|
# CORS Settings (comma-separated)
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000,http://185.208.172.158:9123,http://185.208.172.158
|
||||||
|
|
||||||
|
# Gunicorn Settings (optional)
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_TIMEOUT=120
|
||||||
|
|
||||||
326
README.md
326
README.md
@@ -1,135 +1,261 @@
|
|||||||
# Zoneco ORG Backend API
|
# Zoneco ORG Backend API
|
||||||
|
|
||||||
A professional Django REST Framework backend for Zoneco ORG with PostgreSQL database.
|
A Django REST Framework backend for Zoneco ORG with PostgreSQL database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Status
|
||||||
|
|
||||||
|
```
|
||||||
|
Ran 24 tests in ~12s — OK (0 failures)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Group | Tests | Status |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| Contact Us | 6 | ✅ All pass |
|
||||||
|
| Compositions | 11 | ✅ All pass |
|
||||||
|
| Campaigns | 7 | ✅ All pass |
|
||||||
|
|
||||||
|
Run tests at any time:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python manage.py test api -v 2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Contact Us API**: Handle contact form submissions with name, email/phone, description, and category
|
- **Contact Us API** — Contact form submissions with category filter
|
||||||
- **Composition API**: Manage compositions with name, description, and image
|
- **Composition API** — Multi-image upload, main image flag, date-range filter
|
||||||
- **Campaign API**: Manage campaigns with name, description, start/end times, and image
|
- **Campaign API** — Campaigns with active / upcoming / ended filters
|
||||||
- **RESTful API**: Full CRUD operations for all models
|
- **Admin Panel** — Full Django admin at `/admin/`
|
||||||
- **CORS Enabled**: Configured for frontend integration
|
- **Image Upload** — JPEG, PNG, WebP, GIF, max 5 MB, stored in `media/`
|
||||||
- **Image Upload**: Support for image uploads with proper media handling
|
- **CORS Enabled** — Configured for frontend integration
|
||||||
- **Admin Panel**: Django admin interface for managing data
|
- **Pagination** — 20 items per page
|
||||||
|
|
||||||
## Database Configuration
|
---
|
||||||
|
|
||||||
- **Database Name**: Zoneco_ORG
|
## Quick Start
|
||||||
- **User**: postgres
|
|
||||||
- **Password**: postgres
|
|
||||||
- **Host**: localhost
|
|
||||||
- **Port**: 5432
|
|
||||||
|
|
||||||
## Installation
|
### 1. Install dependencies
|
||||||
|
|
||||||
1. **Install Python dependencies:**
|
```bash
|
||||||
```bash
|
pip install -r requirements.txt
|
||||||
pip install -r requirements.txt
|
```
|
||||||
```
|
|
||||||
|
|
||||||
2. **Create PostgreSQL database:**
|
### 2. Create PostgreSQL database
|
||||||
```sql
|
|
||||||
CREATE DATABASE "Zoneco_ORG";
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Run migrations:**
|
```sql
|
||||||
```bash
|
CREATE DATABASE "Zoneco_ORG";
|
||||||
python manage.py makemigrations
|
```
|
||||||
python manage.py migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Create superuser (optional):**
|
### 3. Apply migrations
|
||||||
```bash
|
|
||||||
python manage.py createsuperuser
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Run development server:**
|
```bash
|
||||||
```bash
|
python manage.py migrate
|
||||||
python manage.py runserver
|
```
|
||||||
```
|
|
||||||
|
|
||||||
The API will be available at `http://localhost:8000/api/`
|
### 4. Run development server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
API available at `{{base_url}}/api/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Credentials
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| URL | `{{base_url}}/admin/` |
|
||||||
|
| Username | `Zoneco_@1405` |
|
||||||
|
| Password | `Fun_@_zone2026` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Contact Us
|
### Authentication
|
||||||
- `GET /api/contact-us/` - List all contacts
|
|
||||||
- `POST /api/contact-us/` - Create new contact (public)
|
|
||||||
- `GET /api/contact-us/{id}/` - Get specific contact
|
|
||||||
- `PUT /api/contact-us/{id}/` - Update contact (authenticated)
|
|
||||||
- `PATCH /api/contact-us/{id}/` - Partial update (authenticated)
|
|
||||||
- `DELETE /api/contact-us/{id}/` - Delete contact (authenticated)
|
|
||||||
- `GET /api/contact-us/by_category/?category={category}` - Filter by category
|
|
||||||
|
|
||||||
### Compositions
|
| Action | Access |
|
||||||
- `GET /api/compositions/` - List all compositions
|
|--------|--------|
|
||||||
- `POST /api/compositions/` - Create new composition (authenticated)
|
| GET (list, retrieve, custom filters) | Public — no login required |
|
||||||
- `GET /api/compositions/{id}/` - Get specific composition
|
| POST (create) | Public — no login required |
|
||||||
- `PUT /api/compositions/{id}/` - Update composition (authenticated)
|
| PUT / PATCH / DELETE | Admin only — Basic Auth required |
|
||||||
- `PATCH /api/compositions/{id}/` - Partial update (authenticated)
|
|
||||||
- `DELETE /api/compositions/{id}/` - Delete composition (authenticated)
|
|
||||||
|
|
||||||
### Campaigns
|
---
|
||||||
- `GET /api/campaigns/` - List all campaigns
|
|
||||||
- `POST /api/campaigns/` - Create new campaign (authenticated)
|
### Contact Us — `/api/contact-us/`
|
||||||
- `GET /api/campaigns/{id}/` - Get specific campaign
|
|
||||||
- `PUT /api/campaigns/{id}/` - Update campaign (authenticated)
|
| Method | Endpoint | Access | Description |
|
||||||
- `PATCH /api/campaigns/{id}/` - Partial update (authenticated)
|
|--------|----------|--------|-------------|
|
||||||
- `DELETE /api/campaigns/{id}/` - Delete campaign (authenticated)
|
| GET | `/api/contact-us/` | Public | List all submissions |
|
||||||
- `GET /api/campaigns/active/` - Get active campaigns
|
| POST | `/api/contact-us/` | Public | Submit a contact form |
|
||||||
- `GET /api/campaigns/upcoming/` - Get upcoming campaigns
|
| GET | `/api/contact-us/{id}/` | Public | Get one submission |
|
||||||
- `GET /api/campaigns/ended/` - Get ended campaigns
|
| GET | `/api/contact-us/by_category/?category=...` | Public | Filter by category |
|
||||||
|
| PATCH | `/api/contact-us/{id}/` | Admin | Partial update |
|
||||||
|
| PUT | `/api/contact-us/{id}/` | Admin | Full update |
|
||||||
|
| DELETE | `/api/contact-us/{id}/` | Admin | Delete |
|
||||||
|
|
||||||
|
**Category choices:** `همکاری` · `فروش` · `پشتیبانی` · `درخواست مشاور` · `سایر`
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required |
|
||||||
|
|-------|------|----------|
|
||||||
|
| `name` | string | Yes |
|
||||||
|
| `email_or_phone` | string | Yes |
|
||||||
|
| `description` | string | Yes |
|
||||||
|
| `category` | string | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Compositions — `/api/compositions/`
|
||||||
|
|
||||||
|
Each composition can have **multiple images**. One image can be flagged as the **main image** (`is_main: true`).
|
||||||
|
|
||||||
|
| Method | Endpoint | Access | Description |
|
||||||
|
|--------|----------|--------|-------------|
|
||||||
|
| GET | `/api/compositions/` | Public | List all compositions |
|
||||||
|
| POST | `/api/compositions/` | Public | Create composition (with optional images) |
|
||||||
|
| GET | `/api/compositions/{id}/` | Public | Get one composition |
|
||||||
|
| GET | `/api/compositions/by-created-at/?from=...&to=...` | Public | Filter by creation date range |
|
||||||
|
| POST | `/api/compositions/{id}/add-images/` | Admin | Add images to existing composition |
|
||||||
|
| POST | `/api/compositions/{id}/set-main-image/` | Admin | Set main image |
|
||||||
|
| DELETE | `/api/compositions/{id}/images/{image_id}/` | Admin | Delete a single image |
|
||||||
|
| PATCH | `/api/compositions/{id}/` | Admin | Partial update |
|
||||||
|
| PUT | `/api/compositions/{id}/` | Admin | Full update |
|
||||||
|
| DELETE | `/api/compositions/{id}/` | Admin | Delete composition |
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `name` | string | Yes | |
|
||||||
|
| `description` | string | Yes | |
|
||||||
|
| `uploaded_images` | file[] | No | One or more image files (multipart) |
|
||||||
|
| `main_image_index` | integer | No | 0-based index of main image in `uploaded_images`. Defaults to 0. |
|
||||||
|
|
||||||
|
**Image constraints:** JPEG, PNG, WebP, GIF — max **5 MB** each — stored at `media/compositions/`
|
||||||
|
|
||||||
|
**`by-created-at` query params:**
|
||||||
|
|
||||||
|
| Param | Required | Format |
|
||||||
|
|-------|----------|--------|
|
||||||
|
| `from` | At least one | ISO 8601 e.g. `2026-06-01T00:00:00Z` |
|
||||||
|
| `to` | At least one | ISO 8601 e.g. `2026-06-30T23:59:59Z` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Campaigns — `/api/campaigns/`
|
||||||
|
|
||||||
|
| Method | Endpoint | Access | Description |
|
||||||
|
|--------|----------|--------|-------------|
|
||||||
|
| GET | `/api/campaigns/` | Public | List all campaigns |
|
||||||
|
| POST | `/api/campaigns/` | Public | Create campaign |
|
||||||
|
| GET | `/api/campaigns/{id}/` | Public | Get one campaign |
|
||||||
|
| GET | `/api/campaigns/active/` | Public | Currently active campaigns |
|
||||||
|
| GET | `/api/campaigns/upcoming/` | Public | Not started yet |
|
||||||
|
| GET | `/api/campaigns/ended/` | Public | Already finished |
|
||||||
|
| PATCH | `/api/campaigns/{id}/` | Admin | Partial update |
|
||||||
|
| PUT | `/api/campaigns/{id}/` | Admin | Full update |
|
||||||
|
| DELETE | `/api/campaigns/{id}/` | Admin | Delete |
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|-------|------|----------|-------|
|
||||||
|
| `name` | string | Yes | |
|
||||||
|
| `description` | string | Yes | |
|
||||||
|
| `start_time` | datetime | Yes | ISO 8601 |
|
||||||
|
| `end_time` | datetime | Yes | Must be after `start_time` |
|
||||||
|
| `image` | file | No | Campaign image (multipart) |
|
||||||
|
|
||||||
|
**Read-only computed fields:** `is_active`, `is_upcoming`, `is_ended`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
### ContactUs
|
### ContactUs
|
||||||
- `name` (CharField): Name of the contact
|
|
||||||
- `email_or_phone` (CharField): Email or phone number
|
| Field | Type |
|
||||||
- `description` (TextField): Message description
|
|-------|------|
|
||||||
- `category` (CharField): Category (همکاری, فروش, پشتیبانی, درخواست مشاور, سایر)
|
| `name` | CharField |
|
||||||
- `created_at` (DateTimeField): Creation timestamp
|
| `email_or_phone` | CharField |
|
||||||
- `updated_at` (DateTimeField): Last update timestamp
|
| `description` | TextField |
|
||||||
|
| `category` | CharField (choices) |
|
||||||
|
| `created_at` | DateTimeField (auto) |
|
||||||
|
| `updated_at` | DateTimeField (auto) |
|
||||||
|
|
||||||
### Composition
|
### Composition
|
||||||
- `name` (CharField): Name of the composition
|
|
||||||
- `description` (TextField): Description
|
| Field | Type |
|
||||||
- `image` (ImageField): Composition image
|
|-------|------|
|
||||||
- `created_at` (DateTimeField): Creation timestamp
|
| `name` | CharField |
|
||||||
- `updated_at` (DateTimeField): Last update timestamp
|
| `description` | TextField |
|
||||||
|
| `image` | ImageField (legacy) |
|
||||||
|
| `created_at` | DateTimeField (auto) |
|
||||||
|
| `updated_at` | DateTimeField (auto) |
|
||||||
|
|
||||||
|
### CompositionImage
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|-------|------|
|
||||||
|
| `composition` | ForeignKey → Composition |
|
||||||
|
| `image` | ImageField |
|
||||||
|
| `is_main` | BooleanField |
|
||||||
|
| `created_at` | DateTimeField (auto) |
|
||||||
|
|
||||||
|
DB constraint: only one `is_main=True` per composition.
|
||||||
|
|
||||||
### Campaign
|
### Campaign
|
||||||
- `name` (CharField): Campaign name
|
|
||||||
- `description` (TextField): Campaign description
|
|
||||||
- `start_time` (DateTimeField): Campaign start time
|
|
||||||
- `end_time` (DateTimeField): Campaign end time
|
|
||||||
- `image` (ImageField): Campaign image
|
|
||||||
- `created_at` (DateTimeField): Creation timestamp
|
|
||||||
- `updated_at` (DateTimeField): Last update timestamp
|
|
||||||
|
|
||||||
## Admin Panel
|
| Field | Type |
|
||||||
|
|-------|------|
|
||||||
|
| `name` | CharField |
|
||||||
|
| `description` | TextField |
|
||||||
|
| `start_time` | DateTimeField |
|
||||||
|
| `end_time` | DateTimeField |
|
||||||
|
| `image` | ImageField |
|
||||||
|
| `created_at` | DateTimeField (auto) |
|
||||||
|
| `updated_at` | DateTimeField (auto) |
|
||||||
|
|
||||||
Access the Django admin panel at `http://localhost:8000/admin/` after creating a superuser.
|
---
|
||||||
|
|
||||||
## CORS Configuration
|
|
||||||
|
|
||||||
CORS is configured to allow requests from:
|
|
||||||
- `http://localhost:5173` (Vite default)
|
|
||||||
- `http://localhost:3000`
|
|
||||||
- `http://127.0.0.1:5173`
|
|
||||||
- `http://127.0.0.1:3000`
|
|
||||||
|
|
||||||
## Media Files
|
## Media Files
|
||||||
|
|
||||||
Uploaded images are stored in the `media/` directory:
|
| Content | Path |
|
||||||
- Composition images: `media/compositions/`
|
|---------|------|
|
||||||
- Campaign images: `media/campaigns/`
|
| Composition images | `media/compositions/` |
|
||||||
|
| Campaign images | `media/campaigns/` |
|
||||||
|
|
||||||
## Development Notes
|
---
|
||||||
|
|
||||||
- The backend uses Django REST Framework for API endpoints
|
## Postman Collection
|
||||||
- PostgreSQL is used as the database
|
|
||||||
- Image uploads are handled via multipart/form-data
|
|
||||||
- All models include proper indexing for performance
|
|
||||||
- Serializers include validation and proper error handling
|
|
||||||
- Viewsets provide both public read access and authenticated write access
|
|
||||||
|
|
||||||
|
Import `backend/Zoneco_ORG_API.postman_collection.json` into Postman.
|
||||||
|
|
||||||
|
Set the `base_url` collection variable to your server address.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Persian Documentation
|
||||||
|
|
||||||
|
Full Persian API documentation: `backend/API_DOCS_FA.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP Status Codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 200 | OK |
|
||||||
|
| 201 | Created |
|
||||||
|
| 204 | Deleted (no body) |
|
||||||
|
| 400 | Invalid data |
|
||||||
|
| 403 | Forbidden (login required) |
|
||||||
|
| 404 | Not found |
|
||||||
|
|||||||
@@ -2,197 +2,29 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "zoneco-org-api-collection",
|
"_postman_id": "zoneco-org-api-collection",
|
||||||
"name": "Zoneco ORG API",
|
"name": "Zoneco ORG API",
|
||||||
"description": "Complete API collection for Zoneco ORG backend - Campaign, Contact Us, and Composition endpoints",
|
"description": "مجموعه کامل APIهای بکاند Zoneco ORG — شامل Contact Us، Compositions (با آپلود چند تصویر) و Campaigns.\n\nمتغیرها:\n- base_url: آدرس سرور (پیشفرض http://localhost:8000)\n- composition_id: شناسه ترکیب برای تست\n- image_id: شناسه تصویر ترکیب\n\nبرای درخواستهای ادمین از Basic Auth استفاده کنید.",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
},
|
},
|
||||||
"item": [
|
"item": [
|
||||||
{
|
|
||||||
"name": "Campaigns",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "GET All Campaigns",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/campaigns/",
|
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"campaigns",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Get all campaigns"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GET Campaign by ID",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/campaigns/1/",
|
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"campaigns",
|
|
||||||
"1",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Get a specific campaign by ID"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "POST Create Campaign",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{
|
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"body": {
|
|
||||||
"mode": "raw",
|
|
||||||
"raw": "{\n \"name\": \"کمپین تست\",\n \"description\": \"این یک کمپین تستی است\",\n \"start_time\": \"2024-01-01T00:00:00Z\",\n \"end_time\": \"2024-12-31T23:59:59Z\"\n}",
|
|
||||||
"options": {
|
|
||||||
"raw": {
|
|
||||||
"language": "json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/campaigns/",
|
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"campaigns",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Create a new campaign"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GET Active Campaigns",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/campaigns/active/",
|
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"campaigns",
|
|
||||||
"active",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Get all currently active campaigns"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GET Upcoming Campaigns",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/campaigns/upcoming/",
|
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"campaigns",
|
|
||||||
"upcoming",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Get all upcoming campaigns"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GET Ended Campaigns",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": {
|
|
||||||
"raw": "{{base_url}}/api/campaigns/ended/",
|
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"campaigns",
|
|
||||||
"ended",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Get all ended campaigns"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "Campaign management endpoints"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Contact Us",
|
"name": "Contact Us",
|
||||||
|
"description": "APIهای فرم تماس با ما",
|
||||||
"item": [
|
"item": [
|
||||||
{
|
{
|
||||||
"name": "GET All Contact Us",
|
"name": "GET All Contact Us",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"url": {
|
"url": "{{base_url}}/api/contact-us/"
|
||||||
"raw": "{{base_url}}/api/contact-us/",
|
}
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"contact-us",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Get all contact form submissions"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "GET Contact Us by ID",
|
"name": "GET Contact Us by ID",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"url": {
|
"url": "{{base_url}}/api/contact-us/1/"
|
||||||
"raw": "{{base_url}}/api/contact-us/1/",
|
}
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"contact-us",
|
|
||||||
"1",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Get a specific contact submission by ID"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "POST Create Contact Us",
|
"name": "POST Create Contact Us",
|
||||||
@@ -206,27 +38,10 @@
|
|||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"name\": \"احمد محمدی\",\n \"email_or_phone\": \"ahmad@example.com\",\n \"description\": \"سلام، میخواستم در مورد خدمات شما اطلاعات بیشتری دریافت کنم.\",\n \"category\": \"پشتیبانی\"\n}",
|
"raw": "{\n \"name\": \"احمد محمدی\",\n \"email_or_phone\": \"ahmad@example.com\",\n \"description\": \"سلام، میخواستم در مورد خدمات شما اطلاعات بیشتری دریافت کنم.\",\n \"category\": \"پشتیبانی\"\n}"
|
||||||
"options": {
|
|
||||||
"raw": {
|
|
||||||
"language": "json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"url": {
|
"url": "{{base_url}}/api/contact-us/"
|
||||||
"raw": "{{base_url}}/api/contact-us/",
|
}
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"contact-us",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Submit a new contact form"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "GET Contact Us by Category",
|
"name": "GET Contact Us by Category",
|
||||||
@@ -235,110 +50,341 @@
|
|||||||
"header": [],
|
"header": [],
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{base_url}}/api/contact-us/by_category/?category=پشتیبانی",
|
"raw": "{{base_url}}/api/contact-us/by_category/?category=پشتیبانی",
|
||||||
"host": [
|
"host": ["{{base_url}}"],
|
||||||
"{{base_url}}"
|
"path": ["api", "contact-us", "by_category", ""],
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"contact-us",
|
|
||||||
"by_category",
|
|
||||||
""
|
|
||||||
],
|
|
||||||
"query": [
|
"query": [
|
||||||
{
|
{
|
||||||
"key": "category",
|
"key": "category",
|
||||||
"value": "پشتیبانی",
|
"value": "پشتیبانی",
|
||||||
"description": "Category: همکاری, فروش, پشتیبانی, درخواست مشاور, سایر"
|
"description": "همکاری | فروش | پشتیبانی | درخواست مشاور | سایر"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PATCH Update Contact Us (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"description": "Get contacts filtered by category"
|
"method": "PATCH",
|
||||||
},
|
"header": [
|
||||||
"response": []
|
{"key": "Content-Type", "value": "application/json"}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"نام بروزرسانی شده\"\n}"
|
||||||
|
},
|
||||||
|
"url": "{{base_url}}/api/contact-us/1/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DELETE Contact Us (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/contact-us/1/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"description": "Contact form submission endpoints"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Compositions",
|
"name": "Compositions",
|
||||||
|
"description": "APIهای ترکیبات — پشتیبانی از آپلود چند تصویر با تصویر اصلی",
|
||||||
"item": [
|
"item": [
|
||||||
{
|
{
|
||||||
"name": "GET All Compositions",
|
"name": "GET All Compositions",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"url": {
|
"url": "{{base_url}}/api/compositions/"
|
||||||
"raw": "{{base_url}}/api/compositions/",
|
}
|
||||||
"host": [
|
|
||||||
"{{base_url}}"
|
|
||||||
],
|
|
||||||
"path": [
|
|
||||||
"api",
|
|
||||||
"compositions",
|
|
||||||
""
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"description": "Get all compositions"
|
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "GET Composition by ID",
|
"name": "GET Composition by ID",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/compositions/{{composition_id}}/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET Compositions by Created At",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{base_url}}/api/compositions/1/",
|
"raw": "{{base_url}}/api/compositions/by-created-at/?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z",
|
||||||
"host": [
|
"host": ["{{base_url}}"],
|
||||||
"{{base_url}}"
|
"path": ["api", "compositions", "by-created-at", ""],
|
||||||
],
|
"query": [
|
||||||
"path": [
|
{
|
||||||
"api",
|
"key": "from",
|
||||||
"compositions",
|
"value": "2026-01-01T00:00:00Z",
|
||||||
"1",
|
"description": "Start datetime — ISO 8601 (UTC). At least one of from/to required."
|
||||||
""
|
},
|
||||||
|
{
|
||||||
|
"key": "to",
|
||||||
|
"value": "2026-12-31T23:59:59Z",
|
||||||
|
"description": "End datetime — ISO 8601 (UTC). At least one of from/to required."
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"description": "Get a specific composition by ID"
|
}
|
||||||
},
|
|
||||||
"response": []
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "POST Create Composition",
|
"name": "POST Create Composition (JSON)",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [
|
"header": [
|
||||||
{
|
{"key": "Content-Type", "value": "application/json"}
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/json"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"body": {
|
"body": {
|
||||||
"mode": "raw",
|
"mode": "raw",
|
||||||
"raw": "{\n \"name\": \"ترکیب تست\",\n \"description\": \"این یک ترکیب تستی است\"\n}",
|
"raw": "{\n \"name\": \"ترکیب تست\",\n \"description\": \"این یک ترکیب تستی است\"\n}"
|
||||||
"options": {
|
|
||||||
"raw": {
|
|
||||||
"language": "json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"url": {
|
"url": "{{base_url}}/api/compositions/"
|
||||||
"raw": "{{base_url}}/api/compositions/",
|
}
|
||||||
"host": [
|
},
|
||||||
"{{base_url}}"
|
{
|
||||||
],
|
"name": "POST Create Composition with Images",
|
||||||
"path": [
|
"request": {
|
||||||
"api",
|
"method": "POST",
|
||||||
"compositions",
|
"header": [],
|
||||||
""
|
"body": {
|
||||||
|
"mode": "formdata",
|
||||||
|
"formdata": [
|
||||||
|
{"key": "name", "value": "ترکیب با تصویر", "type": "text"},
|
||||||
|
{"key": "description", "value": "ترکیب با چند تصویر", "type": "text"},
|
||||||
|
{"key": "uploaded_images", "type": "file", "src": []},
|
||||||
|
{"key": "uploaded_images", "type": "file", "src": []},
|
||||||
|
{"key": "main_image_index", "value": "0", "type": "text", "description": "ایندکس تصویر اصلی (از 0)"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Create a new composition"
|
"url": "{{base_url}}/api/compositions/"
|
||||||
},
|
}
|
||||||
"response": []
|
},
|
||||||
|
{
|
||||||
|
"name": "POST Add Images to Composition (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "formdata",
|
||||||
|
"formdata": [
|
||||||
|
{"key": "uploaded_images", "type": "file", "src": []},
|
||||||
|
{"key": "main_image_index", "value": "0", "type": "text"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": "{{base_url}}/api/compositions/{{composition_id}}/add-images/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POST Set Main Image (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{"key": "Content-Type", "value": "application/json"}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"image_id\": {{image_id}}\n}"
|
||||||
|
},
|
||||||
|
"url": "{{base_url}}/api/compositions/{{composition_id}}/set-main-image/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DELETE Composition Image (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/compositions/{{composition_id}}/images/{{image_id}}/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PATCH Update Composition (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "PATCH",
|
||||||
|
"header": [
|
||||||
|
{"key": "Content-Type", "value": "application/json"}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"نام بروزرسانی شده\"\n}"
|
||||||
|
},
|
||||||
|
"url": "{{base_url}}/api/compositions/{{composition_id}}/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DELETE Composition (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/compositions/{{composition_id}}/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"description": "Composition management endpoints"
|
},
|
||||||
|
{
|
||||||
|
"name": "Campaigns",
|
||||||
|
"description": "APIهای کمپینها",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "GET All Campaigns",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/campaigns/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET Campaign by ID",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/campaigns/1/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POST Create Campaign",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{"key": "Content-Type", "value": "application/json"}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"کمپین تست\",\n \"description\": \"این یک کمپین تستی است\",\n \"start_time\": \"2026-06-01T00:00:00Z\",\n \"end_time\": \"2026-12-31T23:59:59Z\"\n}"
|
||||||
|
},
|
||||||
|
"url": "{{base_url}}/api/campaigns/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POST Create Campaign with Image",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "formdata",
|
||||||
|
"formdata": [
|
||||||
|
{"key": "name", "value": "کمپین با تصویر", "type": "text"},
|
||||||
|
{"key": "description", "value": "توضیحات کمپین", "type": "text"},
|
||||||
|
{"key": "start_time", "value": "2026-06-01T00:00:00Z", "type": "text"},
|
||||||
|
{"key": "end_time", "value": "2026-12-31T23:59:59Z", "type": "text"},
|
||||||
|
{"key": "image", "type": "file", "src": []}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": "{{base_url}}/api/campaigns/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET Active Campaigns",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/campaigns/active/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET Upcoming Campaigns",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/campaigns/upcoming/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GET Ended Campaigns",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/campaigns/ended/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PATCH Update Campaign (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "PATCH",
|
||||||
|
"header": [
|
||||||
|
{"key": "Content-Type", "value": "application/json"}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"name\": \"نام بروزرسانی شده\"\n}"
|
||||||
|
},
|
||||||
|
"url": "{{base_url}}/api/campaigns/1/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DELETE Campaign (Admin)",
|
||||||
|
"request": {
|
||||||
|
"auth": {
|
||||||
|
"type": "basic",
|
||||||
|
"basic": [
|
||||||
|
{"key": "username", "value": "{{admin_username}}", "type": "string"},
|
||||||
|
{"key": "password", "value": "{{admin_password}}", "type": "string"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": "{{base_url}}/api/campaigns/1/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
@@ -346,7 +392,26 @@
|
|||||||
"key": "base_url",
|
"key": "base_url",
|
||||||
"value": "http://localhost:8000",
|
"value": "http://localhost:8000",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "composition_id",
|
||||||
|
"value": "1",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "image_id",
|
||||||
|
"value": "1",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "admin_username",
|
||||||
|
"value": "Zoneco_@1405",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "admin_password",
|
||||||
|
"value": "Fun_@_zone2026",
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
api/admin.py
10
api/admin.py
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import ContactUs, Composition, Campaign
|
from .models import ContactUs, Composition, Campaign, CompositionImage
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ContactUs)
|
@admin.register(ContactUs)
|
||||||
@@ -24,12 +24,20 @@ class ContactUsAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompositionImageInline(admin.TabularInline):
|
||||||
|
model = CompositionImage
|
||||||
|
extra = 1
|
||||||
|
fields = ['image', 'is_main', 'created_at']
|
||||||
|
readonly_fields = ['created_at']
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Composition)
|
@admin.register(Composition)
|
||||||
class CompositionAdmin(admin.ModelAdmin):
|
class CompositionAdmin(admin.ModelAdmin):
|
||||||
list_display = ['name', 'created_at']
|
list_display = ['name', 'created_at']
|
||||||
search_fields = ['name', 'description']
|
search_fields = ['name', 'description']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
date_hierarchy = 'created_at'
|
date_hierarchy = 'created_at'
|
||||||
|
inlines = [CompositionImageInline]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('اطلاعات ترکیب', {
|
('اطلاعات ترکیب', {
|
||||||
|
|||||||
33
api/migrations/0002_composition_images.py
Normal file
33
api/migrations/0002_composition_images.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2026-06-23 14:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('api', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CompositionImage',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('image', models.ImageField(upload_to='compositions/', verbose_name='تصویر')),
|
||||||
|
('is_main', models.BooleanField(default=False, verbose_name='تصویر اصلی')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||||
|
('composition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='api.composition', verbose_name='ترکیب')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'تصویر ترکیب',
|
||||||
|
'verbose_name_plural': 'تصاویر ترکیب',
|
||||||
|
'ordering': ['-is_main', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='compositionimage',
|
||||||
|
constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('composition',), name='unique_main_image_per_composition'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -49,6 +49,8 @@ class Composition(models.Model):
|
|||||||
"""
|
"""
|
||||||
name = models.CharField(max_length=200, verbose_name='نام')
|
name = models.CharField(max_length=200, verbose_name='نام')
|
||||||
description = models.TextField(verbose_name='توضیحات')
|
description = models.TextField(verbose_name='توضیحات')
|
||||||
|
# Legacy single-image field, kept for backward compatibility.
|
||||||
|
# New uploads use the related CompositionImage model below.
|
||||||
image = models.ImageField(
|
image = models.ImageField(
|
||||||
upload_to='compositions/',
|
upload_to='compositions/',
|
||||||
verbose_name='تصویر',
|
verbose_name='تصویر',
|
||||||
@@ -68,6 +70,56 @@ class Composition(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def main_image(self):
|
||||||
|
"""Return the image flagged as main, falling back to the first image."""
|
||||||
|
return self.images.filter(is_main=True).first() or self.images.first()
|
||||||
|
|
||||||
|
|
||||||
|
class CompositionImage(models.Model):
|
||||||
|
"""
|
||||||
|
Image belonging to a Composition. A composition can have many images,
|
||||||
|
and at most one of them can be flagged as the main image.
|
||||||
|
"""
|
||||||
|
composition = models.ForeignKey(
|
||||||
|
Composition,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='images',
|
||||||
|
verbose_name='ترکیب'
|
||||||
|
)
|
||||||
|
image = models.ImageField(
|
||||||
|
upload_to='compositions/',
|
||||||
|
verbose_name='تصویر'
|
||||||
|
)
|
||||||
|
is_main = models.BooleanField(default=False, verbose_name='تصویر اصلی')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'تصویر ترکیب'
|
||||||
|
verbose_name_plural = 'تصاویر ترکیب'
|
||||||
|
# Main image first, then newest.
|
||||||
|
ordering = ['-is_main', '-created_at']
|
||||||
|
constraints = [
|
||||||
|
# At most one main image per composition.
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=['composition'],
|
||||||
|
condition=models.Q(is_main=True),
|
||||||
|
name='unique_main_image_per_composition'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.composition.name} - {'main' if self.is_main else 'image'} #{self.pk}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Ensure only one main image per composition: if this one is being
|
||||||
|
# set as main, unset the flag on the others.
|
||||||
|
if self.is_main:
|
||||||
|
CompositionImage.objects.filter(
|
||||||
|
composition=self.composition, is_main=True
|
||||||
|
).exclude(pk=self.pk).update(is_main=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Campaign(models.Model):
|
class Campaign(models.Model):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import ContactUs, Composition, Campaign
|
from .models import ContactUs, Composition, Campaign, CompositionImage
|
||||||
|
|
||||||
|
|
||||||
class ContactUsSerializer(serializers.ModelSerializer):
|
class ContactUsSerializer(serializers.ModelSerializer):
|
||||||
@@ -31,17 +31,36 @@ class ContactUsSerializer(serializers.ModelSerializer):
|
|||||||
return value.strip()
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
class CompositionSerializer(serializers.ModelSerializer):
|
# Accepted image content types and max upload size (5 MB).
|
||||||
|
ALLOWED_IMAGE_CONTENT_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||||
|
MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def validate_uploaded_image(image):
|
||||||
|
"""Reject anything that is not a real, reasonably sized image file."""
|
||||||
|
content_type = getattr(image, 'content_type', None)
|
||||||
|
if content_type and content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Unsupported image type '{content_type}'. "
|
||||||
|
f"Allowed: {', '.join(ALLOWED_IMAGE_CONTENT_TYPES)}."
|
||||||
|
)
|
||||||
|
if image.size > MAX_IMAGE_SIZE:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
f"Image too large ({image.size} bytes). Max allowed is {MAX_IMAGE_SIZE} bytes."
|
||||||
|
)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
class CompositionImageSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for Composition model.
|
Serializer for a single image belonging to a Composition.
|
||||||
"""
|
"""
|
||||||
image_url = serializers.SerializerMethodField()
|
image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Composition
|
model = CompositionImage
|
||||||
fields = ['id', 'name', 'description', 'image', 'image_url',
|
fields = ['id', 'image', 'image_url', 'is_main', 'created_at']
|
||||||
'created_at', 'updated_at']
|
read_only_fields = ['id', 'image_url', 'created_at']
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at', 'image_url']
|
|
||||||
|
|
||||||
def get_image_url(self, obj):
|
def get_image_url(self, obj):
|
||||||
"""Get the full URL of the image."""
|
"""Get the full URL of the image."""
|
||||||
@@ -51,6 +70,57 @@ class CompositionSerializer(serializers.ModelSerializer):
|
|||||||
return request.build_absolute_uri(obj.image.url)
|
return request.build_absolute_uri(obj.image.url)
|
||||||
return obj.image.url
|
return obj.image.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CompositionSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
Serializer for Composition model.
|
||||||
|
|
||||||
|
Read: returns the nested ``images`` list plus ``main_image``.
|
||||||
|
Write: accepts ``uploaded_images`` (one or more files) and an optional
|
||||||
|
``main_image_index`` pointing at which uploaded file is the main image.
|
||||||
|
"""
|
||||||
|
# Legacy single image URL (kept for backward compatibility).
|
||||||
|
image_url = serializers.SerializerMethodField()
|
||||||
|
images = CompositionImageSerializer(many=True, read_only=True)
|
||||||
|
main_image = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
# Write-only upload fields.
|
||||||
|
uploaded_images = serializers.ListField(
|
||||||
|
child=serializers.ImageField(validators=[validate_uploaded_image]),
|
||||||
|
write_only=True,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
main_image_index = serializers.IntegerField(
|
||||||
|
write_only=True,
|
||||||
|
required=False,
|
||||||
|
min_value=0,
|
||||||
|
help_text='0-based index into uploaded_images marking the main image.'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Composition
|
||||||
|
fields = ['id', 'name', 'description', 'image', 'image_url',
|
||||||
|
'images', 'main_image', 'uploaded_images', 'main_image_index',
|
||||||
|
'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at', 'image_url',
|
||||||
|
'images', 'main_image']
|
||||||
|
|
||||||
|
def get_image_url(self, obj):
|
||||||
|
"""Get the full URL of the legacy image field."""
|
||||||
|
if obj.image:
|
||||||
|
request = self.context.get('request')
|
||||||
|
if request:
|
||||||
|
return request.build_absolute_uri(obj.image.url)
|
||||||
|
return obj.image.url
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_main_image(self, obj):
|
||||||
|
"""Return the serialized main image, if any."""
|
||||||
|
main = obj.main_image
|
||||||
|
if main:
|
||||||
|
return CompositionImageSerializer(main, context=self.context).data
|
||||||
|
return None
|
||||||
|
|
||||||
def validate_name(self, value):
|
def validate_name(self, value):
|
||||||
"""Validate name field."""
|
"""Validate name field."""
|
||||||
@@ -63,6 +133,65 @@ class CompositionSerializer(serializers.ModelSerializer):
|
|||||||
if not value or len(value.strip()) == 0:
|
if not value or len(value.strip()) == 0:
|
||||||
raise serializers.ValidationError("Description is required.")
|
raise serializers.ValidationError("Description is required.")
|
||||||
return value.strip()
|
return value.strip()
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
"""
|
||||||
|
MultiPartParser stores repeated file fields as a QueryDict where only
|
||||||
|
the last value is returned by .get(). Pull the full list explicitly.
|
||||||
|
"""
|
||||||
|
if hasattr(data, 'getlist'):
|
||||||
|
uploaded = data.getlist('uploaded_images')
|
||||||
|
if uploaded:
|
||||||
|
mutable = data.copy()
|
||||||
|
mutable.setlist('uploaded_images', uploaded)
|
||||||
|
data = mutable
|
||||||
|
return super().to_internal_value(data)
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
"""Ensure main_image_index points at an actually uploaded image."""
|
||||||
|
uploaded = data.get('uploaded_images')
|
||||||
|
index = data.get('main_image_index')
|
||||||
|
if index is not None:
|
||||||
|
if not uploaded:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'main_image_index': 'Cannot set a main image without uploaded_images.'
|
||||||
|
})
|
||||||
|
if index >= len(uploaded):
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'main_image_index': f'Index out of range (got {index}, '
|
||||||
|
f'{len(uploaded)} image(s) uploaded).'
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _save_images(self, composition, uploaded_images, main_index):
|
||||||
|
"""Persist uploaded images, flagging the chosen one as main."""
|
||||||
|
# If the composition has no main image yet, default the first upload to main.
|
||||||
|
has_main = composition.images.filter(is_main=True).exists()
|
||||||
|
for i, image_file in enumerate(uploaded_images):
|
||||||
|
is_main = (i == main_index) if main_index is not None else (
|
||||||
|
i == 0 and not has_main
|
||||||
|
)
|
||||||
|
CompositionImage.objects.create(
|
||||||
|
composition=composition,
|
||||||
|
image=image_file,
|
||||||
|
is_main=is_main,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
uploaded_images = validated_data.pop('uploaded_images', [])
|
||||||
|
main_index = validated_data.pop('main_image_index', None)
|
||||||
|
composition = super().create(validated_data)
|
||||||
|
if uploaded_images:
|
||||||
|
self._save_images(composition, uploaded_images, main_index)
|
||||||
|
return composition
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
uploaded_images = validated_data.pop('uploaded_images', [])
|
||||||
|
main_index = validated_data.pop('main_image_index', None)
|
||||||
|
composition = super().update(instance, validated_data)
|
||||||
|
if uploaded_images:
|
||||||
|
self._save_images(composition, uploaded_images, main_index)
|
||||||
|
return composition
|
||||||
|
|
||||||
|
|
||||||
class CampaignSerializer(serializers.ModelSerializer):
|
class CampaignSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
280
api/tests.py
280
api/tests.py
@@ -1,3 +1,279 @@
|
|||||||
from django.test import TestCase
|
import io
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
# Create your tests here.
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.utils import timezone
|
||||||
|
from PIL import Image
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from .models import Campaign, Composition, CompositionImage, ContactUs
|
||||||
|
|
||||||
|
|
||||||
|
def make_test_image(name='test.jpg', color='red', size=(100, 100)):
|
||||||
|
"""Create a minimal valid JPEG for upload tests."""
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new('RGB', size, color=color).save(buffer, format='JPEG')
|
||||||
|
buffer.seek(0)
|
||||||
|
return SimpleUploadedFile(name, buffer.read(), content_type='image/jpeg')
|
||||||
|
|
||||||
|
|
||||||
|
class ContactUsAPITests(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.contact = ContactUs.objects.create(
|
||||||
|
name='Ali Reza',
|
||||||
|
email_or_phone='ali@example.com',
|
||||||
|
description='Need support',
|
||||||
|
category='پشتیبانی',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_contacts(self):
|
||||||
|
response = self.client.get('/api/contact-us/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['count'], 1)
|
||||||
|
|
||||||
|
def test_create_contact(self):
|
||||||
|
payload = {
|
||||||
|
'name': 'Sara',
|
||||||
|
'email_or_phone': '09121234567',
|
||||||
|
'description': 'Sales inquiry',
|
||||||
|
'category': 'فروش',
|
||||||
|
}
|
||||||
|
response = self.client.post('/api/contact-us/', payload, format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(ContactUs.objects.count(), 2)
|
||||||
|
|
||||||
|
def test_retrieve_contact(self):
|
||||||
|
response = self.client.get(f'/api/contact-us/{self.contact.id}/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['name'], 'Ali Reza')
|
||||||
|
|
||||||
|
def test_by_category(self):
|
||||||
|
response = self.client.get('/api/contact-us/by_category/?category=پشتیبانی')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
|
||||||
|
def test_by_category_missing_param(self):
|
||||||
|
response = self.client.get('/api/contact-us/by_category/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_update_requires_auth(self):
|
||||||
|
response = self.client.patch(
|
||||||
|
f'/api/contact-us/{self.contact.id}/',
|
||||||
|
{'name': 'Updated'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
||||||
|
class CompositionAPITests(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
User = get_user_model()
|
||||||
|
self.admin = User.objects.create_user(
|
||||||
|
username='testadmin', password='testpass123', is_staff=True
|
||||||
|
)
|
||||||
|
self.composition = Composition.objects.create(
|
||||||
|
name='Test Composition',
|
||||||
|
description='Test description',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_compositions(self):
|
||||||
|
response = self.client.get('/api/compositions/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['count'], 1)
|
||||||
|
|
||||||
|
def test_create_composition_json(self):
|
||||||
|
payload = {'name': 'New Comp', 'description': 'Desc'}
|
||||||
|
response = self.client.post('/api/compositions/', payload, format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(response.data['name'], 'New Comp')
|
||||||
|
self.assertEqual(response.data['images'], [])
|
||||||
|
|
||||||
|
def test_retrieve_composition(self):
|
||||||
|
response = self.client.get(f'/api/compositions/{self.composition.id}/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn('images', response.data)
|
||||||
|
self.assertIn('main_image', response.data)
|
||||||
|
self.assertIn('created_at', response.data)
|
||||||
|
|
||||||
|
def test_by_created_at(self):
|
||||||
|
now = timezone.now()
|
||||||
|
Composition.objects.create(
|
||||||
|
name='Old Composition',
|
||||||
|
description='Old',
|
||||||
|
)
|
||||||
|
Composition.objects.filter(name='Old Composition').update(
|
||||||
|
created_at=now - timedelta(days=10)
|
||||||
|
)
|
||||||
|
from_dt = (now - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
response = self.client.get(
|
||||||
|
f'/api/compositions/by-created-at/?from={from_dt}'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
names = [item['name'] for item in response.data]
|
||||||
|
self.assertIn('Test Composition', names)
|
||||||
|
self.assertNotIn('Old Composition', names)
|
||||||
|
|
||||||
|
def test_by_created_at_missing_params(self):
|
||||||
|
response = self.client.get('/api/compositions/by-created-at/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_by_created_at_invalid_datetime(self):
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/compositions/by-created-at/?from=not-a-date'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_create_with_multiple_images(self):
|
||||||
|
img1 = make_test_image('img1.jpg', 'red')
|
||||||
|
img2 = make_test_image('img2.jpg', 'blue')
|
||||||
|
response = self.client.post(
|
||||||
|
'/api/compositions/',
|
||||||
|
{
|
||||||
|
'name': 'Multi Image',
|
||||||
|
'description': 'With images',
|
||||||
|
'uploaded_images': [img1, img2],
|
||||||
|
'main_image_index': '1',
|
||||||
|
},
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(len(response.data['images']), 2)
|
||||||
|
self.assertTrue(response.data['main_image']['is_main'])
|
||||||
|
|
||||||
|
composition = Composition.objects.get(name='Multi Image')
|
||||||
|
self.assertEqual(composition.images.count(), 2)
|
||||||
|
self.assertEqual(composition.main_image.is_main, True)
|
||||||
|
|
||||||
|
def test_add_images(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
img = make_test_image('added.jpg', 'green')
|
||||||
|
response = self.client.post(
|
||||||
|
f'/api/compositions/{self.composition.id}/add-images/',
|
||||||
|
{'uploaded_images': [img]},
|
||||||
|
format='multipart',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data['images']), 1)
|
||||||
|
self.assertTrue(response.data['main_image']['is_main'])
|
||||||
|
|
||||||
|
def test_set_main_image(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
img1 = CompositionImage.objects.create(
|
||||||
|
composition=self.composition,
|
||||||
|
image=make_test_image('a.jpg'),
|
||||||
|
is_main=True,
|
||||||
|
)
|
||||||
|
img2 = CompositionImage.objects.create(
|
||||||
|
composition=self.composition,
|
||||||
|
image=make_test_image('b.jpg'),
|
||||||
|
is_main=False,
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
f'/api/compositions/{self.composition.id}/set-main-image/',
|
||||||
|
{'image_id': img2.id},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
img1.refresh_from_db()
|
||||||
|
img2.refresh_from_db()
|
||||||
|
self.assertFalse(img1.is_main)
|
||||||
|
self.assertTrue(img2.is_main)
|
||||||
|
|
||||||
|
def test_delete_image(self):
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
img = CompositionImage.objects.create(
|
||||||
|
composition=self.composition,
|
||||||
|
image=make_test_image('del.jpg'),
|
||||||
|
is_main=True,
|
||||||
|
)
|
||||||
|
response = self.client.delete(
|
||||||
|
f'/api/compositions/{self.composition.id}/images/{img.id}/'
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||||
|
self.assertFalse(CompositionImage.objects.filter(pk=img.id).exists())
|
||||||
|
|
||||||
|
def test_update_requires_auth(self):
|
||||||
|
response = self.client.patch(
|
||||||
|
f'/api/compositions/{self.composition.id}/',
|
||||||
|
{'name': 'Updated'},
|
||||||
|
format='json',
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignAPITests(APITestCase):
|
||||||
|
def setUp(self):
|
||||||
|
now = timezone.now()
|
||||||
|
self.active = Campaign.objects.create(
|
||||||
|
name='Active Campaign',
|
||||||
|
description='Running now',
|
||||||
|
start_time=now - timedelta(days=1),
|
||||||
|
end_time=now + timedelta(days=1),
|
||||||
|
)
|
||||||
|
self.upcoming = Campaign.objects.create(
|
||||||
|
name='Upcoming Campaign',
|
||||||
|
description='Starts soon',
|
||||||
|
start_time=now + timedelta(days=2),
|
||||||
|
end_time=now + timedelta(days=5),
|
||||||
|
)
|
||||||
|
self.ended = Campaign.objects.create(
|
||||||
|
name='Ended Campaign',
|
||||||
|
description='Already finished',
|
||||||
|
start_time=now - timedelta(days=10),
|
||||||
|
end_time=now - timedelta(days=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_campaigns(self):
|
||||||
|
response = self.client.get('/api/campaigns/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['count'], 3)
|
||||||
|
|
||||||
|
def test_create_campaign(self):
|
||||||
|
now = timezone.now()
|
||||||
|
payload = {
|
||||||
|
'name': 'New Campaign',
|
||||||
|
'description': 'Desc',
|
||||||
|
'start_time': (now + timedelta(days=1)).isoformat(),
|
||||||
|
'end_time': (now + timedelta(days=3)).isoformat(),
|
||||||
|
}
|
||||||
|
response = self.client.post('/api/campaigns/', payload, format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_retrieve_campaign(self):
|
||||||
|
response = self.client.get(f'/api/campaigns/{self.active.id}/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertTrue(response.data['is_active'])
|
||||||
|
self.assertFalse(response.data['is_upcoming'])
|
||||||
|
self.assertFalse(response.data['is_ended'])
|
||||||
|
|
||||||
|
def test_active_campaigns(self):
|
||||||
|
response = self.client.get('/api/campaigns/active/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
names = [item['name'] for item in response.data]
|
||||||
|
self.assertIn('Active Campaign', names)
|
||||||
|
self.assertNotIn('Upcoming Campaign', names)
|
||||||
|
|
||||||
|
def test_upcoming_campaigns(self):
|
||||||
|
response = self.client.get('/api/campaigns/upcoming/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
names = [item['name'] for item in response.data]
|
||||||
|
self.assertIn('Upcoming Campaign', names)
|
||||||
|
|
||||||
|
def test_ended_campaigns(self):
|
||||||
|
response = self.client.get('/api/campaigns/ended/')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
names = [item['name'] for item in response.data]
|
||||||
|
self.assertIn('Ended Campaign', names)
|
||||||
|
|
||||||
|
def test_create_campaign_invalid_dates(self):
|
||||||
|
now = timezone.now()
|
||||||
|
payload = {
|
||||||
|
'name': 'Bad Dates',
|
||||||
|
'description': 'Desc',
|
||||||
|
'start_time': (now + timedelta(days=3)).isoformat(),
|
||||||
|
'end_time': (now + timedelta(days=1)).isoformat(),
|
||||||
|
}
|
||||||
|
response = self.client.post('/api/campaigns/', payload, format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|||||||
113
api/views.py
113
api/views.py
@@ -2,10 +2,18 @@ from rest_framework import viewsets, status
|
|||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .models import ContactUs, Composition, Campaign
|
from django.utils.dateparse import parse_datetime
|
||||||
from .serializers import ContactUsSerializer, CompositionSerializer, CampaignSerializer
|
from .models import ContactUs, Composition, Campaign, CompositionImage
|
||||||
|
from .serializers import (
|
||||||
|
ContactUsSerializer,
|
||||||
|
CompositionSerializer,
|
||||||
|
CampaignSerializer,
|
||||||
|
CompositionImageSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ContactUsViewSet(viewsets.ModelViewSet):
|
class ContactUsViewSet(viewsets.ModelViewSet):
|
||||||
@@ -21,7 +29,7 @@ class ContactUsViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
Override to allow GET (list, retrieve) and POST (create) for anyone.
|
Override to allow GET (list, retrieve) and POST (create) for anyone.
|
||||||
"""
|
"""
|
||||||
if self.action in ['list', 'retrieve', 'create']:
|
if self.action in ['list', 'retrieve', 'create', 'by_category']:
|
||||||
return [AllowAny()]
|
return [AllowAny()]
|
||||||
return [IsAuthenticated()]
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
@@ -42,17 +50,22 @@ class ContactUsViewSet(viewsets.ModelViewSet):
|
|||||||
class CompositionViewSet(viewsets.ModelViewSet):
|
class CompositionViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for Composition model.
|
ViewSet for Composition model.
|
||||||
Provides CRUD operations for compositions.
|
|
||||||
|
Supports uploading one or more images at create/update time via the
|
||||||
|
multipart ``uploaded_images`` field, with an optional ``main_image_index``
|
||||||
|
to flag the main image. Extra actions allow adding images, choosing the
|
||||||
|
main image, and deleting an image after creation.
|
||||||
"""
|
"""
|
||||||
queryset = Composition.objects.all()
|
queryset = Composition.objects.prefetch_related('images').all()
|
||||||
serializer_class = CompositionSerializer
|
serializer_class = CompositionSerializer
|
||||||
permission_classes = [AllowAny] # Public read access
|
permission_classes = [AllowAny] # Public read access
|
||||||
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""
|
"""
|
||||||
Override to allow GET (list, retrieve) and POST (create) for anyone.
|
Override to allow GET (list, retrieve) and POST (create) for anyone.
|
||||||
"""
|
"""
|
||||||
if self.action in ['list', 'retrieve', 'create']:
|
if self.action in ['list', 'retrieve', 'create', 'by_created_at']:
|
||||||
return [AllowAny()]
|
return [AllowAny()]
|
||||||
return [IsAuthenticated()]
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
@@ -61,6 +74,94 @@ class CompositionViewSet(viewsets.ModelViewSet):
|
|||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
context['request'] = self.request
|
context['request'] = self.request
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'], url_path='by-created-at')
|
||||||
|
def by_created_at(self, request):
|
||||||
|
"""Get compositions filtered by created_at date range."""
|
||||||
|
from_param = request.query_params.get('from')
|
||||||
|
to_param = request.query_params.get('to')
|
||||||
|
|
||||||
|
if not from_param and not to_param:
|
||||||
|
return Response(
|
||||||
|
{'error': 'At least one of "from" or "to" query parameters is required.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
compositions = self.queryset
|
||||||
|
|
||||||
|
if from_param:
|
||||||
|
from_dt = parse_datetime(from_param)
|
||||||
|
if from_dt is None:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid "from" datetime. Use ISO 8601 format.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if timezone.is_naive(from_dt):
|
||||||
|
from_dt = timezone.make_aware(from_dt)
|
||||||
|
compositions = compositions.filter(created_at__gte=from_dt)
|
||||||
|
|
||||||
|
if to_param:
|
||||||
|
to_dt = parse_datetime(to_param)
|
||||||
|
if to_dt is None:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid "to" datetime. Use ISO 8601 format.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if timezone.is_naive(to_dt):
|
||||||
|
to_dt = timezone.make_aware(to_dt)
|
||||||
|
compositions = compositions.filter(created_at__lte=to_dt)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(compositions, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def _composition_response(self, composition):
|
||||||
|
"""Return a fresh composition payload with up-to-date images."""
|
||||||
|
composition = Composition.objects.prefetch_related('images').get(
|
||||||
|
pk=composition.pk
|
||||||
|
)
|
||||||
|
return self.get_serializer(composition).data
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='add-images')
|
||||||
|
def add_images(self, request, pk=None):
|
||||||
|
"""Add one or more images to an existing composition."""
|
||||||
|
composition = self.get_object()
|
||||||
|
serializer = self.get_serializer(
|
||||||
|
composition, data=request.data, partial=True
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
composition = serializer.save()
|
||||||
|
return Response(self._composition_response(composition))
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='set-main-image')
|
||||||
|
def set_main_image(self, request, pk=None):
|
||||||
|
"""Flag one of the composition's images as the main image."""
|
||||||
|
composition = self.get_object()
|
||||||
|
image_id = request.data.get('image_id')
|
||||||
|
if image_id is None:
|
||||||
|
return Response(
|
||||||
|
{'error': 'image_id is required.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
image = get_object_or_404(
|
||||||
|
CompositionImage, pk=image_id, composition=composition
|
||||||
|
)
|
||||||
|
image.is_main = True
|
||||||
|
image.save() # model.save() unsets is_main on the other images
|
||||||
|
return Response(self._composition_response(composition))
|
||||||
|
|
||||||
|
@action(
|
||||||
|
detail=True,
|
||||||
|
methods=['delete'],
|
||||||
|
url_path='images/(?P<image_id>[^/.]+)'
|
||||||
|
)
|
||||||
|
def delete_image(self, request, pk=None, image_id=None):
|
||||||
|
"""Delete a single image from the composition."""
|
||||||
|
composition = self.get_object()
|
||||||
|
image = get_object_or_404(
|
||||||
|
CompositionImage, pk=image_id, composition=composition
|
||||||
|
)
|
||||||
|
image.delete()
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class CampaignViewSet(viewsets.ModelViewSet):
|
class CampaignViewSet(viewsets.ModelViewSet):
|
||||||
|
|||||||
122
docker-commands.sh
Normal file
122
docker-commands.sh
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Helper script for common Docker operations
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if .env file exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
print_warn ".env file not found. Creating from template..."
|
||||||
|
cat > .env << EOF
|
||||||
|
SECRET_KEY=$(python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())")
|
||||||
|
DEBUG=False
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1,185.208.172.158
|
||||||
|
POSTGRES_DB=Zoneco_ORG
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_HOST=db
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
DJANGO_PORT=8000
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000,http://185.208.172.158:9123,http://185.208.172.158
|
||||||
|
EOF
|
||||||
|
print_info ".env file created with generated SECRET_KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse command
|
||||||
|
case "$1" in
|
||||||
|
up)
|
||||||
|
print_info "Starting Docker containers..."
|
||||||
|
docker-compose up -d
|
||||||
|
print_info "Containers started. Use 'docker-compose logs -f' to view logs."
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
print_info "Stopping Docker containers..."
|
||||||
|
docker-compose down
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
print_info "Restarting Docker containers..."
|
||||||
|
docker-compose restart
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
print_info "Showing logs (Ctrl+C to exit)..."
|
||||||
|
docker-compose logs -f
|
||||||
|
;;
|
||||||
|
shell)
|
||||||
|
print_info "Opening Django shell..."
|
||||||
|
docker-compose exec web python manage.py shell
|
||||||
|
;;
|
||||||
|
migrate)
|
||||||
|
print_info "Running migrations..."
|
||||||
|
docker-compose exec web python manage.py migrate
|
||||||
|
;;
|
||||||
|
makemigrations)
|
||||||
|
print_info "Creating migrations..."
|
||||||
|
docker-compose exec web python manage.py makemigrations
|
||||||
|
;;
|
||||||
|
createsuperuser)
|
||||||
|
print_info "Creating superuser..."
|
||||||
|
docker-compose exec web python manage.py createsuperuser
|
||||||
|
;;
|
||||||
|
collectstatic)
|
||||||
|
print_info "Collecting static files..."
|
||||||
|
docker-compose exec web python manage.py collectstatic --noinput
|
||||||
|
;;
|
||||||
|
rebuild)
|
||||||
|
print_info "Rebuilding containers..."
|
||||||
|
docker-compose build --no-cache
|
||||||
|
print_info "Containers rebuilt. Use './docker-commands.sh up' to start."
|
||||||
|
;;
|
||||||
|
clean)
|
||||||
|
print_warn "This will remove all containers, volumes, and images. Are you sure? (y/N)"
|
||||||
|
read -r response
|
||||||
|
if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then
|
||||||
|
print_info "Cleaning up..."
|
||||||
|
docker-compose down -v
|
||||||
|
docker system prune -f
|
||||||
|
print_info "Cleanup complete."
|
||||||
|
else
|
||||||
|
print_info "Cleanup cancelled."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
status)
|
||||||
|
print_info "Container status:"
|
||||||
|
docker-compose ps
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {up|down|restart|logs|shell|migrate|makemigrations|createsuperuser|collectstatic|rebuild|clean|status}"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " up - Start containers in detached mode"
|
||||||
|
echo " down - Stop containers"
|
||||||
|
echo " restart - Restart containers"
|
||||||
|
echo " logs - Show logs (follow mode)"
|
||||||
|
echo " shell - Open Django shell"
|
||||||
|
echo " migrate - Run database migrations"
|
||||||
|
echo " makemigrations - Create new migrations"
|
||||||
|
echo " createsuperuser - Create Django superuser"
|
||||||
|
echo " collectstatic - Collect static files"
|
||||||
|
echo " rebuild - Rebuild containers (no cache)"
|
||||||
|
echo " clean - Remove all containers and volumes"
|
||||||
|
echo " status - Show container status"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: zonco_db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-Zoneco_ORG}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- zonco_network
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: zonco_backend
|
||||||
|
restart: unless-stopped
|
||||||
|
command: gunicorn zonco_backend.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120
|
||||||
|
volumes:
|
||||||
|
- ./media:/app/media
|
||||||
|
- ./staticfiles:/app/staticfiles
|
||||||
|
ports:
|
||||||
|
- "${DJANGO_PORT:-8000}:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-Zoneco_ORG}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- zonco_network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
zonco_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
26
entrypoint.sh
Normal file
26
entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Waiting for PostgreSQL to be ready..."
|
||||||
|
while ! pg_isready -h db -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-Zoneco_ORG}; do
|
||||||
|
echo "PostgreSQL is unavailable - sleeping"
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "PostgreSQL is up - executing command"
|
||||||
|
|
||||||
|
# Collect static files
|
||||||
|
echo "Collecting static files..."
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "Running migrations..."
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
|
# Create superuser if it doesn't exist (optional, can be removed in production)
|
||||||
|
# Uncomment and modify if needed:
|
||||||
|
# echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.filter(username='admin').exists() or User.objects.create_superuser('admin', 'admin@example.com', 'admin')" | python manage.py shell
|
||||||
|
|
||||||
|
echo "Starting application..."
|
||||||
|
exec "$@"
|
||||||
|
|
||||||
@@ -3,4 +3,5 @@ djangorestframework==3.14.0
|
|||||||
psycopg2-binary==2.9.11
|
psycopg2-binary==2.9.11
|
||||||
django-cors-headers==4.3.1
|
django-cors-headers==4.3.1
|
||||||
Pillow==12.0.0
|
Pillow==12.0.0
|
||||||
|
gunicorn==21.2.0
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -20,16 +21,24 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = 'django-insecure-ro-sqgr@$*(qao)@d&ezk*z9&%+vbeurgi$b+6y650j*$1b+n5'
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-ro-sqgr@$*(qao)@d&ezk*z9&%+vbeurgi$b+6y650j*$1b+n5')
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
# Default to False for production safety - set DEBUG=True only in development
|
||||||
|
DEBUG = os.environ.get('DEBUG', 'False') == 'True'
|
||||||
|
|
||||||
ALLOWED_HOSTS = [
|
# Allow all hosts in development mode for network access
|
||||||
'localhost',
|
# In production, ALLOWED_HOSTS MUST be set via environment variable
|
||||||
'127.0.0.1',
|
if DEBUG:
|
||||||
'185.208.172.158',
|
ALLOWED_HOSTS = ['*'] # Allow all hosts in development only
|
||||||
]
|
else:
|
||||||
|
# Production: require ALLOWED_HOSTS to be set explicitly
|
||||||
|
allowed_hosts_env = os.environ.get('ALLOWED_HOSTS')
|
||||||
|
if allowed_hosts_env:
|
||||||
|
ALLOWED_HOSTS = allowed_hosts_env.split(',')
|
||||||
|
else:
|
||||||
|
# Fallback for production (should be overridden by env var)
|
||||||
|
ALLOWED_HOSTS = ['185.208.172.158'] # Your server IP
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -86,11 +95,11 @@ WSGI_APPLICATION = 'zonco_backend.wsgi.application'
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
'NAME': 'Zoneco_ORG',
|
'NAME': os.environ.get('POSTGRES_DB', 'Zoneco_ORG'),
|
||||||
'USER': 'postgres',
|
'USER': os.environ.get('POSTGRES_USER', 'postgres'),
|
||||||
'PASSWORD': 'postgres',
|
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'postgres'),
|
||||||
'HOST': 'localhost',
|
'HOST': os.environ.get('POSTGRES_HOST', 'localhost'),
|
||||||
'PORT': '5432',
|
'PORT': os.environ.get('POSTGRES_PORT', '5432'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,14 +168,23 @@ REST_FRAMEWORK = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# CORS configuration
|
# CORS configuration
|
||||||
CORS_ALLOWED_ORIGINS = [
|
# In development, allow all origins for easier testing
|
||||||
"http://localhost:5173", # Vite default port
|
# In production, CORS_ALLOWED_ORIGINS MUST be set via environment variable
|
||||||
"http://localhost:3000",
|
if DEBUG:
|
||||||
"http://127.0.0.1:5173",
|
CORS_ALLOW_ALL_ORIGINS = True # Development only - allows all origins
|
||||||
"http://127.0.0.1:3000",
|
else:
|
||||||
"http://185.208.172.158:9123",
|
# Production: require CORS_ALLOWED_ORIGINS to be set explicitly
|
||||||
"http://185.208.172.158",
|
cors_origins_env = os.environ.get('CORS_ALLOWED_ORIGINS')
|
||||||
]
|
if cors_origins_env:
|
||||||
|
CORS_ALLOWED_ORIGINS = cors_origins_env.split(',')
|
||||||
|
else:
|
||||||
|
# Fallback for production (should be overridden by env var)
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
'http://185.208.172.158:9123',
|
||||||
|
'http://185.208.172.158',
|
||||||
|
'https://185.208.172.158:9123',
|
||||||
|
'https://185.208.172.158',
|
||||||
|
]
|
||||||
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user