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>
This commit is contained in:
Shayan Azadi
2026-07-16 17:29:47 +03:30
parent 7e71d922d3
commit 50c9cf613f
13 changed files with 791 additions and 141 deletions

View File

@@ -9,11 +9,12 @@
## وضعیت تست‌ها ## وضعیت تست‌ها
``` ```
Ran 24 tests — OK (0 failures) Ran 31 tests — OK (0 failures)
``` ```
| تعداد تست | گروه | | تعداد تست | گروه |
|-----------|------| |-----------|------|
| ۷ تست | Admin Login |
| ۶ تست | Contact Us | | ۶ تست | Contact Us |
| ۱۱ تست | Compositions | | ۱۱ تست | Compositions |
| ۷ تست | Campaigns | | ۷ تست | Campaigns |
@@ -56,9 +57,70 @@ python manage.py test api -v 2
|--------|--------| |--------|--------|
| عمومی — بدون نیاز به ورود | لیست و جزئیات (متد `GET`) | | عمومی — بدون نیاز به ورود | لیست و جزئیات (متد `GET`) |
| عمومی — بدون نیاز به ورود | ایجاد (متد `POST`) | | عمومی — بدون نیاز به ورود | ایجاد (متد `POST`) |
| فقط ادمین — نیاز به احراز هویت | ویرایش و حذف (متدهای `PUT`، `PATCH`، `DELETE`) | | فقط ادمین — نیاز به توکن | ویرایش و حذف (متدهای `PUT`، `PATCH`، `DELETE`) |
برای درخواست‌های ادمین در Postman می‌توانید از **Basic Auth** با نام کاربری و رمز ادمین استفاده کنید. ---
## ۰. ورود ادمین (Admin Login)
| مقدار | مورد |
|--------|------|
| `{{admin_username}}` از env (`ADMIN_USERNAME`) | نام کاربری |
| `{{admin_password}}` از env (`ADMIN_PASSWORD`) | رمز عبور |
| `{{base_url}}/admin/` | پنل جنگو ادمین |
| `POST {{base_url}}/api/admin/login/` | ورود از طریق API |
> نام کاربری و رمز در کد هاردکد نمی‌شوند. روی سرور در فایل `.env` تنظیم کنید.
### ورود و دریافت توکن
```
POST {{base_url}}/api/admin/login/
Content-Type: application/json
```
```json
{
"username": "{{admin_username}}",
"password": "{{admin_password}}"
}
```
**پاسخ نمونه:**
```json
{
"token": "<token>",
"user": {
"id": 1,
"username": "<admin_username>",
"is_staff": true,
"is_superuser": true
}
}
```
در درخواست‌های محافظت‌شده این هدر را بفرستید:
```
Authorization: Token <token>
```
### اطلاعات ادمین فعلی
```
GET {{base_url}}/api/admin/me/
Authorization: Token <token>
```
### خروج (حذف توکن)
```
POST {{base_url}}/api/admin/logout/
Authorization: Token <token>
```
برای درخواست‌های ادمین در Postman، ابتدا login کنید، سپس توکن را در متغیر `admin_token` بگذارید.
--- ---
@@ -492,26 +554,29 @@ backend/Zoneco_ORG_API.postman_collection.json
| دسترسی | Endpoint | Method | # | | دسترسی | Endpoint | Method | # |
|--------|----------|--------|---| |--------|----------|--------|---|
| عمومی | `/api/contact-us/` | GET | 1 | | عمومی | `/api/admin/login/` | POST | 1 |
| عمومی | `/api/contact-us/` | POST | 2 | | ادمین | `/api/admin/logout/` | POST | 2 |
| عمومی | `/api/contact-us/{id}/` | GET | 3 | | ادمین | `/api/admin/me/` | GET | 3 |
| عمومی | `/api/contact-us/by_category/?category=...` | GET | 4 | | عمومی | `/api/contact-us/` | GET | 4 |
| ادمین | `/api/contact-us/{id}/` | PUT/PATCH/DELETE | 5 | | عمومی | `/api/contact-us/` | POST | 5 |
| عمومی | `/api/compositions/` | GET | 6 | | عمومی | `/api/contact-us/{id}/` | GET | 6 |
| عمومی | `/api/compositions/` | POST | 7 | | عمومی | `/api/contact-us/by_category/?category=...` | GET | 7 |
| عمومی | `/api/compositions/{id}/` | GET | 8 | | ادمین | `/api/contact-us/{id}/` | PUT/PATCH/DELETE | 8 |
| عمومی | `/api/compositions/by-created-at/?from=...&to=...` | GET | 9 | | عمومی | `/api/compositions/` | GET | 9 |
| ادمین | `/api/compositions/{id}/add-images/` | POST | 10 | | عمومی | `/api/compositions/` | POST | 10 |
| ادمین | `/api/compositions/{id}/set-main-image/` | POST | 11 | | عمومی | `/api/compositions/{id}/` | GET | 11 |
| ادمین | `/api/compositions/{id}/images/{image_id}/` | DELETE | 12 | | عمومی | `/api/compositions/by-created-at/?from=...&to=...` | GET | 12 |
| ادمین | `/api/compositions/{id}/` | PUT/PATCH/DELETE | 13 | | ادمین | `/api/compositions/{id}/add-images/` | POST | 13 |
| عمومی | `/api/campaigns/` | GET | 14 | | ادمین | `/api/compositions/{id}/set-main-image/` | POST | 14 |
| عمومی | `/api/campaigns/` | POST | 15 | | ادمین | `/api/compositions/{id}/images/{image_id}/` | DELETE | 15 |
| عمومی | `/api/campaigns/{id}/` | GET | 16 | | ادمین | `/api/compositions/{id}/` | PUT/PATCH/DELETE | 16 |
| عمومی | `/api/campaigns/active/` | GET | 17 | | عمومی | `/api/campaigns/` | GET | 17 |
| عمومی | `/api/campaigns/upcoming/` | GET | 18 | | عمومی | `/api/campaigns/` | POST | 18 |
| عمومی | `/api/campaigns/ended/` | GET | 19 | | عمومی | `/api/campaigns/{id}/` | GET | 19 |
| ادمین | `/api/campaigns/{id}/` | PUT/PATCH/DELETE | 20 | | عمومی | `/api/campaigns/active/` | GET | 20 |
| عمومی | `/api/campaigns/upcoming/` | GET | 21 |
| عمومی | `/api/campaigns/ended/` | GET | 22 |
| ادمین | `/api/campaigns/{id}/` | PUT/PATCH/DELETE | 23 |
> **توجه:** در Postman قبل از هر مسیر، مقدار `{{base_url}}` را قرار دهید. > **توجه:** در Postman قبل از هر مسیر، مقدار `{{base_url}}` را قرار دهید.
> مثال: `{{base_url}}/api/campaigns/active/` > مثال: `{{base_url}}/api/campaigns/active/`

View File

@@ -19,14 +19,16 @@ This document provides instructions for running the Zoneco ORG backend using Doc
# Copy and modify as needed # Copy and modify as needed
SECRET_KEY=your-secret-key-here-change-in-production SECRET_KEY=your-secret-key-here-change-in-production
DEBUG=False 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_DB=Zoneco_ORG
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=change-me
POSTGRES_HOST=db POSTGRES_HOST=db
POSTGRES_PORT=5432 POSTGRES_PORT=5432
DJANGO_PORT=8000 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=your-admin-username
ADMIN_PASSWORD=your-admin-password
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://YOUR_SERVER_IP:9123
``` ```
3. **Build and start the containers:** 3. **Build and start the containers:**

View File

@@ -4,25 +4,34 @@
# Django Settings # Django Settings
SECRET_KEY=your-secret-key-here-change-in-production 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 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 # Database Configuration
POSTGRES_DB=Zoneco_ORG POSTGRES_DB=Zoneco_ORG
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=change-me
POSTGRES_HOST=db POSTGRES_HOST=db
POSTGRES_PORT=5432 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
DJANGO_PORT=8000 DJANGO_PORT=8000
# CORS Settings (comma-separated) # CORS Settings (comma-separated origins)
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 # 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 Settings (optional)
GUNICORN_WORKERS=3 GUNICORN_WORKERS=3
GUNICORN_TIMEOUT=120 GUNICORN_TIMEOUT=120

View File

@@ -7,11 +7,12 @@ A Django REST Framework backend for Zoneco ORG with PostgreSQL database.
## Test Status ## Test Status
``` ```
Ran 24 tests in ~12s — OK (0 failures) Ran 31 tests — OK (0 failures)
``` ```
| Group | Tests | Status | | Group | Tests | Status |
|-------|-------|--------| |-------|-------|--------|
| Admin Login | 7 | ✅ All pass |
| Contact Us | 6 | ✅ All pass | | Contact Us | 6 | ✅ All pass |
| Compositions | 11 | ✅ All pass | | Compositions | 11 | ✅ All pass |
| Campaigns | 7 | ✅ All pass | | Campaigns | 7 | ✅ All pass |
@@ -26,6 +27,7 @@ python manage.py test api -v 2
## Features ## Features
- **Admin Login API** — Token-based login with username/password
- **Contact Us API** — Contact form submissions with category filter - **Contact Us API** — Contact form submissions with category filter
- **Composition API** — Multi-image upload, main image flag, date-range filter - **Composition API** — Multi-image upload, main image flag, date-range filter
- **Campaign API** — Campaigns with active / upcoming / ended filters - **Campaign API** — Campaigns with active / upcoming / ended filters
@@ -68,11 +70,70 @@ API available at `{{base_url}}/api/`
## Admin Credentials ## Admin Credentials
| Field | Value | Set these in the server `.env` (never hardcode in code):
|-------|-------|
| URL | `{{base_url}}/admin/` | | Env var | Purpose |
| Username | `Zoneco_@1405` | |---------|---------|
| Password | `Fun_@_zone2026` | | `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}}/api/admin/login/
Content-Type: application/json
```
```json
{
"username": "{{admin_username}}",
"password": "{{admin_password}}"
}
```
**Response:**
```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}}/api/admin/me/
Authorization: Token <token>
```
### Logout (deletes the token)
```
POST {{base_url}}/api/admin/logout/
Authorization: Token <token>
```
--- ---
@@ -84,7 +145,8 @@ API available at `{{base_url}}/api/`
|--------|--------| |--------|--------|
| GET (list, retrieve, custom filters) | Public — no login required | | GET (list, retrieve, custom filters) | Public — no login required |
| POST (create) | Public — no login required | | POST (create) | Public — no login required |
| PUT / PATCH / DELETE | Admin only — Basic Auth required | | PUT / PATCH / DELETE | Admin only — Token required |
| Admin login / logout / me | See Admin Login API above |
--- ---

View File

@@ -2,10 +2,106 @@
"info": { "info": {
"_postman_id": "zoneco-org-api-collection", "_postman_id": "zoneco-org-api-collection",
"name": "Zoneco ORG API", "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": "مجموعه کامل APIهای بک‌اند Zoneco ORG.\n\n1) متغیرهای collection را تنظیم کنید: base_url, admin_username, admin_password\n2) Admin Auth > POST Admin Login را بزنید\n3) توکن در admin_token ذخیره می‌شود\n4) درخواست‌های ادمین با Authorization: Token {{admin_token}} ارسال می‌شوند\n\nCredentials را در متغیرها بگذارید — در کد هاردکد نکنید.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
}, },
"item": [ "item": [
{
"name": "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);",
" }",
"}"
]
}
}
],
"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}}/api/admin/login/",
"description": "Login with admin credentials. Saves token to admin_token automatically."
}
},
{
"name": "GET Admin Me",
"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}}/api/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}}/api/admin/logout/"
}
}
]
},
{ {
"name": "Contact Us", "name": "Contact Us",
"description": "APIهای فرم تماس با ما", "description": "APIهای فرم تماس با ما",
@@ -50,8 +146,15 @@
"header": [], "header": [],
"url": { "url": {
"raw": "{{base_url}}/api/contact-us/by_category/?category=پشتیبانی", "raw": "{{base_url}}/api/contact-us/by_category/?category=پشتیبانی",
"host": ["{{base_url}}"], "host": [
"path": ["api", "contact-us", "by_category", ""], "{{base_url}}"
],
"path": [
"api",
"contact-us",
"by_category",
""
],
"query": [ "query": [
{ {
"key": "category", "key": "category",
@@ -66,15 +169,31 @@
"name": "PATCH Update Contact Us (Admin)", "name": "PATCH Update Contact Us (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "PATCH", "method": "PATCH",
"header": [ "header": [
{"key": "Content-Type", "value": "application/json"} {
"key": "Content-Type",
"value": "application/json"
}
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
@@ -87,10 +206,23 @@
"name": "DELETE Contact Us (Admin)", "name": "DELETE Contact Us (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "DELETE", "method": "DELETE",
@@ -120,15 +252,22 @@
"url": "{{base_url}}/api/compositions/{{composition_id}}/" "url": "{{base_url}}/api/compositions/{{composition_id}}/"
} }
}, },
{ {
"name": "GET Compositions by Created At", "name": "GET Compositions by Created At",
"request": { "request": {
"method": "GET", "method": "GET",
"header": [], "header": [],
"url": { "url": {
"raw": "{{base_url}}/api/compositions/by-created-at/?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z", "raw": "{{base_url}}/api/compositions/by-created-at/?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z",
"host": ["{{base_url}}"], "host": [
"path": ["api", "compositions", "by-created-at", ""], "{{base_url}}"
],
"path": [
"api",
"compositions",
"by-created-at",
""
],
"query": [ "query": [
{ {
"key": "from", "key": "from",
@@ -145,11 +284,14 @@
} }
}, },
{ {
"name": "POST Create Composition (JSON)", "name": "POST Create Composition (JSON)",
"request": { "request": {
"method": "POST", "method": "POST",
"header": [ "header": [
{"key": "Content-Type", "value": "application/json"} {
"key": "Content-Type",
"value": "application/json"
}
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
@@ -166,11 +308,32 @@
"body": { "body": {
"mode": "formdata", "mode": "formdata",
"formdata": [ "formdata": [
{"key": "name", "value": "ترکیب با تصویر", "type": "text"}, {
{"key": "description", "value": "ترکیب با چند تصویر", "type": "text"}, "key": "name",
{"key": "uploaded_images", "type": "file", "src": []}, "value": "ترکیب با تصویر",
{"key": "uploaded_images", "type": "file", "src": []}, "type": "text"
{"key": "main_image_index", "value": "0", "type": "text", "description": "ایندکس تصویر اصلی (از 0)"} },
{
"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}}/api/compositions/"
@@ -180,10 +343,23 @@
"name": "POST Add Images to Composition (Admin)", "name": "POST Add Images to Composition (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "POST", "method": "POST",
@@ -191,8 +367,16 @@
"body": { "body": {
"mode": "formdata", "mode": "formdata",
"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}}/api/compositions/{{composition_id}}/add-images/"
@@ -202,15 +386,31 @@
"name": "POST Set Main Image (Admin)", "name": "POST Set Main Image (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "POST", "method": "POST",
"header": [ "header": [
{"key": "Content-Type", "value": "application/json"} {
"key": "Content-Type",
"value": "application/json"
}
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
@@ -223,10 +423,23 @@
"name": "DELETE Composition Image (Admin)", "name": "DELETE Composition Image (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "DELETE", "method": "DELETE",
@@ -238,15 +451,31 @@
"name": "PATCH Update Composition (Admin)", "name": "PATCH Update Composition (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "PATCH", "method": "PATCH",
"header": [ "header": [
{"key": "Content-Type", "value": "application/json"} {
"key": "Content-Type",
"value": "application/json"
}
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
@@ -259,10 +488,23 @@
"name": "DELETE Composition (Admin)", "name": "DELETE Composition (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "DELETE", "method": "DELETE",
@@ -297,7 +539,10 @@
"request": { "request": {
"method": "POST", "method": "POST",
"header": [ "header": [
{"key": "Content-Type", "value": "application/json"} {
"key": "Content-Type",
"value": "application/json"
}
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
@@ -314,11 +559,31 @@
"body": { "body": {
"mode": "formdata", "mode": "formdata",
"formdata": [ "formdata": [
{"key": "name", "value": "کمپین با تصویر", "type": "text"}, {
{"key": "description", "value": "توضیحات کمپین", "type": "text"}, "key": "name",
{"key": "start_time", "value": "2026-06-01T00:00:00Z", "type": "text"}, "value": "کمپین با تصویر",
{"key": "end_time", "value": "2026-12-31T23:59:59Z", "type": "text"}, "type": "text"
{"key": "image", "type": "file", "src": []} },
{
"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}}/api/campaigns/"
@@ -352,15 +617,31 @@
"name": "PATCH Update Campaign (Admin)", "name": "PATCH Update Campaign (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "PATCH", "method": "PATCH",
"header": [ "header": [
{"key": "Content-Type", "value": "application/json"} {
"key": "Content-Type",
"value": "application/json"
}
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
@@ -373,10 +654,23 @@
"name": "DELETE Campaign (Admin)", "name": "DELETE Campaign (Admin)",
"request": { "request": {
"auth": { "auth": {
"type": "basic", "type": "apikey",
"basic": [ "apikey": [
{"key": "username", "value": "{{admin_username}}", "type": "string"}, {
{"key": "password", "value": "{{admin_password}}", "type": "string"} "key": "key",
"value": "Authorization",
"type": "string"
},
{
"key": "value",
"value": "Token {{admin_token}}",
"type": "string"
},
{
"key": "in",
"value": "header",
"type": "string"
}
] ]
}, },
"method": "DELETE", "method": "DELETE",
@@ -390,7 +684,7 @@
"variable": [ "variable": [
{ {
"key": "base_url", "key": "base_url",
"value": "http://localhost:8000", "value": "http://127.0.0.1:8000",
"type": "string" "type": "string"
}, },
{ {
@@ -405,13 +699,18 @@
}, },
{ {
"key": "admin_username", "key": "admin_username",
"value": "Zoneco_@1405", "value": "",
"type": "string" "type": "string"
}, },
{ {
"key": "admin_password", "key": "admin_password",
"value": "Fun_@_zone2026", "value": "",
"type": "string"
},
{
"key": "admin_token",
"value": "",
"type": "string" "type": "string"
} }
] ]
} }

View File

@@ -1,7 +1,34 @@
from rest_framework import serializers from rest_framework import serializers
from django.contrib.auth import authenticate
from .models import ContactUs, Composition, Campaign, CompositionImage 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): class ContactUsSerializer(serializers.ModelSerializer):
""" """
Serializer for ContactUs model. Serializer for ContactUs model.

View File

@@ -64,7 +64,10 @@ class ContactUsAPITests(APITestCase):
{'name': 'Updated'}, {'name': 'Updated'},
format='json', format='json',
) )
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertIn(response.status_code, (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
))
class CompositionAPITests(APITestCase): class CompositionAPITests(APITestCase):
@@ -200,7 +203,10 @@ class CompositionAPITests(APITestCase):
{'name': 'Updated'}, {'name': 'Updated'},
format='json', format='json',
) )
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertIn(response.status_code, (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
))
class CampaignAPITests(APITestCase): class CampaignAPITests(APITestCase):
@@ -277,3 +283,106 @@ class CampaignAPITests(APITestCase):
} }
response = self.client.post('/api/campaigns/', payload, format='json') response = self.client.post('/api/campaigns/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
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)
self.assertTrue(response.data['user']['is_staff'])
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_me_requires_auth(self):
response = self.client.get('/api/admin/me/')
self.assertIn(response.status_code, (
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
))
def test_token_allows_protected_update(self):
contact = ContactUs.objects.create(
name='Old Name',
email_or_phone='a@b.com',
description='msg',
category='سایر',
)
login = self.client.post(
'/api/admin/login/',
{'username': self.admin_username, 'password': self.admin_password},
format='json',
)
self.client.credentials(HTTP_AUTHORIZATION=f'Token {login.data["token"]}')
response = self.client.patch(
f'/api/contact-us/{contact.id}/',
{'name': 'New Name'},
format='json',
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['name'], 'New Name')
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)
# Token should no longer work
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 django.urls import path, include
from rest_framework.routers import DefaultRouter 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 = DefaultRouter()
router.register(r'contact-us', ContactUsViewSet, basename='contact-us') 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') router.register(r'campaigns', CampaignViewSet, basename='campaign')
urlpatterns = [ 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)), path('', include(router.urls)),
] ]

View File

@@ -1,8 +1,9 @@
from rest_framework import viewsets, status 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.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.authtoken.models import Token
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@@ -13,9 +14,56 @@ from .serializers import (
CompositionSerializer, CompositionSerializer,
CampaignSerializer, CampaignSerializer,
CompositionImageSerializer, 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([IsAuthenticated])
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([IsAuthenticated])
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): class ContactUsViewSet(viewsets.ModelViewSet):
""" """
ViewSet for ContactUs model. ViewSet for ContactUs model.

View File

@@ -28,14 +28,16 @@ if [ ! -f .env ]; then
cat > .env << EOF cat > .env << EOF
SECRET_KEY=$(python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())") SECRET_KEY=$(python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())")
DEBUG=False 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_DB=Zoneco_ORG
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=change-me
POSTGRES_HOST=db POSTGRES_HOST=db
POSTGRES_PORT=5432 POSTGRES_PORT=5432
DJANGO_PORT=8000 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 EOF
print_info ".env file created with generated SECRET_KEY" print_info ".env file created with generated SECRET_KEY"
fi fi

View File

@@ -37,6 +37,9 @@ services:
- .env - .env
environment: environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-Zoneco_ORG} - 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -1,26 +1,47 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "Waiting for PostgreSQL to be ready..." DB_HOST="${POSTGRES_HOST:-db}"
while ! pg_isready -h db -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-Zoneco_ORG}; do 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" echo "PostgreSQL is unavailable - sleeping"
sleep 1 sleep 1
done done
echo "PostgreSQL is up - executing command" echo "PostgreSQL is up"
# Collect static files
echo "Collecting static files..." echo "Collecting static files..."
python manage.py collectstatic --noinput python manage.py collectstatic --noinput
# Run migrations
echo "Running migrations..." echo "Running migrations..."
python manage.py migrate --noinput python manage.py migrate --noinput
# Create superuser if it doesn't exist (optional, can be removed in production) # Create/update admin from environment variables (no hardcoded credentials).
# Uncomment and modify if needed: # Set ADMIN_USERNAME and ADMIN_PASSWORD in the server .env / compose env.
# 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 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..." echo "Starting application..."
exec "$@" exec "$@"

View File

@@ -32,13 +32,11 @@ DEBUG = os.environ.get('DEBUG', 'False') == 'True'
if DEBUG: if DEBUG:
ALLOWED_HOSTS = ['*'] # Allow all hosts in development only ALLOWED_HOSTS = ['*'] # Allow all hosts in development only
else: else:
# Production: require ALLOWED_HOSTS to be set explicitly allowed_hosts_env = os.environ.get('ALLOWED_HOSTS', '')
allowed_hosts_env = os.environ.get('ALLOWED_HOSTS') ALLOWED_HOSTS = [h.strip() for h in allowed_hosts_env.split(',') if h.strip()]
if allowed_hosts_env: if not ALLOWED_HOSTS:
ALLOWED_HOSTS = allowed_hosts_env.split(',') # Fail safe: empty list rejects all hosts until env is configured
else: ALLOWED_HOSTS = []
# Fallback for production (should be overridden by env var)
ALLOWED_HOSTS = ['185.208.172.158'] # Your server IP
# Application definition # Application definition
@@ -52,6 +50,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
# Third party apps # Third party apps
'rest_framework', 'rest_framework',
'rest_framework.authtoken',
'corsheaders', 'corsheaders',
# Local apps # Local apps
'api', 'api',
@@ -152,6 +151,11 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# REST Framework configuration # REST Framework configuration
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny', 'rest_framework.permissions.AllowAny',
], ],
@@ -173,18 +177,8 @@ REST_FRAMEWORK = {
if DEBUG: if DEBUG:
CORS_ALLOW_ALL_ORIGINS = True # Development only - allows all origins CORS_ALLOW_ALL_ORIGINS = True # Development only - allows all origins
else: else:
# Production: require CORS_ALLOWED_ORIGINS to be set explicitly cors_origins_env = os.environ.get('CORS_ALLOWED_ORIGINS', '')
cors_origins_env = os.environ.get('CORS_ALLOWED_ORIGINS') CORS_ALLOWED_ORIGINS = [o.strip() for o in cors_origins_env.split(',') if o.strip()]
if cors_origins_env:
CORS_ALLOWED_ORIGINS = cors_origins_env.split(',')
else:
# Fallback for production (should be overridden by env var)
CORS_ALLOWED_ORIGINS = [
'http://185.208.172.158:9123',
'http://185.208.172.158',
'https://185.208.172.158:9123',
'https://185.208.172.158',
]
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True