diff --git a/API_DOCS_FA.md b/API_DOCS_FA.md
deleted file mode 100644
index 58b8bed..0000000
--- a/API_DOCS_FA.md
+++ /dev/null
@@ -1,519 +0,0 @@
-
-
-# مستندات 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
deleted file mode 100644
index 45a01d0..0000000
--- a/DOCKER_README.md
+++ /dev/null
@@ -1,219 +0,0 @@
-# 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/ENV_TEMPLATE.txt b/ENV_TEMPLATE.txt
index 0b598ce..d5a0fa9 100644
--- a/ENV_TEMPLATE.txt
+++ b/ENV_TEMPLATE.txt
@@ -4,25 +4,34 @@
# 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())"
+# 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
+
+# Comma-separated hostnames/IPs for this server (no hardcoded defaults in code)
+# Example: ALLOWED_HOSTS=YOUR_SERVER_IP,your-domain.com
+ALLOWED_HOSTS=127.0.0.1,localhost
# Database Configuration
POSTGRES_DB=Zoneco_ORG
POSTGRES_USER=postgres
-POSTGRES_PASSWORD=postgres
+POSTGRES_PASSWORD=change-me
POSTGRES_HOST=db
POSTGRES_PORT=5432
-DATABASE_URL=postgresql://postgres:postgres@db:5432/Zoneco_ORG
+DATABASE_URL=postgresql://postgres:change-me@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
+# CORS Settings (comma-separated origins)
+# Example: CORS_ALLOWED_ORIGINS=http://YOUR_SERVER_IP:9123,https://your-frontend.com
+CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
+
+# Admin bootstrap (used by Docker entrypoint on server)
+# Leave empty locally if you create the user manually
+ADMIN_USERNAME=
+ADMIN_PASSWORD=
# Gunicorn Settings (optional)
GUNICORN_WORKERS=3
GUNICORN_TIMEOUT=120
-
diff --git a/README.md b/README.md
index f82e3be..e493159 100644
--- a/README.md
+++ b/README.md
@@ -7,11 +7,12 @@ A Django REST Framework backend for Zoneco ORG with PostgreSQL database.
## Test Status
```
-Ran 24 tests in ~12s — OK (0 failures)
+Ran 31 tests — OK (0 failures)
```
| Group | Tests | Status |
|-------|-------|--------|
+| Admin Login | 7 | ✅ All pass |
| Contact Us | 6 | ✅ All pass |
| Compositions | 11 | ✅ All pass |
| Campaigns | 7 | ✅ All pass |
@@ -26,6 +27,7 @@ python manage.py test api -v 2
## Features
+- **Admin Login API** — Token-based login with username/password
- **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
@@ -68,23 +70,93 @@ API available at `{{base_url}}/api/`
## Admin Credentials
-| Field | Value |
-|-------|-------|
-| URL | `{{base_url}}/admin/` |
-| Username | `Zoneco_@1405` |
-| Password | `Fun_@_zone2026` |
+Set these in the server `.env` (never hardcode in code):
+
+| Env var | Purpose |
+|---------|---------|
+| `ADMIN_USERNAME` | Admin username for Docker bootstrap |
+| `ADMIN_PASSWORD` | Admin password for Docker bootstrap |
+
+| Endpoint | Path |
+|----------|------|
+| Django Admin Panel | `{{base_url}}/admin/` |
+| API Login | `POST {{base_url}}/api/admin/login/` |
+
+On Docker deploy, `entrypoint.sh` creates/updates the admin from `ADMIN_USERNAME` / `ADMIN_PASSWORD`.
+
+---
+
+## Admin Login API
+
+### Login
+
+```
+POST {{base_url}}/admin/login/
+Content-Type: application/json
+```
+
+> In Postman, set `base_url` to `https://zoneco.org/api` (includes `/api`).
+
+```json
+{
+ "username": "{{admin_username}}",
+ "password": "{{admin_password}}"
+}
+```
+
+**Response includes the admin token:**
+```json
+{
+ "token": "",
+ "user": {
+ "id": 1,
+ "username": "",
+ "is_staff": true,
+ "is_superuser": true
+ }
+}
+```
+
+### Use the token on protected endpoints
+
+```
+Authorization: Token
+```
+
+### Current admin user
+
+```
+GET {{base_url}}/admin/me/
+Authorization: Token
+```
+
+### Logout (deletes the token)
+
+```
+POST {{base_url}}/admin/logout/
+Authorization: Token
+```
---
## API Endpoints
-### Authentication
+### Authentication / Permissions
+
+| Who | Allowed |
+|-----|---------|
+| Public (user) | Submit contact, view own contacts (`/contact-us/mine/`), view campaigns & compositions (مقالات) |
+| Admin only | List all contacts, reply to contacts, create/edit/delete campaigns & compositions |
| 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 |
+| GET list/retrieve campaigns & compositions | Public |
+| POST create campaigns & compositions | Admin — Token required |
+| PUT / PATCH / DELETE | Admin — Token required |
+| Contact create | Public |
+| Contact list / by_category / reply | Admin — Token required |
+| Contact mine (`?email_or_phone=`) | Public |
+| Admin login / logout / me | See Admin Login API above |
---
@@ -237,15 +309,9 @@ DB constraint: only one `is_main=True` per composition.
## Postman Collection
-Import `backend/Zoneco_ORG_API.postman_collection.json` into Postman.
+Import `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`
+Set collection variables: `base_url`, `admin_username`, `admin_password`.
---
diff --git a/Zoneco_ORG_API.postman_collection.json b/Zoneco_ORG_API.postman_collection.json
index e9ea747..39c8c8d 100644
--- a/Zoneco_ORG_API.postman_collection.json
+++ b/Zoneco_ORG_API.postman_collection.json
@@ -2,20 +2,162 @@
"info": {
"_postman_id": "zoneco-org-api-collection",
"name": "Zoneco ORG API",
- "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 استفاده کنید.",
+ "description": "Zoneco ORG API\n\nbase_url must be: https://zoneco.org/api\n\n1) Set admin_username and admin_password variables\n2) Run Admin Auth > POST Admin Login\n3) Token is saved to admin_token and shown in response body\n4) Admin requests send: Authorization: Token {{admin_token}}\n\nPermissions:\n- Public: create contact, mine contacts, view campaigns & compositions\n- Admin only: list all contacts, create/edit campaigns & compositions",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
+ {
+ "name": "Admin Auth",
+ "description": "ورود ادمین با username/password و دریافت توکن",
+ "item": [
+ {
+ "name": "POST Admin Login",
+ "event": [
+ {
+ "listen": "test",
+ "script": {
+ "type": "text/javascript",
+ "exec": [
+ "if (pm.response.code === 200) {",
+ " var json = pm.response.json();",
+ " if (json.token) {",
+ " pm.collectionVariables.set('admin_token', json.token);",
+ " console.log('admin_token saved');",
+ " }",
+ "}"
+ ]
+ }
+ }
+ ],
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"username\": \"{{admin_username}}\",\n \"password\": \"{{admin_password}}\"\n}"
+ },
+ "url": "{{base_url}}/admin/login/",
+ "description": "Login. Response includes token. Also saved to collection variable admin_token."
+ }
+ },
+ {
+ "name": "GET Admin Me (shows token works)",
+ "request": {
+ "auth": {
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "GET",
+ "header": [],
+ "url": "{{base_url}}/admin/me/"
+ }
+ },
+ {
+ "name": "POST Admin Logout",
+ "request": {
+ "auth": {
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
+ ]
+ },
+ "method": "POST",
+ "header": [],
+ "url": "{{base_url}}/admin/logout/"
+ }
+ }
+ ]
+ },
{
"name": "Contact Us",
"description": "APIهای فرم تماس با ما",
"item": [
{
- "name": "GET All Contact Us",
+ "name": "GET My Contact Us (Public)",
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/contact-us/"
+ "url": {
+ "raw": "{{base_url}}/contact-us/mine/?email_or_phone={{my_email_or_phone}}",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "contact-us",
+ "mine",
+ ""
+ ],
+ "query": [
+ {
+ "key": "email_or_phone",
+ "value": "{{my_email_or_phone}}",
+ "description": "Your email or phone used when submitting the form"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "name": "GET All Contact Us (Admin)",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": "{{base_url}}/contact-us/",
+ "auth": {
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
+ ]
+ }
}
},
{
@@ -23,7 +165,7 @@
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/contact-us/1/"
+ "url": "{{base_url}}/contact-us/1/"
}
},
{
@@ -40,18 +182,24 @@
"mode": "raw",
"raw": "{\n \"name\": \"احمد محمدی\",\n \"email_or_phone\": \"ahmad@example.com\",\n \"description\": \"سلام، میخواستم در مورد خدمات شما اطلاعات بیشتری دریافت کنم.\",\n \"category\": \"پشتیبانی\"\n}"
},
- "url": "{{base_url}}/api/contact-us/"
+ "url": "{{base_url}}/contact-us/"
}
},
{
- "name": "GET Contact Us by Category",
+ "name": "GET Contact Us by Category (Admin)",
"request": {
"method": "GET",
"header": [],
"url": {
- "raw": "{{base_url}}/api/contact-us/by_category/?category=پشتیبانی",
- "host": ["{{base_url}}"],
- "path": ["api", "contact-us", "by_category", ""],
+ "raw": "{{base_url}}/contact-us/by_category/?category=پشتیبانی",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "contact-us",
+ "by_category",
+ ""
+ ],
"query": [
{
"key": "category",
@@ -59,6 +207,26 @@
"description": "همکاری | فروش | پشتیبانی | درخواست مشاور | سایر"
}
]
+ },
+ "auth": {
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
+ ]
}
}
},
@@ -66,36 +234,65 @@
"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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "PATCH",
"header": [
- {"key": "Content-Type", "value": "application/json"}
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
],
"body": {
"mode": "raw",
- "raw": "{\n \"name\": \"نام بروزرسانی شده\"\n}"
+ "raw": "{\n \"admin_response\": \"پاسخ ادمین به پیام شما\"\n}"
},
- "url": "{{base_url}}/api/contact-us/1/"
+ "url": "{{base_url}}/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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "DELETE",
"header": [],
- "url": "{{base_url}}/api/contact-us/1/"
+ "url": "{{base_url}}/contact-us/1/"
}
}
]
@@ -109,7 +306,7 @@
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/compositions/"
+ "url": "{{base_url}}/compositions/"
}
},
{
@@ -117,18 +314,24 @@
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/compositions/{{composition_id}}/"
+ "url": "{{base_url}}/compositions/{{composition_id}}/"
}
},
- {
+ {
"name": "GET Compositions by Created At",
"request": {
"method": "GET",
"header": [],
"url": {
- "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", ""],
+ "raw": "{{base_url}}/compositions/by-created-at/?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "compositions",
+ "by-created-at",
+ ""
+ ],
"query": [
{
"key": "from",
@@ -145,45 +348,122 @@
}
},
{
- "name": "POST Create Composition (JSON)",
+ "name": "POST Create Composition (JSON) (Admin)",
"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}"
},
- "url": "{{base_url}}/api/compositions/"
+ "url": "{{base_url}}/compositions/",
+ "auth": {
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
+ ]
+ }
}
},
{
- "name": "POST Create Composition with Images",
+ "name": "POST Create Composition with Images (Admin)",
"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)"}
+ {
+ "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)"
+ }
]
},
- "url": "{{base_url}}/api/compositions/"
+ "url": "{{base_url}}/compositions/",
+ "auth": {
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
+ ]
+ }
}
},
{
"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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "POST",
@@ -191,83 +471,149 @@
"body": {
"mode": "formdata",
"formdata": [
- {"key": "uploaded_images", "type": "file", "src": []},
- {"key": "main_image_index", "value": "0", "type": "text"}
+ {
+ "key": "uploaded_images",
+ "type": "file",
+ "src": []
+ },
+ {
+ "key": "main_image_index",
+ "value": "0",
+ "type": "text"
+ }
]
},
- "url": "{{base_url}}/api/compositions/{{composition_id}}/add-images/"
+ "url": "{{base_url}}/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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "POST",
"header": [
- {"key": "Content-Type", "value": "application/json"}
+ {
+ "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/"
+ "url": "{{base_url}}/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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "DELETE",
"header": [],
- "url": "{{base_url}}/api/compositions/{{composition_id}}/images/{{image_id}}/"
+ "url": "{{base_url}}/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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "PATCH",
"header": [
- {"key": "Content-Type", "value": "application/json"}
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"نام بروزرسانی شده\"\n}"
},
- "url": "{{base_url}}/api/compositions/{{composition_id}}/"
+ "url": "{{base_url}}/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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "DELETE",
"header": [],
- "url": "{{base_url}}/api/compositions/{{composition_id}}/"
+ "url": "{{base_url}}/compositions/{{composition_id}}/"
}
}
]
@@ -281,7 +627,7 @@
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/campaigns/"
+ "url": "{{base_url}}/campaigns/"
}
},
{
@@ -289,39 +635,102 @@
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/campaigns/1/"
+ "url": "{{base_url}}/campaigns/1/"
}
},
{
- "name": "POST Create Campaign",
+ "name": "POST Create Campaign (Admin)",
"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 \"start_time\": \"2026-06-01T00:00:00Z\",\n \"end_time\": \"2026-12-31T23:59:59Z\"\n}"
},
- "url": "{{base_url}}/api/campaigns/"
+ "url": "{{base_url}}/campaigns/",
+ "auth": {
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
+ ]
+ }
}
},
{
- "name": "POST Create Campaign with Image",
+ "name": "POST Create Campaign with Image (Admin)",
"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": []}
+ {
+ "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/"
+ "url": "{{base_url}}/campaigns/",
+ "auth": {
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
+ ]
+ }
}
},
{
@@ -329,7 +738,7 @@
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/campaigns/active/"
+ "url": "{{base_url}}/campaigns/active/"
}
},
{
@@ -337,7 +746,7 @@
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/campaigns/upcoming/"
+ "url": "{{base_url}}/campaigns/upcoming/"
}
},
{
@@ -345,43 +754,72 @@
"request": {
"method": "GET",
"header": [],
- "url": "{{base_url}}/api/campaigns/ended/"
+ "url": "{{base_url}}/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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "PATCH",
"header": [
- {"key": "Content-Type", "value": "application/json"}
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"نام بروزرسانی شده\"\n}"
},
- "url": "{{base_url}}/api/campaigns/1/"
+ "url": "{{base_url}}/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"}
+ "type": "apikey",
+ "apikey": [
+ {
+ "key": "key",
+ "value": "Authorization",
+ "type": "string"
+ },
+ {
+ "key": "value",
+ "value": "Token {{admin_token}}",
+ "type": "string"
+ },
+ {
+ "key": "in",
+ "value": "header",
+ "type": "string"
+ }
]
},
"method": "DELETE",
"header": [],
- "url": "{{base_url}}/api/campaigns/1/"
+ "url": "{{base_url}}/campaigns/1/"
}
}
]
@@ -390,7 +828,7 @@
"variable": [
{
"key": "base_url",
- "value": "http://localhost:8000",
+ "value": "https://zoneco.org/api",
"type": "string"
},
{
@@ -405,13 +843,23 @@
},
{
"key": "admin_username",
- "value": "Zoneco_@1405",
+ "value": "",
"type": "string"
},
{
"key": "admin_password",
- "value": "Fun_@_zone2026",
+ "value": "",
+ "type": "string"
+ },
+ {
+ "key": "admin_token",
+ "value": "",
+ "type": "string"
+ },
+ {
+ "key": "my_email_or_phone",
+ "value": "ali@example.com",
"type": "string"
}
]
-}
+}
\ No newline at end of file
diff --git a/api/admin.py b/api/admin.py
index c056e54..16c24b6 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -4,9 +4,9 @@ from .models import ContactUs, Composition, Campaign, CompositionImage
@admin.register(ContactUs)
class ContactUsAdmin(admin.ModelAdmin):
- list_display = ['name', 'email_or_phone', 'category', 'created_at']
+ list_display = ['name', 'email_or_phone', 'category', 'has_admin_response', 'created_at']
list_filter = ['category', 'created_at']
- search_fields = ['name', 'email_or_phone', 'description']
+ search_fields = ['name', 'email_or_phone', 'description', 'admin_response']
readonly_fields = ['created_at', 'updated_at']
date_hierarchy = 'created_at'
@@ -17,12 +17,19 @@ class ContactUsAdmin(admin.ModelAdmin):
('پیام', {
'fields': ('description',)
}),
+ ('پاسخ ادمین', {
+ 'fields': ('admin_response',)
+ }),
('اطلاعات زمانی', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
+ @admin.display(boolean=True, description='پاسخ ادمین')
+ def has_admin_response(self, obj):
+ return bool(obj.admin_response and obj.admin_response.strip())
+
class CompositionImageInline(admin.TabularInline):
model = CompositionImage
diff --git a/api/migrations/0003_contact_admin_response.py b/api/migrations/0003_contact_admin_response.py
new file mode 100644
index 0000000..e91eb40
--- /dev/null
+++ b/api/migrations/0003_contact_admin_response.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.7 on 2026-07-22 19:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0002_composition_images'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='contactus',
+ name='admin_response',
+ field=models.TextField(blank=True, default='', help_text='Admin reply visible to the contact submitter', verbose_name='پاسخ ادمین'),
+ ),
+ migrations.AddIndex(
+ model_name='contactus',
+ index=models.Index(fields=['email_or_phone'], name='api_contact_email_o_fedc42_idx'),
+ ),
+ ]
diff --git a/api/models.py b/api/models.py
index 2f1f7c8..a5499d2 100644
--- a/api/models.py
+++ b/api/models.py
@@ -27,6 +27,12 @@ class ContactUs(models.Model):
choices=CATEGORY_CHOICES,
verbose_name='دستهبندی'
)
+ admin_response = models.TextField(
+ blank=True,
+ default='',
+ verbose_name='پاسخ ادمین',
+ help_text='Admin reply visible to the contact submitter',
+ )
created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')
updated_at = models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی')
@@ -37,6 +43,7 @@ class ContactUs(models.Model):
indexes = [
models.Index(fields=['-created_at']),
models.Index(fields=['category']),
+ models.Index(fields=['email_or_phone']),
]
def __str__(self):
diff --git a/api/serializers.py b/api/serializers.py
index 2fbad47..449a436 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -1,15 +1,45 @@
from rest_framework import serializers
+from django.contrib.auth import authenticate
from .models import ContactUs, Composition, Campaign, CompositionImage
+class AdminLoginSerializer(serializers.Serializer):
+ """Serializer for admin username/password login."""
+ username = serializers.CharField(required=True)
+ password = serializers.CharField(required=True, write_only=True)
+
+ def validate(self, data):
+ username = data.get('username', '').strip()
+ password = data.get('password', '')
+
+ if not username or not password:
+ raise serializers.ValidationError('Username and password are required.')
+
+ user = authenticate(username=username, password=password)
+ if user is None:
+ raise serializers.ValidationError('Invalid username or password.')
+ if not user.is_active:
+ raise serializers.ValidationError('This account is disabled.')
+ if not (user.is_staff or user.is_superuser):
+ raise serializers.ValidationError(
+ 'This account does not have admin access.'
+ )
+
+ data['user'] = user
+ return data
+
+
class ContactUsSerializer(serializers.ModelSerializer):
"""
Serializer for ContactUs model.
+ Public create cannot set admin_response (enforced in the viewset).
"""
class Meta:
model = ContactUs
- fields = ['id', 'name', 'email_or_phone', 'description', 'category',
- 'created_at', 'updated_at']
+ fields = [
+ 'id', 'name', 'email_or_phone', 'description', 'category',
+ 'admin_response', 'created_at', 'updated_at',
+ ]
read_only_fields = ['id', 'created_at', 'updated_at']
def validate_email_or_phone(self, value):
diff --git a/api/tests.py b/api/tests.py
index 4307335..407f53a 100644
--- a/api/tests.py
+++ b/api/tests.py
@@ -6,13 +6,13 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from PIL import Image
from rest_framework import status
+from rest_framework.authtoken.models import Token
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)
@@ -21,6 +21,14 @@ def make_test_image(name='test.jpg', color='red', size=(100, 100)):
class ContactUsAPITests(APITestCase):
def setUp(self):
+ User = get_user_model()
+ self.admin = User.objects.create_user(
+ username='admin_test',
+ password='test_admin_pass_123',
+ is_staff=True,
+ is_superuser=True,
+ )
+ self.token = Token.objects.create(user=self.admin)
self.contact = ContactUs.objects.create(
name='Ali Reza',
email_or_phone='ali@example.com',
@@ -28,12 +36,20 @@ class ContactUsAPITests(APITestCase):
category='پشتیبانی',
)
- def test_list_contacts(self):
+ def test_list_contacts_requires_admin(self):
+ response = self.client.get('/api/contact-us/')
+ self.assertIn(response.status_code, (
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ))
+
+ def test_list_contacts_as_admin(self):
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
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):
+ def test_create_contact_public(self):
payload = {
'name': 'Sara',
'email_or_phone': '09121234567',
@@ -43,66 +59,101 @@ class ContactUsAPITests(APITestCase):
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)
+ self.assertEqual(response.data.get('admin_response'), '')
- 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_public_cannot_set_admin_response_on_create(self):
+ payload = {
+ 'name': 'Sara',
+ 'email_or_phone': 'sara@example.com',
+ 'description': 'Hello',
+ 'category': 'سایر',
+ 'admin_response': 'should be ignored',
+ }
+ response = self.client.post('/api/contact-us/', payload, format='json')
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ contact = ContactUs.objects.get(pk=response.data['id'])
+ self.assertEqual(contact.admin_response, '')
- def test_by_category(self):
- response = self.client.get('/api/contact-us/by_category/?category=پشتیبانی')
+ def test_mine_contacts(self):
+ ContactUs.objects.create(
+ name='Other',
+ email_or_phone='other@example.com',
+ description='x',
+ category='سایر',
+ )
+ response = self.client.get(
+ '/api/contact-us/mine/?email_or_phone=ali@example.com'
+ )
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
+ self.assertEqual(response.data[0]['email_or_phone'], 'ali@example.com')
- def test_by_category_missing_param(self):
- response = self.client.get('/api/contact-us/by_category/')
+ def test_mine_requires_email_or_phone(self):
+ response = self.client.get('/api/contact-us/mine/')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
- def test_update_requires_auth(self):
+ def test_by_category_requires_admin(self):
+ response = self.client.get('/api/contact-us/by_category/?category=پشتیبانی')
+ self.assertIn(response.status_code, (
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ))
+
+ def test_admin_can_reply(self):
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
response = self.client.patch(
f'/api/contact-us/{self.contact.id}/',
- {'name': 'Updated'},
+ {'admin_response': 'We will help you.'},
format='json',
)
- self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data['admin_response'], 'We will help you.')
class CompositionAPITests(APITestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(
- username='testadmin', password='testpass123', is_staff=True
+ username='admin_test',
+ password='test_admin_pass_123',
+ is_staff=True,
)
+ self.token = Token.objects.create(user=self.admin)
self.composition = Composition.objects.create(
name='Test Composition',
description='Test description',
)
- def test_list_compositions(self):
+ def test_list_compositions_public(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):
+ def test_create_composition_requires_admin(self):
+ payload = {'name': 'New Comp', 'description': 'Desc'}
+ response = self.client.post('/api/compositions/', payload, format='json')
+ self.assertIn(response.status_code, (
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ))
+
+ def test_create_composition_as_admin(self):
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
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):
+ def test_retrieve_composition_public(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):
+ def test_by_created_at_public(self):
now = timezone.now()
- Composition.objects.create(
- name='Old Composition',
- description='Old',
- )
+ Composition.objects.create(name='Old Composition', description='Old')
Composition.objects.filter(name='Old Composition').update(
created_at=now - timedelta(days=10)
)
@@ -115,17 +166,8 @@ class CompositionAPITests(APITestCase):
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):
+ def test_create_with_multiple_images_as_admin(self):
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
img1 = make_test_image('img1.jpg', 'red')
img2 = make_test_image('img2.jpg', 'blue')
response = self.client.post(
@@ -142,12 +184,8 @@ class CompositionAPITests(APITestCase):
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)
+ def test_add_images_as_admin(self):
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
img = make_test_image('added.jpg', 'green')
response = self.client.post(
f'/api/compositions/{self.composition.id}/add-images/',
@@ -156,10 +194,9 @@ class CompositionAPITests(APITestCase):
)
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)
+ def test_set_main_image_as_admin(self):
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
img1 = CompositionImage.objects.create(
composition=self.composition,
image=make_test_image('a.jpg'),
@@ -181,8 +218,8 @@ class CompositionAPITests(APITestCase):
self.assertFalse(img1.is_main)
self.assertTrue(img2.is_main)
- def test_delete_image(self):
- self.client.force_authenticate(user=self.admin)
+ def test_delete_image_as_admin(self):
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
img = CompositionImage.objects.create(
composition=self.composition,
image=make_test_image('del.jpg'),
@@ -194,17 +231,16 @@ class CompositionAPITests(APITestCase):
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):
+ User = get_user_model()
+ self.admin = User.objects.create_user(
+ username='admin_test',
+ password='test_admin_pass_123',
+ is_staff=True,
+ )
+ self.token = Token.objects.create(user=self.admin)
now = timezone.now()
self.active = Campaign.objects.create(
name='Active Campaign',
@@ -225,12 +261,27 @@ class CampaignAPITests(APITestCase):
end_time=now - timedelta(days=5),
)
- def test_list_campaigns(self):
+ def test_list_campaigns_public(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):
+ def test_create_campaign_requires_admin(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.assertIn(response.status_code, (
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ))
+
+ def test_create_campaign_as_admin(self):
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}')
now = timezone.now()
payload = {
'name': 'New Campaign',
@@ -241,19 +292,16 @@ class CampaignAPITests(APITestCase):
response = self.client.post('/api/campaigns/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
- def test_retrieve_campaign(self):
+ def test_retrieve_campaign_public(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/')
@@ -267,13 +315,74 @@ class CampaignAPITests(APITestCase):
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')
+
+class AdminLoginAPITests(APITestCase):
+ def setUp(self):
+ User = get_user_model()
+ self.admin_username = 'admin_test'
+ self.admin_password = 'test_admin_pass_123'
+ self.admin = User.objects.create_user(
+ username=self.admin_username,
+ password=self.admin_password,
+ is_staff=True,
+ is_superuser=True,
+ )
+ self.regular = User.objects.create_user(
+ username='normaluser',
+ password='normalpass123',
+ is_staff=False,
+ )
+
+ def test_admin_login_success(self):
+ response = self.client.post(
+ '/api/admin/login/',
+ {'username': self.admin_username, 'password': self.admin_password},
+ format='json',
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertIn('token', response.data)
+ self.assertEqual(response.data['user']['username'], self.admin_username)
+
+ def test_admin_login_wrong_password(self):
+ response = self.client.post(
+ '/api/admin/login/',
+ {'username': self.admin_username, 'password': 'wrong'},
+ format='json',
+ )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_non_staff_cannot_login(self):
+ response = self.client.post(
+ '/api/admin/login/',
+ {'username': 'normaluser', 'password': 'normalpass123'},
+ format='json',
+ )
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_admin_me_with_token(self):
+ login = self.client.post(
+ '/api/admin/login/',
+ {'username': self.admin_username, 'password': self.admin_password},
+ format='json',
+ )
+ token = login.data['token']
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {token}')
+ response = self.client.get('/api/admin/me/')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data['username'], self.admin_username)
+
+ def test_admin_logout(self):
+ login = self.client.post(
+ '/api/admin/login/',
+ {'username': self.admin_username, 'password': self.admin_password},
+ format='json',
+ )
+ token = login.data['token']
+ self.client.credentials(HTTP_AUTHORIZATION=f'Token {token}')
+ response = self.client.post('/api/admin/logout/')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ response = self.client.get('/api/admin/me/')
+ self.assertIn(response.status_code, (
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ ))
diff --git a/api/urls.py b/api/urls.py
index 8a44b04..7ee0599 100644
--- a/api/urls.py
+++ b/api/urls.py
@@ -1,6 +1,13 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
-from .views import ContactUsViewSet, CompositionViewSet, CampaignViewSet
+from .views import (
+ ContactUsViewSet,
+ CompositionViewSet,
+ CampaignViewSet,
+ admin_login,
+ admin_logout,
+ admin_me,
+)
router = DefaultRouter()
router.register(r'contact-us', ContactUsViewSet, basename='contact-us')
@@ -8,6 +15,8 @@ router.register(r'compositions', CompositionViewSet, basename='composition')
router.register(r'campaigns', CampaignViewSet, basename='campaign')
urlpatterns = [
+ path('admin/login/', admin_login, name='admin-login'),
+ path('admin/logout/', admin_logout, name='admin-logout'),
+ path('admin/me/', admin_me, name='admin-me'),
path('', include(router.urls)),
]
-
diff --git a/api/views.py b/api/views.py
index 93fe7f4..ddb3415 100644
--- a/api/views.py
+++ b/api/views.py
@@ -1,10 +1,10 @@
from rest_framework import viewsets, status
-from rest_framework.decorators import action
+from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
-from rest_framework.permissions import AllowAny, IsAuthenticated
+from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
+from rest_framework.authtoken.models import Token
from django.shortcuts import get_object_or_404
-from django.db.models import Q
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from .models import ContactUs, Composition, Campaign, CompositionImage
@@ -12,30 +12,99 @@ from .serializers import (
ContactUsSerializer,
CompositionSerializer,
CampaignSerializer,
- CompositionImageSerializer,
+ AdminLoginSerializer,
)
+@api_view(['POST'])
+@permission_classes([AllowAny])
+def admin_login(request):
+ """
+ Admin login with username and password.
+
+ Returns an auth token to use as:
+ Authorization: Token
+ """
+ serializer = AdminLoginSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ user = serializer.validated_data['user']
+ token, _ = Token.objects.get_or_create(user=user)
+ return Response({
+ 'token': token.key,
+ 'user': {
+ 'id': user.id,
+ 'username': user.username,
+ 'is_staff': user.is_staff,
+ 'is_superuser': user.is_superuser,
+ },
+ })
+
+
+@api_view(['POST'])
+@permission_classes([IsAdminUser])
+def admin_logout(request):
+ """Delete the current admin auth token (logout)."""
+ Token.objects.filter(user=request.user).delete()
+ return Response({'detail': 'Logged out successfully.'})
+
+
+@api_view(['GET'])
+@permission_classes([IsAdminUser])
+def admin_me(request):
+ """Return the currently authenticated admin user."""
+ user = request.user
+ return Response({
+ 'id': user.id,
+ 'username': user.username,
+ 'is_staff': user.is_staff,
+ 'is_superuser': user.is_superuser,
+ 'is_active': user.is_active,
+ })
+
+
class ContactUsViewSet(viewsets.ModelViewSet):
"""
- ViewSet for ContactUs model.
- Provides CRUD operations for contact form submissions.
+ Contact Us permissions:
+ - Public: create a message, and view own messages via /mine/
+ - Admin: list all, retrieve, update (including admin_response), delete
"""
queryset = ContactUs.objects.all()
serializer_class = ContactUsSerializer
- permission_classes = [AllowAny] # Allow anyone to submit contact forms
-
+ permission_classes = [IsAdminUser]
+
def get_permissions(self):
- """
- Override to allow GET (list, retrieve) and POST (create) for anyone.
- """
- if self.action in ['list', 'retrieve', 'create', 'by_category']:
+ if self.action in ['create', 'mine']:
return [AllowAny()]
- return [IsAuthenticated()]
-
+ return [IsAdminUser()]
+
+ def get_serializer(self, *args, **kwargs):
+ serializer = super().get_serializer(*args, **kwargs)
+ # Public create cannot set admin_response
+ if self.action == 'create' and not (
+ self.request.user and self.request.user.is_staff
+ ):
+ serializer.fields['admin_response'].read_only = True
+ return serializer
+
+ @action(detail=False, methods=['get'])
+ def mine(self, request):
+ """
+ Public: list contacts for a given email_or_phone (own submissions),
+ including admin_response.
+ """
+ email_or_phone = (request.query_params.get('email_or_phone') or '').strip()
+ if not email_or_phone:
+ return Response(
+ {'error': 'email_or_phone query parameter is required.'},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ contacts = self.queryset.filter(email_or_phone__iexact=email_or_phone)
+ serializer = self.get_serializer(contacts, many=True)
+ return Response(serializer.data)
+
@action(detail=False, methods=['get'])
def by_category(self, request):
- """Get contacts filtered by category."""
+ """Admin: get contacts filtered by category."""
category = request.query_params.get('category', None)
if category:
contacts = self.queryset.filter(category=category)
@@ -49,35 +118,28 @@ class ContactUsViewSet(viewsets.ModelViewSet):
class CompositionViewSet(viewsets.ModelViewSet):
"""
- ViewSet for Composition model.
-
- 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.
+ Compositions (مقالات):
+ - Public: list, retrieve, by-created-at
+ - Admin: create, update, delete, manage images
"""
queryset = Composition.objects.prefetch_related('images').all()
serializer_class = CompositionSerializer
- permission_classes = [AllowAny] # Public read access
+ permission_classes = [IsAdminUser]
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', 'by_created_at']:
+ if self.action in ['list', 'retrieve', 'by_created_at']:
return [AllowAny()]
- return [IsAuthenticated()]
-
+ return [IsAdminUser()]
+
def get_serializer_context(self):
- """Add request to serializer context for image URL generation."""
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."""
+ """Public: compositions filtered by created_at date range."""
from_param = request.query_params.get('from')
to_param = request.query_params.get('to')
@@ -113,17 +175,16 @@ class CompositionViewSet(viewsets.ModelViewSet):
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."""
+ """Admin: add one or more images to an existing composition."""
composition = self.get_object()
serializer = self.get_serializer(
composition, data=request.data, partial=True
@@ -131,10 +192,10 @@ class CompositionViewSet(viewsets.ModelViewSet):
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."""
+ """Admin: flag one image as the main image."""
composition = self.get_object()
image_id = request.data.get('image_id')
if image_id is None:
@@ -146,16 +207,16 @@ class CompositionViewSet(viewsets.ModelViewSet):
CompositionImage, pk=image_id, composition=composition
)
image.is_main = True
- image.save() # model.save() unsets is_main on the other images
+ image.save()
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."""
+ """Admin: delete a single image from the composition."""
composition = self.get_object()
image = get_object_or_404(
CompositionImage, pk=image_id, composition=composition
@@ -166,30 +227,26 @@ class CompositionViewSet(viewsets.ModelViewSet):
class CampaignViewSet(viewsets.ModelViewSet):
"""
- ViewSet for Campaign model.
- Provides CRUD operations for campaigns.
+ Campaigns:
+ - Public: list, retrieve, active, upcoming, ended
+ - Admin: create, update, delete
"""
queryset = Campaign.objects.all()
serializer_class = CampaignSerializer
- permission_classes = [AllowAny] # Public read access
-
+ permission_classes = [IsAdminUser]
+
def get_permissions(self):
- """
- Override to allow GET (list, retrieve) and POST (create) for anyone.
- """
- if self.action in ['list', 'retrieve', 'create', 'active', 'upcoming', 'ended']:
+ if self.action in ['list', 'retrieve', 'active', 'upcoming', 'ended']:
return [AllowAny()]
- return [IsAuthenticated()]
-
+ return [IsAdminUser()]
+
def get_serializer_context(self):
- """Add request to serializer context for image URL generation."""
context = super().get_serializer_context()
context['request'] = self.request
return context
-
+
@action(detail=False, methods=['get'])
def active(self, request):
- """Get all currently active campaigns."""
now = timezone.now()
active_campaigns = self.queryset.filter(
start_time__lte=now,
@@ -197,18 +254,16 @@ class CampaignViewSet(viewsets.ModelViewSet):
)
serializer = self.get_serializer(active_campaigns, many=True)
return Response(serializer.data)
-
+
@action(detail=False, methods=['get'])
def upcoming(self, request):
- """Get all upcoming campaigns."""
now = timezone.now()
upcoming_campaigns = self.queryset.filter(start_time__gt=now)
serializer = self.get_serializer(upcoming_campaigns, many=True)
return Response(serializer.data)
-
+
@action(detail=False, methods=['get'])
def ended(self, request):
- """Get all ended campaigns."""
now = timezone.now()
ended_campaigns = self.queryset.filter(end_time__lt=now)
serializer = self.get_serializer(ended_campaigns, many=True)
diff --git a/docker-commands.sh b/docker-commands.sh
index b5881d7..96c8cdd 100644
--- a/docker-commands.sh
+++ b/docker-commands.sh
@@ -28,14 +28,16 @@ if [ ! -f .env ]; then
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
+ALLOWED_HOSTS=localhost,127.0.0.1,YOUR_SERVER_IP
POSTGRES_DB=Zoneco_ORG
POSTGRES_USER=postgres
-POSTGRES_PASSWORD=postgres
+POSTGRES_PASSWORD=change-me
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
+ADMIN_USERNAME=
+ADMIN_PASSWORD=
+CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://YOUR_SERVER_IP:9123
EOF
print_info ".env file created with generated SECRET_KEY"
fi
diff --git a/docker-compose.yml b/docker-compose.yml
index f883ad9..a2f19c6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -37,6 +37,9 @@ services:
- .env
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-Zoneco_ORG}
+ - POSTGRES_HOST=db
+ - ADMIN_USERNAME=${ADMIN_USERNAME:-}
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
depends_on:
db:
condition: service_healthy
diff --git a/entrypoint.sh b/entrypoint.sh
index 4a240a3..4f68ceb 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,26 +1,47 @@
#!/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
+DB_HOST="${POSTGRES_HOST:-db}"
+DB_USER="${POSTGRES_USER:-postgres}"
+DB_NAME="${POSTGRES_DB:-Zoneco_ORG}"
+
+echo "Waiting for PostgreSQL to be ready at ${DB_HOST}..."
+while ! pg_isready -h "${DB_HOST}" -U "${DB_USER}" -d "${DB_NAME}"; do
echo "PostgreSQL is unavailable - sleeping"
sleep 1
done
-echo "PostgreSQL is up - executing command"
+echo "PostgreSQL is up"
-# 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
+# Create/update admin from environment variables (no hardcoded credentials).
+# Set ADMIN_USERNAME and ADMIN_PASSWORD in the server .env / compose env.
+if [ -n "${ADMIN_USERNAME:-}" ] && [ -n "${ADMIN_PASSWORD:-}" ]; then
+ echo "Ensuring admin user from environment variables..."
+ python manage.py shell <