From 7e71d922d355ab3f081d4aad1d547faf408d35c0 Mon Sep 17 00:00:00 2001 From: Shayan Azadi Date: Wed, 24 Jun 2026 13:43:34 +0330 Subject: [PATCH] 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 --- .dockerignore | 74 +++ API_DOCS_FA.md | 519 +++++++++++++++++++ DOCKER_README.md | 219 ++++++++ Dockerfile | 78 +++ ENV_TEMPLATE.txt | 28 + README.md | 326 ++++++++---- Zoneco_ORG_API.postman_collection.json | 589 ++++++++++++---------- api/admin.py | 10 +- api/migrations/0002_composition_images.py | 33 ++ api/models.py | 52 ++ api/serializers.py | 143 +++++- api/tests.py | 280 +++++++++- api/views.py | 113 ++++- docker-commands.sh | 122 +++++ docker-compose.yml | 59 +++ entrypoint.sh | 26 + requirements.txt | 1 + zonco_backend/settings.py | 58 ++- 18 files changed, 2332 insertions(+), 398 deletions(-) create mode 100644 .dockerignore create mode 100644 API_DOCS_FA.md create mode 100644 DOCKER_README.md create mode 100644 Dockerfile create mode 100644 ENV_TEMPLATE.txt create mode 100644 api/migrations/0002_composition_images.py create mode 100644 docker-commands.sh create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh 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