diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..5accfa4
--- /dev/null
+++ b/.dockerignore
@@ -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
+
diff --git a/API_DOCS_FA.md b/API_DOCS_FA.md
new file mode 100644
index 0000000..58b8bed
--- /dev/null
+++ b/API_DOCS_FA.md
@@ -0,0 +1,519 @@
+
+
+# مستندات 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/`
+
+
diff --git a/DOCKER_README.md b/DOCKER_README.md
new file mode 100644
index 0000000..45a01d0
--- /dev/null
+++ b/DOCKER_README.md
@@ -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
+```
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..becd37f
--- /dev/null
+++ b/Dockerfile
@@ -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"]
+
diff --git a/ENV_TEMPLATE.txt b/ENV_TEMPLATE.txt
new file mode 100644
index 0000000..0b598ce
--- /dev/null
+++ b/ENV_TEMPLATE.txt
@@ -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
+
diff --git a/README.md b/README.md
index b48f226..f82e3be 100644
--- a/README.md
+++ b/README.md
@@ -1,135 +1,261 @@
# 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
-- **Contact Us API**: Handle contact form submissions with name, email/phone, description, and category
-- **Composition API**: Manage compositions with name, description, and image
-- **Campaign API**: Manage campaigns with name, description, start/end times, and image
-- **RESTful API**: Full CRUD operations for all models
-- **CORS Enabled**: Configured for frontend integration
-- **Image Upload**: Support for image uploads with proper media handling
-- **Admin Panel**: Django admin interface for managing data
+- **Contact Us API** — Contact form submissions with category filter
+- **Composition API** — Multi-image upload, main image flag, date-range filter
+- **Campaign API** — Campaigns with active / upcoming / ended filters
+- **Admin Panel** — Full Django admin at `/admin/`
+- **Image Upload** — JPEG, PNG, WebP, GIF, max 5 MB, stored in `media/`
+- **CORS Enabled** — Configured for frontend integration
+- **Pagination** — 20 items per page
-## Database Configuration
+---
-- **Database Name**: Zoneco_ORG
-- **User**: postgres
-- **Password**: postgres
-- **Host**: localhost
-- **Port**: 5432
+## Quick Start
-## Installation
+### 1. Install dependencies
-1. **Install Python dependencies:**
- ```bash
- pip install -r requirements.txt
- ```
+```bash
+pip install -r requirements.txt
+```
-2. **Create PostgreSQL database:**
- ```sql
- CREATE DATABASE "Zoneco_ORG";
- ```
+### 2. Create PostgreSQL database
-3. **Run migrations:**
- ```bash
- python manage.py makemigrations
- python manage.py migrate
- ```
+```sql
+CREATE DATABASE "Zoneco_ORG";
+```
-4. **Create superuser (optional):**
- ```bash
- python manage.py createsuperuser
- ```
+### 3. Apply migrations
-5. **Run development server:**
- ```bash
- python manage.py runserver
- ```
+```bash
+python manage.py migrate
+```
-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
-### Contact Us
-- `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
+### Authentication
-### Compositions
-- `GET /api/compositions/` - List all compositions
-- `POST /api/compositions/` - Create new composition (authenticated)
-- `GET /api/compositions/{id}/` - Get specific composition
-- `PUT /api/compositions/{id}/` - Update composition (authenticated)
-- `PATCH /api/compositions/{id}/` - Partial update (authenticated)
-- `DELETE /api/compositions/{id}/` - Delete composition (authenticated)
+| Action | Access |
+|--------|--------|
+| GET (list, retrieve, custom filters) | Public — no login required |
+| POST (create) | Public — no login required |
+| PUT / PATCH / DELETE | Admin only — Basic Auth required |
-### Campaigns
-- `GET /api/campaigns/` - List all campaigns
-- `POST /api/campaigns/` - Create new campaign (authenticated)
-- `GET /api/campaigns/{id}/` - Get specific campaign
-- `PUT /api/campaigns/{id}/` - Update campaign (authenticated)
-- `PATCH /api/campaigns/{id}/` - Partial update (authenticated)
-- `DELETE /api/campaigns/{id}/` - Delete campaign (authenticated)
-- `GET /api/campaigns/active/` - Get active campaigns
-- `GET /api/campaigns/upcoming/` - Get upcoming campaigns
-- `GET /api/campaigns/ended/` - Get ended campaigns
+---
+
+### Contact Us — `/api/contact-us/`
+
+| Method | Endpoint | Access | Description |
+|--------|----------|--------|-------------|
+| GET | `/api/contact-us/` | Public | List all submissions |
+| POST | `/api/contact-us/` | Public | Submit a contact form |
+| GET | `/api/contact-us/{id}/` | Public | Get one submission |
+| 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
### ContactUs
-- `name` (CharField): Name of the contact
-- `email_or_phone` (CharField): Email or phone number
-- `description` (TextField): Message description
-- `category` (CharField): Category (همکاری, فروش, پشتیبانی, درخواست مشاور, سایر)
-- `created_at` (DateTimeField): Creation timestamp
-- `updated_at` (DateTimeField): Last update timestamp
+
+| Field | Type |
+|-------|------|
+| `name` | CharField |
+| `email_or_phone` | CharField |
+| `description` | TextField |
+| `category` | CharField (choices) |
+| `created_at` | DateTimeField (auto) |
+| `updated_at` | DateTimeField (auto) |
### Composition
-- `name` (CharField): Name of the composition
-- `description` (TextField): Description
-- `image` (ImageField): Composition image
-- `created_at` (DateTimeField): Creation timestamp
-- `updated_at` (DateTimeField): Last update timestamp
+
+| Field | Type |
+|-------|------|
+| `name` | CharField |
+| `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
-- `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
-Uploaded images are stored in the `media/` directory:
-- Composition images: `media/compositions/`
-- Campaign images: `media/campaigns/`
+| Content | Path |
+|---------|------|
+| Composition images | `media/compositions/` |
+| Campaign images | `media/campaigns/` |
-## Development Notes
+---
-- The backend uses Django REST Framework for API endpoints
-- 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
+## Postman Collection
+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 |
diff --git a/Zoneco_ORG_API.postman_collection.json b/Zoneco_ORG_API.postman_collection.json
index e1e4031..e9ea747 100644
--- a/Zoneco_ORG_API.postman_collection.json
+++ b/Zoneco_ORG_API.postman_collection.json
@@ -2,197 +2,29 @@
"info": {
"_postman_id": "zoneco-org-api-collection",
"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"
},
"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",
+ "description": "APIهای فرم تماس با ما",
"item": [
{
"name": "GET All Contact Us",
"request": {
"method": "GET",
"header": [],
- "url": {
- "raw": "{{base_url}}/api/contact-us/",
- "host": [
- "{{base_url}}"
- ],
- "path": [
- "api",
- "contact-us",
- ""
- ]
- },
- "description": "Get all contact form submissions"
- },
- "response": []
+ "url": "{{base_url}}/api/contact-us/"
+ }
},
{
"name": "GET Contact Us by ID",
"request": {
"method": "GET",
"header": [],
- "url": {
- "raw": "{{base_url}}/api/contact-us/1/",
- "host": [
- "{{base_url}}"
- ],
- "path": [
- "api",
- "contact-us",
- "1",
- ""
- ]
- },
- "description": "Get a specific contact submission by ID"
- },
- "response": []
+ "url": "{{base_url}}/api/contact-us/1/"
+ }
},
{
"name": "POST Create Contact Us",
@@ -206,27 +38,10 @@
],
"body": {
"mode": "raw",
- "raw": "{\n \"name\": \"احمد محمدی\",\n \"email_or_phone\": \"ahmad@example.com\",\n \"description\": \"سلام، میخواستم در مورد خدمات شما اطلاعات بیشتری دریافت کنم.\",\n \"category\": \"پشتیبانی\"\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
+ "raw": "{\n \"name\": \"احمد محمدی\",\n \"email_or_phone\": \"ahmad@example.com\",\n \"description\": \"سلام، میخواستم در مورد خدمات شما اطلاعات بیشتری دریافت کنم.\",\n \"category\": \"پشتیبانی\"\n}"
},
- "url": {
- "raw": "{{base_url}}/api/contact-us/",
- "host": [
- "{{base_url}}"
- ],
- "path": [
- "api",
- "contact-us",
- ""
- ]
- },
- "description": "Submit a new contact form"
- },
- "response": []
+ "url": "{{base_url}}/api/contact-us/"
+ }
},
{
"name": "GET Contact Us by Category",
@@ -235,110 +50,341 @@
"header": [],
"url": {
"raw": "{{base_url}}/api/contact-us/by_category/?category=پشتیبانی",
- "host": [
- "{{base_url}}"
- ],
- "path": [
- "api",
- "contact-us",
- "by_category",
- ""
- ],
+ "host": ["{{base_url}}"],
+ "path": ["api", "contact-us", "by_category", ""],
"query": [
{
"key": "category",
"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"
- },
- "response": []
+ "method": "PATCH",
+ "header": [
+ {"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",
+ "description": "APIهای ترکیبات — پشتیبانی از آپلود چند تصویر با تصویر اصلی",
"item": [
{
"name": "GET All Compositions",
"request": {
"method": "GET",
"header": [],
- "url": {
- "raw": "{{base_url}}/api/compositions/",
- "host": [
- "{{base_url}}"
- ],
- "path": [
- "api",
- "compositions",
- ""
- ]
- },
- "description": "Get all compositions"
- },
- "response": []
+ "url": "{{base_url}}/api/compositions/"
+ }
},
{
"name": "GET Composition by ID",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": "{{base_url}}/api/compositions/{{composition_id}}/"
+ }
+ },
+ {
+ "name": "GET Compositions by Created At",
"request": {
"method": "GET",
"header": [],
"url": {
- "raw": "{{base_url}}/api/compositions/1/",
- "host": [
- "{{base_url}}"
- ],
- "path": [
- "api",
- "compositions",
- "1",
- ""
+ "raw": "{{base_url}}/api/compositions/by-created-at/?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z",
+ "host": ["{{base_url}}"],
+ "path": ["api", "compositions", "by-created-at", ""],
+ "query": [
+ {
+ "key": "from",
+ "value": "2026-01-01T00:00:00Z",
+ "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": {
"method": "POST",
"header": [
- {
- "key": "Content-Type",
- "value": "application/json"
- }
+ {"key": "Content-Type", "value": "application/json"}
],
"body": {
"mode": "raw",
- "raw": "{\n \"name\": \"ترکیب تست\",\n \"description\": \"این یک ترکیب تستی است\"\n}",
- "options": {
- "raw": {
- "language": "json"
- }
- }
+ "raw": "{\n \"name\": \"ترکیب تست\",\n \"description\": \"این یک ترکیب تستی است\"\n}"
},
- "url": {
- "raw": "{{base_url}}/api/compositions/",
- "host": [
- "{{base_url}}"
- ],
- "path": [
- "api",
- "compositions",
- ""
+ "url": "{{base_url}}/api/compositions/"
+ }
+ },
+ {
+ "name": "POST Create Composition with Images",
+ "request": {
+ "method": "POST",
+ "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"
- },
- "response": []
+ "url": "{{base_url}}/api/compositions/"
+ }
+ },
+ {
+ "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": [
@@ -346,7 +392,26 @@
"key": "base_url",
"value": "http://localhost:8000",
"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"
}
]
}
-
diff --git a/api/admin.py b/api/admin.py
index 81612e8..c056e54 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import ContactUs, Composition, Campaign
+from .models import ContactUs, Composition, Campaign, CompositionImage
@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)
class CompositionAdmin(admin.ModelAdmin):
list_display = ['name', 'created_at']
search_fields = ['name', 'description']
readonly_fields = ['created_at', 'updated_at']
date_hierarchy = 'created_at'
+ inlines = [CompositionImageInline]
fieldsets = (
('اطلاعات ترکیب', {
diff --git a/api/migrations/0002_composition_images.py b/api/migrations/0002_composition_images.py
new file mode 100644
index 0000000..ad5027c
--- /dev/null
+++ b/api/migrations/0002_composition_images.py
@@ -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'),
+ ),
+ ]
diff --git a/api/models.py b/api/models.py
index 14e5d4d..2f1f7c8 100644
--- a/api/models.py
+++ b/api/models.py
@@ -49,6 +49,8 @@ class Composition(models.Model):
"""
name = models.CharField(max_length=200, 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(
upload_to='compositions/',
verbose_name='تصویر',
@@ -68,6 +70,56 @@ class Composition(models.Model):
def __str__(self):
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):
diff --git a/api/serializers.py b/api/serializers.py
index 8b5f724..2fbad47 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -1,5 +1,5 @@
from rest_framework import serializers
-from .models import ContactUs, Composition, Campaign
+from .models import ContactUs, Composition, Campaign, CompositionImage
class ContactUsSerializer(serializers.ModelSerializer):
@@ -31,17 +31,36 @@ class ContactUsSerializer(serializers.ModelSerializer):
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()
class Meta:
- model = Composition
- fields = ['id', 'name', 'description', 'image', 'image_url',
- 'created_at', 'updated_at']
- read_only_fields = ['id', 'created_at', 'updated_at', 'image_url']
+ model = CompositionImage
+ fields = ['id', 'image', 'image_url', 'is_main', 'created_at']
+ read_only_fields = ['id', 'image_url', 'created_at']
def get_image_url(self, obj):
"""Get the full URL of the image."""
@@ -51,6 +70,57 @@ class CompositionSerializer(serializers.ModelSerializer):
return request.build_absolute_uri(obj.image.url)
return obj.image.url
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):
"""Validate name field."""
@@ -63,6 +133,65 @@ class CompositionSerializer(serializers.ModelSerializer):
if not value or len(value.strip()) == 0:
raise serializers.ValidationError("Description is required.")
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):
diff --git a/api/tests.py b/api/tests.py
index 7ce503c..4307335 100644
--- a/api/tests.py
+++ b/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)
diff --git a/api/views.py b/api/views.py
index 2dd0034..93fe7f4 100644
--- a/api/views.py
+++ b/api/views.py
@@ -2,10 +2,18 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
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.utils import timezone
-from .models import ContactUs, Composition, Campaign
-from .serializers import ContactUsSerializer, CompositionSerializer, CampaignSerializer
+from django.utils.dateparse import parse_datetime
+from .models import ContactUs, Composition, Campaign, CompositionImage
+from .serializers import (
+ ContactUsSerializer,
+ CompositionSerializer,
+ CampaignSerializer,
+ CompositionImageSerializer,
+)
class ContactUsViewSet(viewsets.ModelViewSet):
@@ -21,7 +29,7 @@ class ContactUsViewSet(viewsets.ModelViewSet):
"""
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 [IsAuthenticated()]
@@ -42,17 +50,22 @@ class ContactUsViewSet(viewsets.ModelViewSet):
class CompositionViewSet(viewsets.ModelViewSet):
"""
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
permission_classes = [AllowAny] # Public read access
+ parser_classes = [MultiPartParser, FormParser, JSONParser]
def get_permissions(self):
"""
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 [IsAuthenticated()]
@@ -61,6 +74,94 @@ class CompositionViewSet(viewsets.ModelViewSet):
context = super().get_serializer_context()
context['request'] = self.request
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[^/.]+)'
+ )
+ 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):
diff --git a/docker-commands.sh b/docker-commands.sh
new file mode 100644
index 0000000..b5881d7
--- /dev/null
+++ b/docker-commands.sh
@@ -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
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f883ad9
--- /dev/null
+++ b/docker-compose.yml
@@ -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
+
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100644
index 0000000..4a240a3
--- /dev/null
+++ b/entrypoint.sh
@@ -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 "$@"
+
diff --git a/requirements.txt b/requirements.txt
index 459b893..9413b97 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,4 +3,5 @@ djangorestframework==3.14.0
psycopg2-binary==2.9.11
django-cors-headers==4.3.1
Pillow==12.0.0
+gunicorn==21.2.0
diff --git a/zonco_backend/settings.py b/zonco_backend/settings.py
index 9ffae1a..da95333 100644
--- a/zonco_backend/settings.py
+++ b/zonco_backend/settings.py
@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
"""
from pathlib import Path
+import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
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/
# 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!
-DEBUG = True
+# Default to False for production safety - set DEBUG=True only in development
+DEBUG = os.environ.get('DEBUG', 'False') == 'True'
-ALLOWED_HOSTS = [
- 'localhost',
- '127.0.0.1',
- '185.208.172.158',
-]
+# Allow all hosts in development mode for network access
+# In production, ALLOWED_HOSTS MUST be set via environment variable
+if DEBUG:
+ 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
@@ -86,11 +95,11 @@ WSGI_APPLICATION = 'zonco_backend.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
- 'NAME': 'Zoneco_ORG',
- 'USER': 'postgres',
- 'PASSWORD': 'postgres',
- 'HOST': 'localhost',
- 'PORT': '5432',
+ 'NAME': os.environ.get('POSTGRES_DB', 'Zoneco_ORG'),
+ 'USER': os.environ.get('POSTGRES_USER', 'postgres'),
+ 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'postgres'),
+ 'HOST': os.environ.get('POSTGRES_HOST', 'localhost'),
+ 'PORT': os.environ.get('POSTGRES_PORT', '5432'),
}
}
@@ -159,14 +168,23 @@ REST_FRAMEWORK = {
}
# CORS configuration
-CORS_ALLOWED_ORIGINS = [
- "http://localhost:5173", # Vite default port
- "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",
-]
+# In development, allow all origins for easier testing
+# In production, CORS_ALLOWED_ORIGINS MUST be set via environment variable
+if DEBUG:
+ CORS_ALLOW_ALL_ORIGINS = True # Development only - allows all origins
+else:
+ # Production: require CORS_ALLOWED_ORIGINS to be set explicitly
+ 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