Compare commits

...

4 Commits

Author SHA1 Message Date
Shayan Azadi
1dd0bcac90 Merge Shayan: admin login, permissions, and docs into deployed master 2026-07-22 22:54:59 +03:30
Shayan Azadi
149fb614b0 Fix permissions and prepare admin login for deploy
- Admin-only: list contacts, create/edit campaigns and compositions
- Public: submit contact, mine contacts by email/phone, read campaigns/compositions
- Add admin_response field on ContactUs
- Update Postman base_url to https://zoneco.org/api with token save

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 22:52:44 +03:30
Shayan Azadi
97127d5772 Keep a single README and remove extra markdown docs
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:32:04 +03:30
Shayan Azadi
50c9cf613f Add admin login API and remove hardcoded secrets
- Add token-based admin login/logout/me endpoints
- Bootstrap admin from ADMIN_USERNAME/ADMIN_PASSWORD in Docker entrypoint
- Read ALLOWED_HOSTS and CORS from env only (no hardcoded server IPs)
- Keep docs/Postman/tests free of real credentials
- Cover login and auth flows with 31 local API tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:29:47 +03:30
16 changed files with 1067 additions and 1023 deletions

View File

@@ -1,519 +0,0 @@
<div dir="rtl" lang="fa">
# مستندات API (Zoneco ORG)
این سند نحوهٔ کارکرد APIهای بک‌اند پروژه Zoneco ORG را به زبان فارسی توضیح می‌دهد.
---
## وضعیت تست‌ها
```
Ran 24 tests — OK (0 failures)
```
| تعداد تست | گروه |
|-----------|------|
| ۶ تست | Contact Us |
| ۱۱ تست | Compositions |
| ۷ تست | Campaigns |
اجرای تست‌ها:
```bash
cd backend
python manage.py test api -v 2
```
---
## اطلاعات پایه
| مقدار | مورد |
|--------|------|
| `{{base_url}}` | آدرس پایه |
| `/api/` | پیشوند API |
| JSON | فرمت پاسخ |
| ۲۰ آیتم در هر صفحه | صفحه‌بندی |
| `/admin/` | پنل ادمین |
### متغیر Postman
در فایل Postman از متغیر `{{base_url}}` استفاده شده است.
مقدار آن را مطابق محیط اجرا (لوکال، سرور تست یا پروداکشن) تنظیم کنید.
**نمونهٔ آدرس کامل یک endpoint:**
```
{{base_url}}/api/contact-us/
```
---
## سطح دسترسی
| دسترسی | عملیات |
|--------|--------|
| عمومی — بدون نیاز به ورود | لیست و جزئیات (متد `GET`) |
| عمومی — بدون نیاز به ورود | ایجاد (متد `POST`) |
| فقط ادمین — نیاز به احراز هویت | ویرایش و حذف (متدهای `PUT`، `PATCH`، `DELETE`) |
برای درخواست‌های ادمین در Postman می‌توانید از **Basic Auth** با نام کاربری و رمز ادمین استفاده کنید.
---
## ۱. تماس با ما (Contact Us)
**مسیر پایه:**
```
/api/contact-us/
```
### فیلدها
| توضیح | الزامی | نوع | فیلد |
|-------|--------|-----|------|
| نام و نام خانوادگی | بله | string | `name` |
| ایمیل یا شماره تماس | بله | string | `email_or_phone` |
| متن پیام | بله | string | `description` |
| دسته‌بندی | بله | string | `category` |
**مقادیر مجاز برای `category`:**
- همکاری
- فروش
- پشتیبانی
- درخواست مشاور
- سایر
---
### دریافت لیست تماس‌ها
```
GET {{base_url}}/api/contact-us/
```
**پاسخ نمونه:**
```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "احمد محمدی",
"email_or_phone": "ahmad@example.com",
"description": "سلام، سوالی دارم.",
"category": "پشتیبانی",
"created_at": "2025-12-17T12:00:00Z",
"updated_at": "2025-12-17T12:00:00Z"
}
]
}
```
---
### دریافت یک تماس
```
GET {{base_url}}/api/contact-us/{id}/
```
---
### ثبت تماس جدید
```
POST {{base_url}}/api/contact-us/
Content-Type: application/json
```
**بدنه درخواست:**
```json
{
"name": "احمد محمدی",
"email_or_phone": "09121234567",
"description": "می‌خواهم در مورد محصولات اطلاعات بگیرم.",
"category": "فروش"
}
```
**کد پاسخ:** `201 Created`
---
### فیلتر بر اساس دسته‌بندی
```
GET {{base_url}}/api/contact-us/by_category/?category=پشتیبانی
```
| توضیح | الزامی | پارامتر |
|-------|--------|---------|
| یکی از دسته‌های مجاز | بله | `category` |
اگر پارامتر `category` ارسال نشود، خطای `400` برمی‌گردد.
---
### ویرایش و حذف (ادمین)
```
PUT {{base_url}}/api/contact-us/{id}/
PATCH {{base_url}}/api/contact-us/{id}/
DELETE {{base_url}}/api/contact-us/{id}/
```
نیاز به احراز هویت ادمین دارد.
---
## ۲. ترکیبات (Compositions)
**مسیر پایه:**
```
/api/compositions/
```
هر ترکیب می‌تواند **چند تصویر** داشته باشد.
یکی از تصاویر با فیلد `is_main: true` به‌عنوان **تصویر اصلی** مشخص می‌شود.
### فیلدها
| توضیح | الزامی | نوع | فیلد |
|-------|--------|-----|------|
| نام ترکیب | بله | string | `name` |
| توضیحات | بله | string | `description` |
| یک یا چند فایل تصویر (فقط در ایجاد/ویرایش) | خیر | file[] | `uploaded_images` |
| ایندکس تصویر اصلی (شمارش از ۰) | خیر | integer | `main_image_index` |
### محدودیت تصاویر
- فرمت‌های مجاز: JPEG، PNG، WebP، GIF
- حداکثر حجم هر فایل: **۵ مگابایت**
- مسیر ذخیره: `media/compositions/`
---
### دریافت لیست ترکیبات
```
GET {{base_url}}/api/compositions/
```
---
### دریافت یک ترکیب
```
GET {{base_url}}/api/compositions/{id}/
```
**پاسخ نمونه:**
```json
{
"id": 1,
"name": "ترکیب A",
"description": "توضیحات ترکیب",
"image": null,
"image_url": null,
"images": [
{
"id": 2,
"image": "/media/compositions/img2.jpg",
"image_url": "{{base_url}}/media/compositions/img2.jpg",
"is_main": true,
"created_at": "2025-12-17T12:00:00Z"
},
{
"id": 1,
"image": "/media/compositions/img1.jpg",
"image_url": "{{base_url}}/media/compositions/img1.jpg",
"is_main": false,
"created_at": "2025-12-17T11:00:00Z"
}
],
"main_image": {
"id": 2,
"image_url": "{{base_url}}/media/compositions/img2.jpg",
"is_main": true,
"created_at": "2025-12-17T12:00:00Z"
},
"created_at": "2025-12-17T10:00:00Z",
"updated_at": "2025-12-17T12:00:00Z"
}
```
---
### فیلتر بر اساس تاریخ ایجاد
```
GET {{base_url}}/api/compositions/by-created-at/?from=2026-06-01T00:00:00Z&to=2026-06-30T23:59:59Z
```
| توضیح | الزامی | پارامتر |
|-------|--------|---------|
| برگشت ترکیب‌هایی که `created_at >= from` | حداقل یکی | `from` |
| برگشت ترکیب‌هایی که `created_at <= to` | حداقل یکی | `to` |
فرمت تاریخ: ISO 8601 — مثال: `2026-06-01T00:00:00Z`
اگر هیچ پارامتری ارسال نشود، خطای `400` برمی‌گردد.
---
### ایجاد ترکیب (بدون تصویر)
```
POST {{base_url}}/api/compositions/
Content-Type: application/json
```
```json
{
"name": "ترکیب تست",
"description": "توضیحات ترکیب"
}
```
---
### ایجاد ترکیب با چند تصویر
```
POST {{base_url}}/api/compositions/
Content-Type: multipart/form-data
```
| مقدار نمونه | نوع | فیلد |
|-------------|-----|------|
| ترکیب A | text | `name` |
| توضیحات | text | `description` |
| image1.jpg | file | `uploaded_images` |
| image2.jpg | file | `uploaded_images` |
| 1 | text | `main_image_index` |
**نکته:** فیلد `uploaded_images` را برای هر تصویر یک‌بار تکرار کنید.
**نکته:** اگر `main_image_index` ارسال نشود، **اولین تصویر** به‌عنوان تصویر اصلی انتخاب می‌شود.
---
### افزودن تصویر به ترکیب موجود (ادمین)
```
POST {{base_url}}/api/compositions/{id}/add-images/
Content-Type: multipart/form-data
```
| نوع | فیلد |
|-----|------|
| file (یک یا چند فایل) | `uploaded_images` |
| text (اختیاری) | `main_image_index` |
---
### تنظیم تصویر اصلی (ادمین)
```
POST {{base_url}}/api/compositions/{id}/set-main-image/
Content-Type: application/json
```
```json
{
"image_id": 3
}
```
---
### حذف یک تصویر (ادمین)
```
DELETE {{base_url}}/api/compositions/{id}/images/{image_id}/
```
**کد پاسخ:** `204 No Content`
---
### ویرایش و حذف ترکیب (ادمین)
```
PUT {{base_url}}/api/compositions/{id}/
PATCH {{base_url}}/api/compositions/{id}/
DELETE {{base_url}}/api/compositions/{id}/
```
---
## ۳. کمپین‌ها (Campaigns)
**مسیر پایه:**
```
/api/campaigns/
```
### فیلدها
| توضیح | الزامی | نوع | فیلد |
|-------|--------|-----|------|
| نام کمپین | بله | string | `name` |
| توضیحات | بله | string | `description` |
| زمان شروع (ISO 8601) | بله | datetime | `start_time` |
| زمان پایان (باید بعد از `start_time` باشد) | بله | datetime | `end_time` |
| تصویر کمپین | خیر | file | `image` |
### فیلدهای محاسباتی (فقط خواندنی)
| توضیح | فیلد |
|-------|------|
| آیا کمپین الان فعال است | `is_active` |
| آیا کمپین هنوز شروع نشده | `is_upcoming` |
| آیا کمپین تمام شده | `is_ended` |
---
### دریافت لیست کمپین‌ها
```
GET {{base_url}}/api/campaigns/
```
---
### دریافت یک کمپین
```
GET {{base_url}}/api/campaigns/{id}/
```
---
### ایجاد کمپین
```
POST {{base_url}}/api/campaigns/
Content-Type: application/json
```
```json
{
"name": "کمپین نوروز",
"description": "تخفیف ویژه نوروز",
"start_time": "2026-03-01T00:00:00Z",
"end_time": "2026-03-31T23:59:59Z"
}
```
---
### کمپین‌های فعال
```
GET {{base_url}}/api/campaigns/active/
```
کمپین‌هایی که زمان فعلی بین `start_time` و `end_time` قرار دارد.
---
### کمپین‌های آینده
```
GET {{base_url}}/api/campaigns/upcoming/
```
کمپین‌هایی که هنوز شروع نشده‌اند.
---
### کمپین‌های پایان‌یافته
```
GET {{base_url}}/api/campaigns/ended/
```
---
### ویرایش و حذف (ادمین)
```
PUT {{base_url}}/api/campaigns/{id}/
PATCH {{base_url}}/api/campaigns/{id}/
DELETE {{base_url}}/api/campaigns/{id}/
```
---
## کدهای وضعیت HTTP
| معنی | کد |
|------|-----|
| موفق | `200` |
| ایجاد شد | `201` |
| حذف شد (بدون بدنه) | `204` |
| داده نامعتبر | `400` |
| دسترسی ندارید | `403` |
| یافت نشد | `404` |
---
## فایل Postman
مجموعه Postman در مسیر زیر قرار دارد:
```
backend/Zoneco_ORG_API.postman_collection.json
```
### نحوه import
1. Postman را باز کنید
2. گزینه **Import** را بزنید
3. فایل `Zoneco_ORG_API.postman_collection.json` را انتخاب کنید
4. متغیر `{{base_url}}` را مطابق محیط خود تنظیم کنید
5. برای درخواست‌های ادمین، در تب **Authorization** گزینه **Basic Auth** را فعال کنید
---
## خلاصه endpointها
| دسترسی | Endpoint | Method | # |
|--------|----------|--------|---|
| عمومی | `/api/contact-us/` | GET | 1 |
| عمومی | `/api/contact-us/` | POST | 2 |
| عمومی | `/api/contact-us/{id}/` | GET | 3 |
| عمومی | `/api/contact-us/by_category/?category=...` | GET | 4 |
| ادمین | `/api/contact-us/{id}/` | PUT/PATCH/DELETE | 5 |
| عمومی | `/api/compositions/` | GET | 6 |
| عمومی | `/api/compositions/` | POST | 7 |
| عمومی | `/api/compositions/{id}/` | GET | 8 |
| عمومی | `/api/compositions/by-created-at/?from=...&to=...` | GET | 9 |
| ادمین | `/api/compositions/{id}/add-images/` | POST | 10 |
| ادمین | `/api/compositions/{id}/set-main-image/` | POST | 11 |
| ادمین | `/api/compositions/{id}/images/{image_id}/` | DELETE | 12 |
| ادمین | `/api/compositions/{id}/` | PUT/PATCH/DELETE | 13 |
| عمومی | `/api/campaigns/` | GET | 14 |
| عمومی | `/api/campaigns/` | POST | 15 |
| عمومی | `/api/campaigns/{id}/` | GET | 16 |
| عمومی | `/api/campaigns/active/` | GET | 17 |
| عمومی | `/api/campaigns/upcoming/` | GET | 18 |
| عمومی | `/api/campaigns/ended/` | GET | 19 |
| ادمین | `/api/campaigns/{id}/` | PUT/PATCH/DELETE | 20 |
> **توجه:** در Postman قبل از هر مسیر، مقدار `{{base_url}}` را قرار دهید.
> مثال: `{{base_url}}/api/campaigns/active/`
</div>

View File

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

View File

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

102
README.md
View File

@@ -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": "<token>",
"user": {
"id": 1,
"username": "<admin_username>",
"is_staff": true,
"is_superuser": true
}
}
```
### Use the token on protected endpoints
```
Authorization: Token <your_token_here>
```
### Current admin user
```
GET {{base_url}}/admin/me/
Authorization: Token <token>
```
### Logout (deletes the token)
```
POST {{base_url}}/admin/logout/
Authorization: Token <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`.
---

View File

@@ -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,12 +843,22 @@
},
{
"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"
}
]

View File

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

View File

@@ -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'),
),
]

View File

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

View File

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

View File

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

View File

@@ -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)),
]

View File

@@ -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 <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')
@@ -115,7 +177,6 @@ class CompositionViewSet(viewsets.ModelViewSet):
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
)
@@ -123,7 +184,7 @@ class CompositionViewSet(viewsets.ModelViewSet):
@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
@@ -134,7 +195,7 @@ class CompositionViewSet(viewsets.ModelViewSet):
@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,7 +207,7 @@ 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(
@@ -155,7 +216,7 @@ class CompositionViewSet(viewsets.ModelViewSet):
url_path='images/(?P<image_id>[^/.]+)'
)
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,
@@ -200,7 +257,6 @@ class CampaignViewSet(viewsets.ModelViewSet):
@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)
@@ -208,7 +264,6 @@ class CampaignViewSet(viewsets.ModelViewSet):
@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)

View File

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

View File

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

View File

@@ -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 <<EOF
from django.contrib.auth import get_user_model
User = get_user_model()
username = "${ADMIN_USERNAME}"
password = "${ADMIN_PASSWORD}"
user, created = User.objects.get_or_create(
username=username,
defaults={"is_staff": True, "is_superuser": True, "is_active": True},
)
user.is_staff = True
user.is_superuser = True
user.is_active = True
user.set_password(password)
user.save()
print("Admin user CREATED" if created else "Admin user UPDATED")
EOF
else
echo "ADMIN_USERNAME/ADMIN_PASSWORD not set — skipping admin bootstrap"
fi
echo "Starting application..."
exec "$@"

View File

@@ -32,13 +32,11 @@ DEBUG = os.environ.get('DEBUG', 'False') == 'True'
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
allowed_hosts_env = os.environ.get('ALLOWED_HOSTS', '')
ALLOWED_HOSTS = [h.strip() for h in allowed_hosts_env.split(',') if h.strip()]
if not ALLOWED_HOSTS:
# Fail safe: empty list rejects all hosts until env is configured
ALLOWED_HOSTS = []
# Application definition
@@ -52,6 +50,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
# Third party apps
'rest_framework',
'rest_framework.authtoken',
'corsheaders',
# Local apps
'api',
@@ -152,6 +151,11 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# REST Framework configuration
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny',
],
@@ -173,18 +177,8 @@ REST_FRAMEWORK = {
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_origins_env = os.environ.get('CORS_ALLOWED_ORIGINS', '')
CORS_ALLOWED_ORIGINS = [o.strip() for o in cors_origins_env.split(',') if o.strip()]
CORS_ALLOW_CREDENTIALS = True