From 50c9cf613fc93ddee2b69210ed6a36911b808212 Mon Sep 17 00:00:00 2001 From: Shayan Azadi Date: Thu, 16 Jul 2026 17:29:47 +0330 Subject: [PATCH 1/3] 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 --- API_DOCS_FA.md | 111 +++++-- DOCKER_README.md | 8 +- ENV_TEMPLATE.txt | 23 +- README.md | 76 ++++- Zoneco_ORG_API.postman_collection.json | 429 +++++++++++++++++++++---- api/serializers.py | 27 ++ api/tests.py | 113 ++++++- api/urls.py | 13 +- api/views.py | 50 ++- docker-commands.sh | 8 +- docker-compose.yml | 3 + entrypoint.sh | 39 ++- zonco_backend/settings.py | 32 +- 13 files changed, 791 insertions(+), 141 deletions(-) diff --git a/API_DOCS_FA.md b/API_DOCS_FA.md index 58b8bed..ea43d36 100644 --- a/API_DOCS_FA.md +++ b/API_DOCS_FA.md @@ -9,11 +9,12 @@ ## وضعیت تست‌ها ``` -Ran 24 tests — OK (0 failures) +Ran 31 tests — OK (0 failures) ``` | تعداد تست | گروه | |-----------|------| +| ۷ تست | Admin Login | | ۶ تست | Contact Us | | ۱۱ تست | Compositions | | ۷ تست | Campaigns | @@ -56,9 +57,70 @@ python manage.py test api -v 2 |--------|--------| | عمومی — بدون نیاز به ورود | لیست و جزئیات (متد `GET`) | | عمومی — بدون نیاز به ورود | ایجاد (متد `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": "", + "user": { + "id": 1, + "username": "", + "is_staff": true, + "is_superuser": true + } +} +``` + +در درخواست‌های محافظت‌شده این هدر را بفرستید: + +``` +Authorization: Token +``` + +### اطلاعات ادمین فعلی + +``` +GET {{base_url}}/api/admin/me/ +Authorization: Token +``` + +### خروج (حذف توکن) + +``` +POST {{base_url}}/api/admin/logout/ +Authorization: Token +``` + +برای درخواست‌های ادمین در Postman، ابتدا login کنید، سپس توکن را در متغیر `admin_token` بگذارید. --- @@ -492,26 +554,29 @@ backend/Zoneco_ORG_API.postman_collection.json | دسترسی | 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 | +| عمومی | `/api/admin/login/` | POST | 1 | +| ادمین | `/api/admin/logout/` | POST | 2 | +| ادمین | `/api/admin/me/` | GET | 3 | +| عمومی | `/api/contact-us/` | GET | 4 | +| عمومی | `/api/contact-us/` | POST | 5 | +| عمومی | `/api/contact-us/{id}/` | GET | 6 | +| عمومی | `/api/contact-us/by_category/?category=...` | GET | 7 | +| ادمین | `/api/contact-us/{id}/` | PUT/PATCH/DELETE | 8 | +| عمومی | `/api/compositions/` | GET | 9 | +| عمومی | `/api/compositions/` | POST | 10 | +| عمومی | `/api/compositions/{id}/` | GET | 11 | +| عمومی | `/api/compositions/by-created-at/?from=...&to=...` | GET | 12 | +| ادمین | `/api/compositions/{id}/add-images/` | POST | 13 | +| ادمین | `/api/compositions/{id}/set-main-image/` | POST | 14 | +| ادمین | `/api/compositions/{id}/images/{image_id}/` | DELETE | 15 | +| ادمین | `/api/compositions/{id}/` | PUT/PATCH/DELETE | 16 | +| عمومی | `/api/campaigns/` | GET | 17 | +| عمومی | `/api/campaigns/` | POST | 18 | +| عمومی | `/api/campaigns/{id}/` | GET | 19 | +| عمومی | `/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}}` را قرار دهید. > مثال: `{{base_url}}/api/campaigns/active/` diff --git a/DOCKER_README.md b/DOCKER_README.md index 45a01d0..589208e 100644 --- a/DOCKER_README.md +++ b/DOCKER_README.md @@ -19,14 +19,16 @@ This document provides instructions for running the Zoneco ORG backend using Doc # 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 + 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=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:** diff --git a/ENV_TEMPLATE.txt b/ENV_TEMPLATE.txt index 0b598ce..d5a0fa9 100644 --- a/ENV_TEMPLATE.txt +++ b/ENV_TEMPLATE.txt @@ -4,25 +4,34 @@ # Django Settings SECRET_KEY=your-secret-key-here-change-in-production -# Generate a secret key with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" +# Generate a secret key with: +# python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" DEBUG=False -ALLOWED_HOSTS=localhost,127.0.0.1,185.208.172.158 + +# Comma-separated hostnames/IPs for this server (no hardcoded defaults in code) +# Example: ALLOWED_HOSTS=YOUR_SERVER_IP,your-domain.com +ALLOWED_HOSTS=127.0.0.1,localhost # Database Configuration POSTGRES_DB=Zoneco_ORG POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres +POSTGRES_PASSWORD=change-me POSTGRES_HOST=db POSTGRES_PORT=5432 -DATABASE_URL=postgresql://postgres:postgres@db:5432/Zoneco_ORG +DATABASE_URL=postgresql://postgres:change-me@db:5432/Zoneco_ORG # Django Port DJANGO_PORT=8000 -# CORS Settings (comma-separated) -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000,http://185.208.172.158:9123,http://185.208.172.158 +# CORS Settings (comma-separated origins) +# Example: CORS_ALLOWED_ORIGINS=http://YOUR_SERVER_IP:9123,https://your-frontend.com +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 + +# Admin bootstrap (used by Docker entrypoint on server) +# Leave empty locally if you create the user manually +ADMIN_USERNAME= +ADMIN_PASSWORD= # Gunicorn Settings (optional) GUNICORN_WORKERS=3 GUNICORN_TIMEOUT=120 - diff --git a/README.md b/README.md index f82e3be..a75f8cd 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,12 @@ A Django REST Framework backend for Zoneco ORG with PostgreSQL database. ## Test Status ``` -Ran 24 tests in ~12s — OK (0 failures) +Ran 31 tests — OK (0 failures) ``` | Group | Tests | Status | |-------|-------|--------| +| Admin Login | 7 | ✅ All pass | | Contact Us | 6 | ✅ All pass | | Compositions | 11 | ✅ All pass | | Campaigns | 7 | ✅ All pass | @@ -26,6 +27,7 @@ python manage.py test api -v 2 ## Features +- **Admin Login API** — Token-based login with username/password - **Contact Us API** — Contact form submissions with category filter - **Composition API** — Multi-image upload, main image flag, date-range filter - **Campaign API** — Campaigns with active / upcoming / ended filters @@ -68,11 +70,70 @@ 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}}/api/admin/login/ +Content-Type: application/json +``` + +```json +{ + "username": "{{admin_username}}", + "password": "{{admin_password}}" +} +``` + +**Response:** +```json +{ + "token": "", + "user": { + "id": 1, + "username": "", + "is_staff": true, + "is_superuser": true + } +} +``` + +### Use the token on protected endpoints + +``` +Authorization: Token +``` + +### Current admin user + +``` +GET {{base_url}}/api/admin/me/ +Authorization: Token +``` + +### Logout (deletes the token) + +``` +POST {{base_url}}/api/admin/logout/ +Authorization: Token +``` --- @@ -84,7 +145,8 @@ API available at `{{base_url}}/api/` |--------|--------| | GET (list, retrieve, custom filters) | 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 | --- diff --git a/Zoneco_ORG_API.postman_collection.json b/Zoneco_ORG_API.postman_collection.json index e9ea747..824fb5a 100644 --- a/Zoneco_ORG_API.postman_collection.json +++ b/Zoneco_ORG_API.postman_collection.json @@ -2,10 +2,106 @@ "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": "مجموعه کامل 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" }, "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", "description": "APIهای فرم تماس با ما", @@ -50,8 +146,15 @@ "header": [], "url": { "raw": "{{base_url}}/api/contact-us/by_category/?category=پشتیبانی", - "host": ["{{base_url}}"], - "path": ["api", "contact-us", "by_category", ""], + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "contact-us", + "by_category", + "" + ], "query": [ { "key": "category", @@ -66,15 +169,31 @@ "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", @@ -87,10 +206,23 @@ "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", @@ -120,15 +252,22 @@ "url": "{{base_url}}/api/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", ""], + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "compositions", + "by-created-at", + "" + ], "query": [ { "key": "from", @@ -145,11 +284,14 @@ } }, { - "name": "POST Create Composition (JSON)", + "name": "POST Create Composition (JSON)", "request": { "method": "POST", "header": [ - {"key": "Content-Type", "value": "application/json"} + { + "key": "Content-Type", + "value": "application/json" + } ], "body": { "mode": "raw", @@ -166,11 +308,32 @@ "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/" @@ -180,10 +343,23 @@ "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,8 +367,16 @@ "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/" @@ -202,15 +386,31 @@ "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", @@ -223,10 +423,23 @@ "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", @@ -238,15 +451,31 @@ "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", @@ -259,10 +488,23 @@ "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", @@ -297,7 +539,10 @@ "request": { "method": "POST", "header": [ - {"key": "Content-Type", "value": "application/json"} + { + "key": "Content-Type", + "value": "application/json" + } ], "body": { "mode": "raw", @@ -314,11 +559,31 @@ "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/" @@ -352,15 +617,31 @@ "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", @@ -373,10 +654,23 @@ "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", @@ -390,7 +684,7 @@ "variable": [ { "key": "base_url", - "value": "http://localhost:8000", + "value": "http://127.0.0.1:8000", "type": "string" }, { @@ -405,13 +699,18 @@ }, { "key": "admin_username", - "value": "Zoneco_@1405", + "value": "", "type": "string" }, { "key": "admin_password", - "value": "Fun_@_zone2026", + "value": "", + "type": "string" + }, + { + "key": "admin_token", + "value": "", "type": "string" } ] -} +} \ No newline at end of file diff --git a/api/serializers.py b/api/serializers.py index 2fbad47..162c783 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,7 +1,34 @@ 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. diff --git a/api/tests.py b/api/tests.py index 4307335..aee33fb 100644 --- a/api/tests.py +++ b/api/tests.py @@ -64,7 +64,10 @@ class ContactUsAPITests(APITestCase): {'name': 'Updated'}, 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): @@ -200,7 +203,10 @@ class CompositionAPITests(APITestCase): {'name': 'Updated'}, 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): @@ -277,3 +283,106 @@ class CampaignAPITests(APITestCase): } response = self.client.post('/api/campaigns/', payload, format='json') 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, + )) diff --git a/api/urls.py b/api/urls.py index 8a44b04..7ee0599 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,6 +1,13 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import ContactUsViewSet, CompositionViewSet, CampaignViewSet +from .views import ( + ContactUsViewSet, + CompositionViewSet, + CampaignViewSet, + admin_login, + admin_logout, + admin_me, +) router = DefaultRouter() router.register(r'contact-us', ContactUsViewSet, basename='contact-us') @@ -8,6 +15,8 @@ router.register(r'compositions', CompositionViewSet, basename='composition') router.register(r'campaigns', CampaignViewSet, basename='campaign') urlpatterns = [ + path('admin/login/', admin_login, name='admin-login'), + path('admin/logout/', admin_logout, name='admin-logout'), + path('admin/me/', admin_me, name='admin-me'), path('', include(router.urls)), ] - diff --git a/api/views.py b/api/views.py index 93fe7f4..b73eb8a 100644 --- a/api/views.py +++ b/api/views.py @@ -1,8 +1,9 @@ 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.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 @@ -13,9 +14,56 @@ from .serializers import ( CompositionSerializer, CampaignSerializer, CompositionImageSerializer, + AdminLoginSerializer, ) +@api_view(['POST']) +@permission_classes([AllowAny]) +def admin_login(request): + """ + Admin login with username and password. + + Returns an auth token to use as: + Authorization: Token + """ + serializer = AdminLoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + token, _ = Token.objects.get_or_create(user=user) + return Response({ + 'token': token.key, + 'user': { + 'id': user.id, + 'username': user.username, + 'is_staff': user.is_staff, + 'is_superuser': user.is_superuser, + }, + }) + + +@api_view(['POST']) +@permission_classes([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): """ ViewSet for ContactUs model. diff --git a/docker-commands.sh b/docker-commands.sh index b5881d7..96c8cdd 100644 --- a/docker-commands.sh +++ b/docker-commands.sh @@ -28,14 +28,16 @@ if [ ! -f .env ]; then cat > .env << EOF SECRET_KEY=$(python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())") DEBUG=False -ALLOWED_HOSTS=localhost,127.0.0.1,185.208.172.158 +ALLOWED_HOSTS=localhost,127.0.0.1,YOUR_SERVER_IP POSTGRES_DB=Zoneco_ORG POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres +POSTGRES_PASSWORD=change-me POSTGRES_HOST=db POSTGRES_PORT=5432 DJANGO_PORT=8000 -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173,http://127.0.0.1:3000,http://185.208.172.158:9123,http://185.208.172.158 +ADMIN_USERNAME= +ADMIN_PASSWORD= +CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000,http://YOUR_SERVER_IP:9123 EOF print_info ".env file created with generated SECRET_KEY" fi diff --git a/docker-compose.yml b/docker-compose.yml index f883ad9..a2f19c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,9 @@ services: - .env environment: - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-Zoneco_ORG} + - POSTGRES_HOST=db + - ADMIN_USERNAME=${ADMIN_USERNAME:-} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} depends_on: db: condition: service_healthy diff --git a/entrypoint.sh b/entrypoint.sh index 4a240a3..4f68ceb 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,26 +1,47 @@ #!/bin/bash set -e -echo "Waiting for PostgreSQL to be ready..." -while ! pg_isready -h db -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-Zoneco_ORG}; do +DB_HOST="${POSTGRES_HOST:-db}" +DB_USER="${POSTGRES_USER:-postgres}" +DB_NAME="${POSTGRES_DB:-Zoneco_ORG}" + +echo "Waiting for PostgreSQL to be ready at ${DB_HOST}..." +while ! pg_isready -h "${DB_HOST}" -U "${DB_USER}" -d "${DB_NAME}"; do echo "PostgreSQL is unavailable - sleeping" sleep 1 done -echo "PostgreSQL is up - executing command" +echo "PostgreSQL is up" -# Collect static files echo "Collecting static files..." python manage.py collectstatic --noinput -# Run migrations echo "Running migrations..." python manage.py migrate --noinput -# Create superuser if it doesn't exist (optional, can be removed in production) -# Uncomment and modify if needed: -# echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.filter(username='admin').exists() or User.objects.create_superuser('admin', 'admin@example.com', 'admin')" | python manage.py shell +# Create/update admin from environment variables (no hardcoded credentials). +# Set ADMIN_USERNAME and ADMIN_PASSWORD in the server .env / compose env. +if [ -n "${ADMIN_USERNAME:-}" ] && [ -n "${ADMIN_PASSWORD:-}" ]; then + echo "Ensuring admin user from environment variables..." + python manage.py shell < Date: Thu, 16 Jul 2026 17:32:04 +0330 Subject: [PATCH 2/3] Keep a single README and remove extra markdown docs EOF Co-authored-by: Cursor --- API_DOCS_FA.md | 584 ----------------------------------------------- DOCKER_README.md | 221 ------------------ README.md | 10 +- 3 files changed, 2 insertions(+), 813 deletions(-) delete mode 100644 API_DOCS_FA.md delete mode 100644 DOCKER_README.md diff --git a/API_DOCS_FA.md b/API_DOCS_FA.md deleted file mode 100644 index ea43d36..0000000 --- a/API_DOCS_FA.md +++ /dev/null @@ -1,584 +0,0 @@ -
- -# مستندات API (Zoneco ORG) - -این سند نحوهٔ کارکرد APIهای بک‌اند پروژه Zoneco ORG را به زبان فارسی توضیح می‌دهد. - ---- - -## وضعیت تست‌ها - -``` -Ran 31 tests — OK (0 failures) -``` - -| تعداد تست | گروه | -|-----------|------| -| ۷ تست | Admin Login | -| ۶ تست | 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`) | - ---- - -## ۰. ورود ادمین (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": "", - "user": { - "id": 1, - "username": "", - "is_staff": true, - "is_superuser": true - } -} -``` - -در درخواست‌های محافظت‌شده این هدر را بفرستید: - -``` -Authorization: Token -``` - -### اطلاعات ادمین فعلی - -``` -GET {{base_url}}/api/admin/me/ -Authorization: Token -``` - -### خروج (حذف توکن) - -``` -POST {{base_url}}/api/admin/logout/ -Authorization: Token -``` - -برای درخواست‌های ادمین در Postman، ابتدا login کنید، سپس توکن را در متغیر `admin_token` بگذارید. - ---- - -## ۱. تماس با ما (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/admin/login/` | POST | 1 | -| ادمین | `/api/admin/logout/` | POST | 2 | -| ادمین | `/api/admin/me/` | GET | 3 | -| عمومی | `/api/contact-us/` | GET | 4 | -| عمومی | `/api/contact-us/` | POST | 5 | -| عمومی | `/api/contact-us/{id}/` | GET | 6 | -| عمومی | `/api/contact-us/by_category/?category=...` | GET | 7 | -| ادمین | `/api/contact-us/{id}/` | PUT/PATCH/DELETE | 8 | -| عمومی | `/api/compositions/` | GET | 9 | -| عمومی | `/api/compositions/` | POST | 10 | -| عمومی | `/api/compositions/{id}/` | GET | 11 | -| عمومی | `/api/compositions/by-created-at/?from=...&to=...` | GET | 12 | -| ادمین | `/api/compositions/{id}/add-images/` | POST | 13 | -| ادمین | `/api/compositions/{id}/set-main-image/` | POST | 14 | -| ادمین | `/api/compositions/{id}/images/{image_id}/` | DELETE | 15 | -| ادمین | `/api/compositions/{id}/` | PUT/PATCH/DELETE | 16 | -| عمومی | `/api/campaigns/` | GET | 17 | -| عمومی | `/api/campaigns/` | POST | 18 | -| عمومی | `/api/campaigns/{id}/` | GET | 19 | -| عمومی | `/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}}` را قرار دهید. -> مثال: `{{base_url}}/api/campaigns/active/` - -
diff --git a/DOCKER_README.md b/DOCKER_README.md deleted file mode 100644 index 589208e..0000000 --- a/DOCKER_README.md +++ /dev/null @@ -1,221 +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,YOUR_SERVER_IP - POSTGRES_DB=Zoneco_ORG - POSTGRES_USER=postgres - POSTGRES_PASSWORD=change-me - POSTGRES_HOST=db - POSTGRES_PORT=5432 - DJANGO_PORT=8000 - 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:** - ```bash - docker-compose up --build - ``` - -4. **The application will be available at:** - - API: `http://localhost:8000/api/` - - Admin: `http://localhost:8000/admin/` - -## Docker Commands - -### Start services -```bash -docker-compose up -``` - -### Start services in detached mode -```bash -docker-compose up -d -``` - -### Stop services -```bash -docker-compose down -``` - -### Stop services and remove volumes (⚠️ deletes database data) -```bash -docker-compose down -v -``` - -### View logs -```bash -docker-compose logs -f -``` - -### View logs for specific service -```bash -docker-compose logs -f web -docker-compose logs -f db -``` - -### Rebuild containers -```bash -docker-compose build --no-cache -``` - -### Execute commands in running container -```bash -# Django shell -docker-compose exec web python manage.py shell - -# Create superuser -docker-compose exec web python manage.py createsuperuser - -# Run migrations manually -docker-compose exec web python manage.py migrate - -# Collect static files -docker-compose exec web python manage.py collectstatic --noinput -``` - -## Architecture - -The Docker setup consists of: - -1. **Web Service** (`web`): - - Django application running with Gunicorn - - Multi-stage Dockerfile for optimized image size - - Non-root user for security - - Automatic migrations and static file collection on startup - - Health checks enabled - -2. **Database Service** (`db`): - - PostgreSQL 15 Alpine (lightweight) - - Persistent data volume - - Health checks to ensure readiness - -## Features - -### Security Best Practices -- ✅ Multi-stage build for smaller image size -- ✅ Non-root user execution -- ✅ Environment variable configuration -- ✅ No hardcoded secrets -- ✅ Health checks for both services - -### Production Ready -- ✅ Gunicorn WSGI server -- ✅ Automatic database migrations -- ✅ Static file collection -- ✅ Database connection retry logic -- ✅ Proper logging - -### Development Friendly -- ✅ Volume mounts for media and static files -- ✅ Hot-reload capability (with proper setup) -- ✅ Easy access to Django management commands - -## Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `SECRET_KEY` | Django secret key | (required in production) | -| `DEBUG` | Enable debug mode | `False` | -| `ALLOWED_HOSTS` | Comma-separated allowed hosts | `localhost,127.0.0.1` | -| `POSTGRES_DB` | Database name | `Zoneco_ORG` | -| `POSTGRES_USER` | Database user | `postgres` | -| `POSTGRES_PASSWORD` | Database password | `postgres` | -| `POSTGRES_HOST` | Database host | `db` | -| `POSTGRES_PORT` | Database port | `5432` | -| `DJANGO_PORT` | Django application port | `8000` | -| `CORS_ALLOWED_ORIGINS` | Comma-separated CORS origins | (see .env.example) | - -## Volumes - -- `postgres_data`: Persistent PostgreSQL data -- `./media`: Media files (images, uploads) -- `./staticfiles`: Collected static files - -## Troubleshooting - -### Database connection errors -```bash -# Check if database is healthy -docker-compose ps - -# Check database logs -docker-compose logs db - -# Restart services -docker-compose restart -``` - -### Permission issues -```bash -# Fix media/staticfiles permissions -sudo chown -R $USER:$USER media staticfiles -``` - -### Port already in use -```bash -# Change port in .env file -DJANGO_PORT=8001 -# Then update docker-compose.yml or restart -``` - -### Clear everything and start fresh -```bash -docker-compose down -v -docker-compose build --no-cache -docker-compose up -``` - -## Production Deployment - -For production deployment: - -1. **Set strong SECRET_KEY:** - ```bash - python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" - ``` - -2. **Set DEBUG=False** in `.env` - -3. **Configure proper ALLOWED_HOSTS** - -4. **Use strong database passwords** - -5. **Consider using:** - - Reverse proxy (nginx) - - SSL/TLS certificates - - Separate database server - - Backup strategy - - Monitoring and logging - -## File Structure - -``` -backend/ -├── Dockerfile # Multi-stage production Dockerfile -├── docker-compose.yml # Service orchestration -├── .dockerignore # Files to exclude from build -├── entrypoint.sh # Startup script -├── requirements.txt # Python dependencies -└── DOCKER_README.md # This file -``` - diff --git a/README.md b/README.md index a75f8cd..055cb3e 100644 --- a/README.md +++ b/README.md @@ -299,15 +299,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`. --- From 149fb614b0ddadbcce81b4aee6836f99cb7470e5 Mon Sep 17 00:00:00 2001 From: Shayan Azadi Date: Wed, 22 Jul 2026 22:52:44 +0330 Subject: [PATCH 3/3] 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 --- README.md | 26 +- Zoneco_ORG_API.postman_collection.json | 231 ++++++++++++++---- api/admin.py | 11 +- api/migrations/0003_contact_admin_response.py | 22 ++ api/models.py | 7 + api/serializers.py | 7 +- api/tests.py | 216 ++++++++-------- api/views.py | 125 +++++----- 8 files changed, 425 insertions(+), 220 deletions(-) create mode 100644 api/migrations/0003_contact_admin_response.py diff --git a/README.md b/README.md index 055cb3e..e493159 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,12 @@ On Docker deploy, `entrypoint.sh` creates/updates the admin from `ADMIN_USERNAME ### Login ``` -POST {{base_url}}/api/admin/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}}", @@ -102,7 +104,7 @@ Content-Type: application/json } ``` -**Response:** +**Response includes the admin token:** ```json { "token": "", @@ -124,14 +126,14 @@ Authorization: Token ### Current admin user ``` -GET {{base_url}}/api/admin/me/ +GET {{base_url}}/admin/me/ Authorization: Token ``` ### Logout (deletes the token) ``` -POST {{base_url}}/api/admin/logout/ +POST {{base_url}}/admin/logout/ Authorization: Token ``` @@ -139,13 +141,21 @@ Authorization: Token ## API Endpoints -### Authentication +### Authentication / Permissions + +| Who | Allowed | +|-----|---------| +| Public (user) | Submit contact, view own contacts (`/contact-us/mine/`), view campaigns & compositions (مقالات) | +| Admin only | List all contacts, reply to contacts, create/edit/delete campaigns & compositions | | Action | Access | |--------|--------| -| GET (list, retrieve, custom filters) | Public — no login required | -| POST (create) | Public — no login required | -| PUT / PATCH / DELETE | Admin only — Token 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 | --- diff --git a/Zoneco_ORG_API.postman_collection.json b/Zoneco_ORG_API.postman_collection.json index 824fb5a..39c8c8d 100644 --- a/Zoneco_ORG_API.postman_collection.json +++ b/Zoneco_ORG_API.postman_collection.json @@ -2,7 +2,7 @@ "info": { "_postman_id": "zoneco-org-api-collection", "name": "Zoneco ORG API", - "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 را در متغیرها بگذارید — در کد هاردکد نکنید.", + "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": [ @@ -22,6 +22,7 @@ " var json = pm.response.json();", " if (json.token) {", " pm.collectionVariables.set('admin_token', json.token);", + " console.log('admin_token saved');", " }", "}" ] @@ -40,12 +41,12 @@ "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." + "url": "{{base_url}}/admin/login/", + "description": "Login. Response includes token. Also saved to collection variable admin_token." } }, { - "name": "GET Admin Me", + "name": "GET Admin Me (shows token works)", "request": { "auth": { "type": "apikey", @@ -69,7 +70,7 @@ }, "method": "GET", "header": [], - "url": "{{base_url}}/api/admin/me/" + "url": "{{base_url}}/admin/me/" } }, { @@ -97,7 +98,7 @@ }, "method": "POST", "header": [], - "url": "{{base_url}}/api/admin/logout/" + "url": "{{base_url}}/admin/logout/" } } ] @@ -107,11 +108,56 @@ "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" + } + ] + } } }, { @@ -119,7 +165,7 @@ "request": { "method": "GET", "header": [], - "url": "{{base_url}}/api/contact-us/1/" + "url": "{{base_url}}/contact-us/1/" } }, { @@ -136,21 +182,20 @@ "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=پشتیبانی", + "raw": "{{base_url}}/contact-us/by_category/?category=پشتیبانی", "host": [ "{{base_url}}" ], "path": [ - "api", "contact-us", "by_category", "" @@ -162,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" + } + ] } } }, @@ -197,9 +262,9 @@ ], "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/" } }, { @@ -227,7 +292,7 @@ }, "method": "DELETE", "header": [], - "url": "{{base_url}}/api/contact-us/1/" + "url": "{{base_url}}/contact-us/1/" } } ] @@ -241,7 +306,7 @@ "request": { "method": "GET", "header": [], - "url": "{{base_url}}/api/compositions/" + "url": "{{base_url}}/compositions/" } }, { @@ -249,7 +314,7 @@ "request": { "method": "GET", "header": [], - "url": "{{base_url}}/api/compositions/{{composition_id}}/" + "url": "{{base_url}}/compositions/{{composition_id}}/" } }, { @@ -258,12 +323,11 @@ "method": "GET", "header": [], "url": { - "raw": "{{base_url}}/api/compositions/by-created-at/?from=2026-01-01T00:00:00Z&to=2026-12-31T23:59:59Z", + "raw": "{{base_url}}/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", "" @@ -284,7 +348,7 @@ } }, { - "name": "POST Create Composition (JSON)", + "name": "POST Create Composition (JSON) (Admin)", "request": { "method": "POST", "header": [ @@ -297,11 +361,31 @@ "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": [], @@ -336,7 +420,27 @@ } ] }, - "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" + } + ] + } } }, { @@ -379,7 +483,7 @@ } ] }, - "url": "{{base_url}}/api/compositions/{{composition_id}}/add-images/" + "url": "{{base_url}}/compositions/{{composition_id}}/add-images/" } }, { @@ -416,7 +520,7 @@ "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/" } }, { @@ -444,7 +548,7 @@ }, "method": "DELETE", "header": [], - "url": "{{base_url}}/api/compositions/{{composition_id}}/images/{{image_id}}/" + "url": "{{base_url}}/compositions/{{composition_id}}/images/{{image_id}}/" } }, { @@ -481,7 +585,7 @@ "mode": "raw", "raw": "{\n \"name\": \"نام بروزرسانی شده\"\n}" }, - "url": "{{base_url}}/api/compositions/{{composition_id}}/" + "url": "{{base_url}}/compositions/{{composition_id}}/" } }, { @@ -509,7 +613,7 @@ }, "method": "DELETE", "header": [], - "url": "{{base_url}}/api/compositions/{{composition_id}}/" + "url": "{{base_url}}/compositions/{{composition_id}}/" } } ] @@ -523,7 +627,7 @@ "request": { "method": "GET", "header": [], - "url": "{{base_url}}/api/campaigns/" + "url": "{{base_url}}/campaigns/" } }, { @@ -531,11 +635,11 @@ "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": [ @@ -548,11 +652,31 @@ "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": [], @@ -586,7 +710,27 @@ } ] }, - "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" + } + ] + } } }, { @@ -594,7 +738,7 @@ "request": { "method": "GET", "header": [], - "url": "{{base_url}}/api/campaigns/active/" + "url": "{{base_url}}/campaigns/active/" } }, { @@ -602,7 +746,7 @@ "request": { "method": "GET", "header": [], - "url": "{{base_url}}/api/campaigns/upcoming/" + "url": "{{base_url}}/campaigns/upcoming/" } }, { @@ -610,7 +754,7 @@ "request": { "method": "GET", "header": [], - "url": "{{base_url}}/api/campaigns/ended/" + "url": "{{base_url}}/campaigns/ended/" } }, { @@ -647,7 +791,7 @@ "mode": "raw", "raw": "{\n \"name\": \"نام بروزرسانی شده\"\n}" }, - "url": "{{base_url}}/api/campaigns/1/" + "url": "{{base_url}}/campaigns/1/" } }, { @@ -675,7 +819,7 @@ }, "method": "DELETE", "header": [], - "url": "{{base_url}}/api/campaigns/1/" + "url": "{{base_url}}/campaigns/1/" } } ] @@ -684,7 +828,7 @@ "variable": [ { "key": "base_url", - "value": "http://127.0.0.1:8000", + "value": "https://zoneco.org/api", "type": "string" }, { @@ -711,6 +855,11 @@ "key": "admin_token", "value": "", "type": "string" + }, + { + "key": "my_email_or_phone", + "value": "ali@example.com", + "type": "string" } ] } \ No newline at end of file diff --git a/api/admin.py b/api/admin.py index c056e54..16c24b6 100644 --- a/api/admin.py +++ b/api/admin.py @@ -4,9 +4,9 @@ from .models import ContactUs, Composition, Campaign, CompositionImage @admin.register(ContactUs) class ContactUsAdmin(admin.ModelAdmin): - list_display = ['name', 'email_or_phone', 'category', 'created_at'] + list_display = ['name', 'email_or_phone', 'category', 'has_admin_response', 'created_at'] list_filter = ['category', 'created_at'] - search_fields = ['name', 'email_or_phone', 'description'] + search_fields = ['name', 'email_or_phone', 'description', 'admin_response'] readonly_fields = ['created_at', 'updated_at'] date_hierarchy = 'created_at' @@ -17,12 +17,19 @@ class ContactUsAdmin(admin.ModelAdmin): ('پیام', { 'fields': ('description',) }), + ('پاسخ ادمین', { + 'fields': ('admin_response',) + }), ('اطلاعات زمانی', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) + @admin.display(boolean=True, description='پاسخ ادمین') + def has_admin_response(self, obj): + return bool(obj.admin_response and obj.admin_response.strip()) + class CompositionImageInline(admin.TabularInline): model = CompositionImage diff --git a/api/migrations/0003_contact_admin_response.py b/api/migrations/0003_contact_admin_response.py new file mode 100644 index 0000000..e91eb40 --- /dev/null +++ b/api/migrations/0003_contact_admin_response.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2026-07-22 19:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_composition_images'), + ] + + operations = [ + migrations.AddField( + model_name='contactus', + name='admin_response', + field=models.TextField(blank=True, default='', help_text='Admin reply visible to the contact submitter', verbose_name='پاسخ ادمین'), + ), + migrations.AddIndex( + model_name='contactus', + index=models.Index(fields=['email_or_phone'], name='api_contact_email_o_fedc42_idx'), + ), + ] diff --git a/api/models.py b/api/models.py index 2f1f7c8..a5499d2 100644 --- a/api/models.py +++ b/api/models.py @@ -27,6 +27,12 @@ class ContactUs(models.Model): choices=CATEGORY_CHOICES, verbose_name='دسته‌بندی' ) + admin_response = models.TextField( + blank=True, + default='', + verbose_name='پاسخ ادمین', + help_text='Admin reply visible to the contact submitter', + ) created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد') updated_at = models.DateTimeField(auto_now=True, verbose_name='تاریخ بروزرسانی') @@ -37,6 +43,7 @@ class ContactUs(models.Model): indexes = [ models.Index(fields=['-created_at']), models.Index(fields=['category']), + models.Index(fields=['email_or_phone']), ] def __str__(self): diff --git a/api/serializers.py b/api/serializers.py index 162c783..449a436 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -32,11 +32,14 @@ class AdminLoginSerializer(serializers.Serializer): class ContactUsSerializer(serializers.ModelSerializer): """ Serializer for ContactUs model. + Public create cannot set admin_response (enforced in the viewset). """ class Meta: model = ContactUs - fields = ['id', 'name', 'email_or_phone', 'description', 'category', - 'created_at', 'updated_at'] + fields = [ + 'id', 'name', 'email_or_phone', 'description', 'category', + 'admin_response', 'created_at', 'updated_at', + ] read_only_fields = ['id', 'created_at', 'updated_at'] def validate_email_or_phone(self, value): diff --git a/api/tests.py b/api/tests.py index aee33fb..407f53a 100644 --- a/api/tests.py +++ b/api/tests.py @@ -6,13 +6,13 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.utils import timezone from PIL import Image from rest_framework import status +from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase from .models import Campaign, Composition, CompositionImage, ContactUs def make_test_image(name='test.jpg', color='red', size=(100, 100)): - """Create a minimal valid JPEG for upload tests.""" buffer = io.BytesIO() Image.new('RGB', size, color=color).save(buffer, format='JPEG') buffer.seek(0) @@ -21,6 +21,14 @@ def make_test_image(name='test.jpg', color='red', size=(100, 100)): class ContactUsAPITests(APITestCase): def setUp(self): + User = get_user_model() + self.admin = User.objects.create_user( + username='admin_test', + password='test_admin_pass_123', + is_staff=True, + is_superuser=True, + ) + self.token = Token.objects.create(user=self.admin) self.contact = ContactUs.objects.create( name='Ali Reza', email_or_phone='ali@example.com', @@ -28,12 +36,20 @@ class ContactUsAPITests(APITestCase): category='پشتیبانی', ) - def test_list_contacts(self): + def test_list_contacts_requires_admin(self): + response = self.client.get('/api/contact-us/') + self.assertIn(response.status_code, ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + )) + + def test_list_contacts_as_admin(self): + self.client.credentials(HTTP_AUTHORIZATION=f'Token {self.token.key}') response = self.client.get('/api/contact-us/') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['count'], 1) - def test_create_contact(self): + def test_create_contact_public(self): payload = { 'name': 'Sara', 'email_or_phone': '09121234567', @@ -43,69 +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): - response = self.client.patch( - f'/api/contact-us/{self.contact.id}/', - {'name': 'Updated'}, - format='json', - ) + 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}/', + {'admin_response': 'We will help you.'}, + format='json', + ) + 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) ) @@ -118,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( @@ -145,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/', @@ -159,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'), @@ -184,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'), @@ -197,20 +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.assertIn(response.status_code, ( - status.HTTP_401_UNAUTHORIZED, - 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', @@ -231,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', @@ -247,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/') @@ -273,17 +315,6 @@ 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') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - class AdminLoginAPITests(APITestCase): def setUp(self): @@ -311,7 +342,6 @@ class AdminLoginAPITests(APITestCase): 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( @@ -341,34 +371,6 @@ class AdminLoginAPITests(APITestCase): 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/', @@ -379,8 +381,6 @@ class AdminLoginAPITests(APITestCase): 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, diff --git a/api/views.py b/api/views.py index b73eb8a..ddb3415 100644 --- a/api/views.py +++ b/api/views.py @@ -1,11 +1,10 @@ from rest_framework import viewsets, status 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 @@ -13,7 +12,6 @@ from .serializers import ( ContactUsSerializer, CompositionSerializer, CampaignSerializer, - CompositionImageSerializer, AdminLoginSerializer, ) @@ -43,7 +41,7 @@ def admin_login(request): @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAdminUser]) def admin_logout(request): """Delete the current admin auth token (logout).""" Token.objects.filter(user=request.user).delete() @@ -51,7 +49,7 @@ def admin_logout(request): @api_view(['GET']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAdminUser]) def admin_me(request): """Return the currently authenticated admin user.""" user = request.user @@ -66,24 +64,47 @@ def admin_me(request): 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) @@ -97,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') @@ -161,17 +175,16 @@ class CompositionViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(compositions, many=True) return Response(serializer.data) - + def _composition_response(self, composition): - """Return a fresh composition payload with up-to-date images.""" composition = Composition.objects.prefetch_related('images').get( pk=composition.pk ) return self.get_serializer(composition).data - + @action(detail=True, methods=['post'], url_path='add-images') def add_images(self, request, pk=None): - """Add one or more images to an existing composition.""" + """Admin: add one or more images to an existing composition.""" composition = self.get_object() serializer = self.get_serializer( composition, data=request.data, partial=True @@ -179,10 +192,10 @@ class CompositionViewSet(viewsets.ModelViewSet): serializer.is_valid(raise_exception=True) composition = serializer.save() return Response(self._composition_response(composition)) - + @action(detail=True, methods=['post'], url_path='set-main-image') def set_main_image(self, request, pk=None): - """Flag one of the composition's images as the main image.""" + """Admin: flag one image as the main image.""" composition = self.get_object() image_id = request.data.get('image_id') if image_id is None: @@ -194,16 +207,16 @@ class CompositionViewSet(viewsets.ModelViewSet): CompositionImage, pk=image_id, composition=composition ) image.is_main = True - image.save() # model.save() unsets is_main on the other images + image.save() return Response(self._composition_response(composition)) - + @action( detail=True, methods=['delete'], url_path='images/(?P[^/.]+)' ) def delete_image(self, request, pk=None, image_id=None): - """Delete a single image from the composition.""" + """Admin: delete a single image from the composition.""" composition = self.get_object() image = get_object_or_404( CompositionImage, pk=image_id, composition=composition @@ -214,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, @@ -245,18 +254,16 @@ class CampaignViewSet(viewsets.ModelViewSet): ) serializer = self.get_serializer(active_campaigns, many=True) return Response(serializer.data) - + @action(detail=False, methods=['get']) def upcoming(self, request): - """Get all upcoming campaigns.""" now = timezone.now() upcoming_campaigns = self.queryset.filter(start_time__gt=now) serializer = self.get_serializer(upcoming_campaigns, many=True) return Response(serializer.data) - + @action(detail=False, methods=['get']) def ended(self, request): - """Get all ended campaigns.""" now = timezone.now() ended_campaigns = self.queryset.filter(end_time__lt=now) serializer = self.get_serializer(ended_campaigns, many=True)