This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+74
View File
@@ -0,0 +1,74 @@
---
alwaysApply: false
---
# Backend API Architecture & Postman
## 1. URL / Routing Architecture
- **Root (config/urls.py):** API mounts under `api/<app-prefix>/` via `include()`.
- Example: `path("api/auth/", include("auth.urls"))`, `path("api/sensor-hub/", include("sensor_hub.urls"))`.
- App prefix: kebab-case (e.g. `sensor-hub`).
- **App URLs (each apps urls.py):** Only endpoint definitions with `path()`.
- Same view can be used for several paths; distinguish by path or `kwargs` (e.g. `kwargs={"action": "active"}`).
- Order matters: more specific paths first (e.g. `active/`, `deactive/`), then path-param routes (e.g. `<uuid:uuid>/`), then base `""` for list.
- Example pattern:
- `path("active/", View.as_view(), kwargs={"action": "active"})`
- `path("deactive/", View.as_view(), kwargs={"action": "deactive"})`
- `path("<uuid:uuid>/", View.as_view(), name="...-detail")`
- `path("", View.as_view(), name="...-list")`
- **Views:** One `APIView` per resource (or per flow, e.g. auth). Dispatch by HTTP method and optionally by `request.path` or `kwargs` (e.g. `uuid`, `action`). No business logic in views; orchestration only.
---
## 2. Postman Collection Layout
- **Placement:** One collection per app: `<app_name>/postman/<collection_name>.json` (e.g. `sensor_hub/postman/sensor_hub.json`, `auth/postman/postman.json`).
- **Structure:**
- `info`: `name`, `schema` (v2.1.0), optional `description`.
- `item`: array of requests (one per endpoint variant/method).
- `variable`: at least `baseUrl` (e.g. `http://localhost:8000`); add `token`, `uuid` etc. when needed.
- **Request style:**
- One base URL per resource; multiple requests for different methods or path params (e.g. list vs `{{uuid}}/`).
- URL: `{{baseUrl}}/api/<app-prefix>/...` (e.g. `{{baseUrl}}/api/sensor-hub/`, `{{baseUrl}}/api/sensor-hub/{{uuid}}/`).
- Auth: where required, header `Authorization: Bearer {{token}}`.
- No random/dynamic values in body or response examples.
---
## 3. Postman Request Generator (when I give you routes)
Your task is to take the API routes I provide and convert them into a valid Postman collection JSON (as above).
ROUTE STYLE:
- When routes are defined as a single URL with different HTTP methods, generate one base URL and multiple requests (one per method/variant). Use the same URL for all; use path params (e.g. `<uuid>/`) or query for GET detail vs list when applicable.
RULES:
1. For each route (or each method/variant on the same URL), generate:
- Name: A descriptive, concise name for the request based on the route and HTTP method.
- Method: The HTTP method (GET, POST, PUT, DELETE, etc.).
- URL: The route URL.
- Body: If the endpoint accepts input, provide a JSON body example with appropriate keys; otherwise, leave it empty.
- Response: Provide a sample JSON response in the following format:
- If the endpoint returns no data:
{
"status": "success"
}
- If the endpoint returns data:
{
"status": "success",
"data": {}
}
- All responses must use HTTP status 200.
2. Do NOT generate random or dynamic values in the body or response.
3. Output must be a valid Postman collection JSON structure:
- Include "info" with collection name.
- Include "item" array with all requests.
4. Keep the JSON fully compatible with Postman import.
5. Do NOT include explanations outside the JSON.
Wait for me to provide the route definitions.
+96
View File
@@ -0,0 +1,96 @@
---
alwaysApply: true
---
## 2. Django App (Module) Naming
| Item | Convention | Example |
|--------|------------|----------------------------|
| App name | snake_case, **بدون** پسوند `_api` | `account`, `auth`, `sensor_hub` |
- نام اپ‌ها را با `_api` تمام **نکنید**. مثلاً به‌جای `account_api` از `account` استفاده کنید.
- برای ماژول‌های فقط API، همان نام دامنه کافی است (مثلاً `auth`، `account`).
---
## 3. Model and Database Field Naming
| Item | Convention | Example |
|-------------------|-------------------------|----------------------------------------------|
| Model | PascalCase | `UserProfile` |
| Fields | snake_case | `first_name`, `email_address` |
| Boolean | `is_` / `has_` + name | `is_active`, `has_paid` |
| Date/Time | `created_at` / `updated_at` | `created_at`, `updated_at` |
| ForeignKey / M2M | snake_case, often model name | `author = ForeignKey(UserProfile)` |
| Choices / Enum | UPPER_SNAKE_CASE values | `role = CharField(choices=(("ADMIN","Admin"), ("USER","User")))` |
---
## 4. DRF Conventions
- **Serializer:** Validation + data transformation. Use PascalCase names.
- **Service layer:** All business logic lives here.
- **View:** Orchestration only — call services and return responses. No business logic in views.
- **URLs:** Define endpoints only. Use kebab-case for URL paths.
---
## 5. API Response Format
همه پاسخ‌های API باید فیلد `code` را برگردانند؛ مقدار آن برابر **HTTP status code** درخواست است (مثلاً 200، 201، 400، 404).
| فیلد | توضیح |
|-------|----------------------------------------|
| `code` | کد وضعیت HTTP (مثلاً 200، 404، 500) |
| `msg` | پیام (مثلاً "success" برای 2xx) |
| `data` | دادهٔ برگشتی (اختیاری) |
مثال:
```json
{"code": 200, "msg": "success", "data": {...}}
```
---
## 6. Simple Example: How the Layers Connect (users app)
```python
# models.py
class UserProfile(models.Model):
first_name = models.CharField(max_length=50)
last_name = models.CharField(max_length=50)
email_address = models.EmailField(unique=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# serializers.py
class UserCreateSerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = ["first_name", "last_name", "email_address"]
# services.py
def create_user(first_name, last_name, email_address):
return UserProfile.objects.create(
first_name=first_name,
last_name=last_name,
email_address=email_address,
)
# views.py
class UserCreateAPIView(APIView):
def post(self, request):
serializer = UserCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = create_user(**serializer.validated_data)
return Response({"code": 201, "msg": "success", "data": {"id": user.id}}, status=201)
```
- All names follow the conventions above.
- Business logic is in `services.py`.
- Serializer only validates and serializes.
- View only orchestrates (calls service, returns response).
+72
View File
@@ -0,0 +1,72 @@
---
alwaysApply: false
---
You are a Django API code generator.
Your task is to generate a complete and runnable Django project based on the routes I provide.
ROUTE STYLE:
- Routes may be defined as a single URL with different HTTP methods (e.g. one path, GET for list, GET with query for detail, PUT/PATCH for update, DELETE for delete, POST for action). Use one view class that implements get, post, put, patch, delete as needed. Use query parameters (e.g. sensor_id) to distinguish list vs detail when both use GET.
STRICT RULES:
- Use Django only.
- Do NOT use Django REST Framework unless I explicitly request it.
- Do NOT connect to any database.
- Do NOT create any Models.
- Do NOT generate random or dynamic data.
- Input parameters must be accepted (body, query params, path params).
- HOWEVER, absolutely NO processing, validation, transformation, or logic may be applied to them.
- Do NOT use input values inside the response.
- No conditional logic.
- No business logic.
- No validation.
- All endpoints must always return static JSON responses only.
- ALL responses must return HTTP status code 200 only.
- No other status codes are allowed.
- No explanations outside the code.
- Return complete runnable code including project structure (views.py, urls.py, settings if needed, etc.).
--------------------------------------------------
RESPONSE FORMAT (STRICTLY ENFORCED)
If the endpoint does NOT require returning data:
{
"status": "success"
}
If the endpoint requires returning data:
{
"status": "success",
"data": {}
}
Mandatory rules:
- The "status" field MUST always be exactly "success".
- If "data" is present, it MUST be exactly an empty object {}.
- If data is not required, DO NOT include the "data" field.
- No additional fields are allowed.
--------------------------------------------------
COMMENTING REQUIREMENTS (VERY IMPORTANT):
Each endpoint MUST include professional, multi-line docstring documentation.
The documentation MUST include:
1. Clear description of the endpoint purpose.
2. Complete description of ALL input parameters:
- Parameter name
- Data type
- Location (body / query / path)
- Description of its intended purpose
3. Full description of the response structure:
- status field
- data field (if applicable)
4. Explicit statement that no processing or validation is performed on inputs.
Use clean, professional API documentation style.
Do not write anything outside the code.
Wait for my route definitions.
+16
View File
@@ -0,0 +1,16 @@
.env
.env.*
!.env.example
.git
__pycache__
*.pyc
.venv
venv
*.egg-info
.pytest_cache
.coverage
htmlcov
*.log
media
staticfiles
.cursor
+45
View File
@@ -0,0 +1,45 @@
# Django
SECRET_KEY=your-secret-key-change-in-production
DEBUG=1
DOCKER_VERSION=develop
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,ai-web
DOCKER_VERSION=develop
# Database (MySQL)
DB_ENGINE=django.db.backends.mysql
DB_NAME=croplogic
DB_USER=croplogic
DB_PASSWORD=changeme
DB_HOST=db
DB_PORT=3306
DB_ROOT_PASSWORD=root
ACCESS_CONTROL_AUTHZ_ENABLED=true
ACCESS_CONTROL_AUTHZ_BASE_URL=http://croplogic-accsess-opa:8181
ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision
ACCESS_CONTROL_AUTHZ_TIMEOUT=30
# SMS.ir
SMS_IR_API_KEY=
SMS_IR_LINE_NUMBER=300000000000
USE_EXTERNAL_API_MOCK=true
CROP_ZONE_CHUNK_AREA_SQM=10000
CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/0
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP=true
PEST_DISEASE_RISK_SUMMARY_CACHE_TTL=14400
WATER_NEED_PREDICTION_CACHE_TTL=14400
SOIL_SUMMARY_CACHE_TTL=14400
SOIL_ANOMALIES_CACHE_TTL=14400
FARM_ALERTS_AI_SYNC_CRON_MINUTE=0
FARM_ALERTS_AI_SYNC_CRON_HOUR=*
QDRANT_HOST=qdrant
QDRANT_PORT=6333
SENSOR_EXTERNAL_API_KEY=12345
SENSOR_EXTERNAL_API_KEY=12345
@@ -0,0 +1,120 @@
name: Backend Service CI/CD
on:
push:
branches: [production]
paths:
- '**'
- '.gitea/workflows/backend.yml'
pull_request:
branches: [production]
paths:
- '**'
- '.gitea/workflows/backend.yml'
jobs:
test:
name: Lint & Test
runs-on: self-hosted
container:
image: mirror2.chabokan.net/ubuntu:22.04
options: --add-host gitea:172.17.0.1
steps:
- name: Setup Ubuntu apt mirrors
run: |
tee /etc/apt/sources.list > /dev/null <<'EOF'
deb https://mirror-linux.runflare.com/ubuntu/ noble main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-updates main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-backports main restricted universe multiverse
deb https://mirror-linux.runflare.com/ubuntu/ noble-security main restricted universe multiverse
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal main universe
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal-updates main universe
deb [trusted=yes] https://mirror2.chabokan.net/ubuntu focal-security main universe
deb http://mirror.iranserver.com/ubuntu/ jammy main restricted
deb-src http://mirror.iranserver.com/ubuntu/ jammy main restricted
deb http://mirror.iranserver.com/ubuntu/ jammy-updates main restricted
deb-src http://mirror.iranserver.com/ubuntu/ jammy-updates main restricted
deb http://mirror.iranserver.com/ubuntu/ jammy universe
deb-src http://mirror.iranserver.com/ubuntu/ jammy universe
deb http://mirror.iranserver.com/ubuntu/ jammy-updates universe
deb http://mirror.iranserver.com/ubuntu/ jammy multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy multiverse
deb http://mirror.iranserver.com/ubuntu/ jammy-updates multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy-updates multiverse
deb http://mirror.iranserver.com/ubuntu/ jammy-backports main restricted universe multiverse
deb-src http://mirror.iranserver.com/ubuntu/ jammy-backports main restricted universe multiverse
EOF
apt-get update
- name: Install git
run: |
apt-get install -y git
- name: Checkout repository
run: |
git clone http://15f3baa28036aa35f8eb707585567d1b87bd8977@git.crop-logic.ir/sajad-dev/Backend.git .
- name: Install Python
run: |
apt-get install -y python3 python3-pip python3-venv git
- name: Setup Python pip mirrors
run: |
pip3 config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple
pip3 config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple
pip3 config --user set global.trusted-host "package-mirror.liara.ir mirror.cdn.ir mirror2.chabokan.net"
- name: Install system dependencies
run: |
apt-get install -y \
python3 \
python3-pip \
python3-venv \
pkg-config \
build-essential \
default-libmysqlclient-dev
- name: Install dependencies
run: |
python3 -m pip install --upgrade pip
pip3 install -r requirements.txt
pip3 install pytest flake8
- name: Run lint
run: |
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p ${{secrets.SERVER_SSH_PORT}} -H ${{ secrets.SERVER_HOST }} >> ~/.ssh/known_hosts
- name: Deploy
run: |
ssh ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }} -p ${{secrets.SERVER_SSH_PORT}}<< 'EOF'
cd application/Backend
git pull origin production
docker-compose -f docker-compose-prod.yaml down --remove-orphans
docker-compose -f docker-compose-prod.yaml up -d
EOF
+72
View File
@@ -0,0 +1,72 @@
name: Backend Service CI/CD
on:
push:
branches: [main]
paths:
- 'backend/**'
- 'backend/.github/workflows/backend.yml'
pull_request:
branches: [main]
paths:
- 'backend/**'
- 'backend/.github/workflows/backend.yml'
defaults:
run:
working-directory: backend
jobs:
test:
name: Lint & Test
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ['3.11']
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-backend-${{ hashFiles('backend/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-backend-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest flake8
- name: Run lint
run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Run tests
run: pytest --tb=short -q
deploy:
name: Deploy Backend Service
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
port: ${{ secrets.SSH_PORT }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/myproject/backend
git pull origin main
sudo systemctl restart backend
+59
View File
@@ -0,0 +1,59 @@
# Environment
.env
.env.local
*.env
!*.env.example
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
env/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Django
*.log
local_settings.py
db.sqlite3
media/
staticfiles/
*.pot
# Testing / Coverage
.coverage
htmlcov/
.pytest_cache/
.tox/
.nox/
# OS
.DS_Store
Thumbs.db
+4
View File
@@ -0,0 +1,4 @@
[submodule "Schemas"]
path = Schemas
url = ssh://git@git.crop-logic.ir:2222/sajad-dev/Schemas.git
branch = develop
+38
View File
@@ -0,0 +1,38 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is a Django REST backend organized by app domains. Core configuration lives in `config/` (`settings.py`, `urls.py`, `celery.py`). Feature apps (for example `account/`, `farm_hub/`, `sensor_catalog/`, `crop_zoning/`, `notifications/`) each contain `models.py`, `serializers.py`, `views.py`, `urls.py`, and app-specific `migrations/`.
Tests are mostly colocated in each app as `tests.py`. Mock payloads and integration fixtures are stored under `json/mock_data/` and `external_api_adapter/json/`. API collections are under `*/postman/`.
## Build, Test, and Development Commands
- `python -m venv .venv && source .venv/bin/activate` - create and activate a virtual environment.
- `pip install -r requirements.txt` - install runtime and development dependencies.
- `python manage.py migrate` - apply database migrations.
- `python manage.py runserver` - start the local API server.
- `python manage.py test` - run the full Django test suite.
- `python manage.py test farm_hub` - run tests for a single app.
- `docker compose up --build` - run the backend and dependencies via Docker.
## Coding Style & Naming Conventions
Follow standard Django/Python conventions:
- 4-space indentation, snake_case for functions/variables, PascalCase for classes.
- Keep serializers in `serializers.py`, business logic in `services.py`, and routing in `urls.py`.
- Name management commands as verbs (for example `seed_admin_user`).
- Prefer small, app-scoped modules over cross-app imports unless shared behavior is intentional.
## Testing Guidelines
Use Djangos built-in test runner (`unittest` style). Place tests in each apps `tests.py` (or `tests/` package if expanded). Name tests as `test_<behavior>` and cover serializers, view responses, and service edge cases. Use `unittest.mock.patch` for external integrations (AI adapters, SMS, or HTTP services).
## Commit & Pull Request Guidelines
Current history uses generic messages (`UPDATE`), but contributors should use clear, imperative commits such as `add sensor catalog seed command`.
For PRs, include:
- concise description of scope and affected apps,
- migration notes (`manage.py makemigrations`/`migrate` impact),
- test evidence (`python manage.py test ...` output),
- linked issue/task ID when available,
- request/response examples for API changes (Postman or JSON sample paths).
## Security & Configuration Tips
Copy `.env.example` to `.env` and never commit secrets. Validate CORS/JWT settings in `config/settings.py` per environment. Keep mock JSON and seed data free of production credentials or personal data.
@@ -0,0 +1,26 @@
# Backend ↔ AI Integration Flow Contract
## Ownership
- `Backend/plants` owns the canonical plant catalog stored in Backend DB.
- `Ai/farm_data` stores plant catalog snapshots and derived farm read-model data for AI workflows.
- `Backend/farm_alerts` returns persisted tracker snapshots; it does not expose live AI inference on the tracker endpoint.
- `Ai/crop_simulation` owns simulation-derived outputs and live inference tasks.
## Flow Types
- `direct_proxy`: Backend forwards request/response to AI without changing ownership.
- `backend_owned_data_with_ai_enrichment`: Backend owns the base record and augments it with AI output or AI sync.
- `cached_snapshot`: Response is served from persisted snapshot state.
- `live_ai_inference`: Response or task is generated from live AI execution.
- `ai_owned_derived_output`: AI returns computed or derived outputs from its own services/read-models.
## Response Metadata
Touched endpoints now expose a top-level `meta` object with:
- `flow_type`
- `source_type`
- `source_service`
- `ownership`
- `live`
- `cached`
- optional `generated_at`
- optional `snapshot_at`
- optional sync fields for Backend plant endpoints
@@ -0,0 +1,100 @@
# Backend ↔ AI Route Connection Audit
Last reconciled against current route registrations and view implementations in:
- `Backend/config/urls.py`
- `Backend/*/urls.py`
- `Backend/*/views.py`
- `Ai/config/urls.py`
- `Ai/*/urls.py`
- `Backend/external_api_adapter/json/ai/index.json`
## Status Vocabulary
- `implemented`: route exists and the corresponding backend ↔ AI integration is implemented now
- `partially_implemented`: route exists, but behavior/readiness is limited or alias-based
- `contract_only`: mock/spec exists, but no real client-facing implementation is registered
- `deprecated`: kept for compatibility or aliasing, but not the preferred canonical route
- `missing`: documented previously, but no route/implementation exists now
- `disabled`: intentionally not exposed for current developer/public use
- `transitional`: works now, but still reflects temporary architecture boundaries or compatibility layers
## Runtime vs Seed Rule
- seed/bootstrap data stays allowed for local/dev/test/bootstrap flows
- runtime application code must not silently return mock/sample/demo data
- if real data is missing, the contract must surface an explicit empty state or structured failure
## Ownership Boundaries
- Backend owns canonical plant catalog records exposed in `Backend/plants`
- AI `farm_data` owns the derived farm read-model and canonical AI-side farm ↔ plant assignment path
- Backend farm-alert tracker route is cached snapshot delivery, not live AI on request
- AI crop-simulation routes own live or derived simulation outputs
## Source-Of-Truth Matrix
| Backend/API contract | Actual route or AI path | Status | Notes |
|---|---|---:|---|
| `POST /api/rag/chat/` | AI only: `Ai/rag/urls.py` | `implemented` | Real AI route; not a backend client route |
| `POST /api/farm-alerts/tracker/` | `Backend/farm_alerts/views.py` → cached snapshot response | `transitional` | Backend route is production-valid, but semantics are `cached_snapshot`, not live AI inference |
| `POST /api/farm-alerts/timeline/` | no backend route | `missing` | Previously documented incorrectly |
| `GET /api/soil-data/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes |
| `POST /api/soil-data/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes |
| `GET /api/soil-data/tasks/{task_id}/status/` | AI only: `Ai/location_data/urls.py` | `implemented` | Exists on AI service, not on backend public routes |
| `POST /api/soil-data/ndvi-health/` | real backend route is `POST /api/crop-health/ndvi-health/` | `deprecated` | Old path should not be presented as current |
| `POST /api/soile/moisture-heatmap/` | AI route; backend canonical alias is `POST /api/soil/moisture-heatmap/` | `implemented` | `soile/*` is AI-facing, `soil/*` is backend-facing |
| `POST /api/soile/health-summary/` | AI route; backend canonical alias is `POST /api/soil/summary/` | `implemented` | Same as above |
| `POST /api/soile/anomaly-detection/` | AI route; backend canonical alias is `POST /api/soil/anomalies/` | `implemented` | Same as above |
| `POST /api/farm-data/` | AI route exists; backend uses it for sync | `implemented` | Internal AI contract; not a backend public endpoint |
| `GET /api/farm-data/{farm_uuid}/detail/` | AI route exists: `Ai/farm_data/urls.py` | `implemented` | Internal AI service contract |
| `POST /api/farm-data/parameters/` | AI route exists: `Ai/farm_data/urls.py` | `implemented` | Internal AI service contract |
| `POST /api/weather/farm-card/` | backend route exists; AI canonical route also exists | `implemented` | Backend proxies to weather functionality |
| `POST /api/weather/water-need-prediction/` | AI route exists; backend public contract differs | `partially_implemented` | AI path is real; backend public path is different |
| `POST /api/economy/overview/` | backend + AI route exist | `implemented` | End-to-end connected |
| `GET /api/plants/` | AI route exists as `Ai/plant/urls.py` and backend route exists as `GET /api/plants/` | `implemented` | Different services, both real |
| `POST /api/plants/` | AI + backend real | `implemented` | Different services, both real |
| `GET /api/plants/{pk}/` | AI + backend real | `implemented` | Backend is canonical catalog; AI is its own service/snapshot consumer |
| `PUT /api/plants/{pk}/` | AI route real; backend route not exposed with PUT | `partially_implemented` | Real on AI, not mirrored on backend public app |
| `PATCH /api/plants/{pk}/` | AI route real; backend route not exposed with PATCH | `partially_implemented` | Same limitation |
| `DELETE /api/plants/{pk}/` | AI route real; backend route not exposed with DELETE | `partially_implemented` | Same limitation |
| `POST /api/plants/fetch-info/` | AI route real | `implemented` | AI route exists; backend public equivalent is absent |
| `POST /api/pest-disease/detect/` | backend alias + AI route real | `implemented` | Canonical current path |
| `POST /api/pest-disease/risk/` | backend alias + AI route real | `implemented` | Canonical current path |
| `POST /api/pest-disease/risk-summary/` | backend alias route exists | `implemented` | Implemented in backend alias layer |
| `GET /api/irrigation/` | backend + AI real | `implemented` | Canonical list route |
| `POST /api/irrigation/` | AI route real; backend route currently list/create mismatch | `partially_implemented` | Backend public create contract is not yet cleanly reconciled |
| `GET /api/irrigation/{pk}/` | AI route real; backend route missing | `partially_implemented` | Real in AI only |
| `PUT /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Present in mock/spec and AI service, not a backend public route |
| `PATCH /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Same |
| `DELETE /api/irrigation/{pk}/` | AI route real; backend route missing | `contract_only` | Same |
| `POST /api/irrigation/recommend/` | backend + AI real | `implemented` | Canonical route |
| `GET /api/irrigation/recommend/{task_id}/status/` | mock/spec only | `contract_only` | No current backend or AI route registration found |
| `POST /api/fertilization/recommend/` | backend + AI real | `implemented` | Canonical route |
| `GET /api/fertilization/recommend/{task_id}/status/` | mock/spec only | `contract_only` | No current route registration found |
| `POST /api/crop-simulation/growth/` | AI route real; backend canonical client route is `/api/yield-harvest/growth/` | `deprecated` | Real AI route, but backend public source-of-truth remains under `yield-harvest/*` |
| `GET /api/crop-simulation/growth/{task_id}/status/` | AI route real; backend canonical client route is `/api/yield-harvest/growth/{task_id}/status/` | `deprecated` | Same |
| `POST /api/crop-simulation/current-farm-chart/` | AI route real; backend canonical client route is `/api/yield-harvest/current-farm-chart/` | `deprecated` | Same |
| `POST /api/crop-simulation/harvest-prediction/` | AI route real; backend canonical client route is `/api/yield-harvest/harvest-prediction/` | `deprecated` | Same |
| `POST /api/crop-simulation/yield-prediction/` | AI route real; backend canonical client route is `/api/yield-harvest/yield-prediction/` | `deprecated` | Same |
## Response Semantics
- `farm-alerts/tracker` backend route → `cached snapshot`
- `irrigation/*` backend routes → mostly `proxy` or `backend-owned data with AI enrichment`
- `yield-harvest/*` backend routes → `proxy` to AI plus persisted backend logs for some summaries
- `farm-data/*` AI routes → `AI-owned derived read/write model`
## Reconciliation Notes
- `pest-disease/*` is now the real backend alias and AI contract. Older references to `pest-detection/analyze` as the “real” path are stale.
- `farm-alerts/timeline` is not a registered backend route and must not be documented as implemented.
- `soil-data/*`, `farm-data/*`, and several `plants/*` routes are real on the AI service, but not backend public routes; docs must distinguish internal AI contracts from backend client APIs.
- `crop-simulation/*` remains real on AI, while backend public endpoints are exposed under `yield-harvest/*`.
- task status endpoints for fertilization and irrigation recommendation remain mock/spec-only in `Backend/external_api_adapter/json/ai/index.json`.
- schema UI endpoints are intentionally disabled in AI; developers should rely on version-controlled audit docs until schema publishing is intentionally re-enabled.
## Known Gaps / Follow-up
- Some backend docs still use historical “AI route” wording where “internal AI contract” would be more precise.
- Some dashboard-era docs still need cleanup where old mock fallback language remains.
@@ -0,0 +1,68 @@
# Requested Endpoint Usage Audit
This file is the backend-facing API status matrix reconciled against current code.
Status vocabulary:
- `implemented`
- `partially_implemented`
- `stub/contract-only`
- `deprecated`
- `missing`
## Endpoint Matrix
| Endpoint | Backend route | AI route | Status | Notes |
|---|---|---|---:|---|
| `POST /api/weather/farm-card/` | yes | yes | `implemented` | Current backend public weather card route. |
| `POST /api/economy/overview/` | yes | yes | `implemented` | End-to-end route is live. |
| `GET /api/irrigation/` | yes | yes | `implemented` | Method list route is live. |
| `POST /api/irrigation/recommend/` | yes | yes | `implemented` | Recommendation route is live. |
| `POST /api/irrigation/water-stress/` | yes | yes | `implemented` | Backend route proxies to AI-backed water stress flow. |
| `POST /api/fertilization/recommend/` | yes | yes | `implemented` | Live route. |
| `POST /api/pest-disease/detect/` | yes | yes | `implemented` | Canonical current public alias. |
| `POST /api/pest-disease/risk/` | yes | yes | `implemented` | Canonical current public alias. |
| `POST /api/pest-disease/risk-summary/` | yes | no separate AI route | `implemented` | Backend route derives risk summary from the same AI risk integration. |
| `POST /api/farm-alerts/tracker/` | yes | yes | `partially_implemented` | Backend serves snapshot-backed tracker response; not a direct request-time AI invocation. |
| `POST /api/farm-alerts/timeline/` | no | no | `missing` | Was documented, but no route exists. |
| `POST /api/soil/summary/` | yes | n/a | `implemented` | Backend public summary route. |
| `POST /api/soil/anomalies/` | yes | via `POST /api/soile/anomaly-detection/` | `implemented` | Backend canonical route. |
| `POST /api/soil/moisture-heatmap/` | yes | via `POST /api/soile/moisture-heatmap/` | `implemented` | Backend canonical route. |
| `POST /api/crop-health/ndvi-health/` | yes | via `POST /api/soil-data/ndvi-health/` | `implemented` | Backend canonical route. |
| `POST /api/yield-harvest/current-farm-chart/` | yes | via `/api/crop-simulation/current-farm-chart/` | `implemented` | Backend canonical route. |
| `POST /api/yield-harvest/harvest-prediction/` | yes | via `/api/crop-simulation/harvest-prediction/` | `implemented` | Backend canonical route. |
| `POST /api/yield-harvest/yield-prediction/` | yes | via `/api/crop-simulation/yield-prediction/` | `implemented` | Backend canonical route. |
| `POST /api/yield-harvest/growth/` | yes | via `/api/crop-simulation/growth/` | `implemented` | Backend canonical route. |
| `GET /api/yield-harvest/growth/{task_id}/status/` | yes | via `/api/crop-simulation/growth/{task_id}/status/` | `implemented` | Backend canonical route. |
| `GET /api/yield-harvest/summary/` | yes | no | `implemented` | Summary route exists. |
| `GET /api/yield-harvest/yield-harvest-summary/` | yes | via AI summary service | `implemented` | Compatibility alias remains live. |
## Internal AI Contracts Not To Present As Backend Public APIs
| Endpoint | Status | Notes |
|---|---:|---|
| `POST /api/rag/chat/` | `implemented` | AI service route only. |
| `GET|POST /api/soil-data/` | `implemented` | AI service route only. |
| `GET /api/soil-data/tasks/{task_id}/status/` | `implemented` | AI service route only. |
| `POST /api/soile/*` | `implemented` | AI service routes; backend public aliases are under `soil/*`. |
| `POST /api/farm-data/` | `implemented` | AI service route used for integration and sync. |
| `GET /api/farm-data/{farm_uuid}/detail/` | `implemented` | AI service route. |
| `POST /api/farm-data/parameters/` | `implemented` | AI service route. |
| `POST /api/weather/water-need-prediction/` | `implemented` | AI service route; backend public contract is under `water/*`. |
| `POST /api/crop-simulation/*` | `implemented` | AI service routes; backend public contract is under `yield-harvest/*`. |
## Contract-Only / Stale Spec Entries
| Endpoint | Status | Notes |
|---|---:|---|
| `GET /api/irrigation/recommend/{task_id}/status/` | `stub/contract-only` | Present in mock spec, no real route registration found. |
| `GET /api/fertilization/recommend/{task_id}/status/` | `stub/contract-only` | Present in mock spec, no real route registration found. |
| `PUT|PATCH|DELETE /api/irrigation/{pk}/` | `stub/contract-only` | Spec exists, but no backend public route is registered. |
## Deprecated Path Decisions
| Old path | Replacement |
|---|---|
| `/api/soil-data/ndvi-health/` | `/api/crop-health/ndvi-health/` |
| `/api/crop-simulation/*` as backend public routes | `/api/yield-harvest/*` |
| `/api/soile/*` as backend public routes | `/api/soil/*` |
+41
View File
@@ -0,0 +1,41 @@
FROM docker.iranserver.com/python:3.10
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Debian/debian mirrors for apt
RUN rm -f /etc/apt/sources.list /etc/apt/sources.list.d/* && \
printf '%s\n' \
'deb https://mirror-linux.runflare.com/debian/ bookworm main contrib non-free non-free-firmware' \
'deb https://mirror-linux.runflare.com/debian/ bookworm-updates main contrib non-free non-free-firmware' \
'deb https://mirror-linux.runflare.com/debian-security/ bookworm-security main contrib non-free non-free-firmware' \
> /etc/apt/sources.list
# System deps for MySQL client (pkg-config required by mysqlclient to find libs)
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev \
build-essential \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# Python mirrors
RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \
pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \
pip config --user set global.trusted-host package-mirror.liara.ir && \
pip config --user set global.trusted-host mirror2.chabokan.net && \
pip config --user set global.trusted-host mirror-pypi.runflare.com
RUN pip install -r requirements.txt
COPY entrypoint.sh /app/entrypoint.sh
COPY . .
EXPOSE 8000
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
+44
View File
@@ -0,0 +1,44 @@
ARG BASE_IMAGE=mirror-docker.runflare.com/library/python:3.10-slim-bookworm
FROM ${BASE_IMAGE}
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
ARG APT_MIRROR=mirror-linux.runflare.com/debian
ARG APT_SECURITY_MIRROR=mirror-linux.runflare.com/debian-security
ARG PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple
ARG PIP_TRUSTED_HOST=mirror-pypi.runflare.com
WORKDIR /app
# Route Debian packages through the requested mirror.
RUN printf '%s\n' \
"deb https://${APT_MIRROR} bookworm main contrib non-free non-free-firmware" \
"deb https://${APT_MIRROR} bookworm-updates main contrib non-free non-free-firmware" \
"deb https://${APT_SECURITY_MIRROR} bookworm-security main contrib non-free non-free-firmware" \
> /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
# Route Python packages through the requested mirror.
RUN pip config set global.index-url "${PIP_INDEX_URL}" \
&& pip config set global.trusted-host "${PIP_TRUSTED_HOST}" \
&& pip install -r /app/requirements.txt
COPY entrypoint.sh /app/entrypoint.sh
COPY . /app
RUN chmod +x /app/entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["sh", "/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccessControlConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "access_control"
@@ -0,0 +1 @@
GOLD_PLAN_CODE = "gold"
@@ -0,0 +1,96 @@
from django.http import JsonResponse
from django.utils.deprecation import MiddlewareMixin
from rest_framework.permissions import AllowAny
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
from farm_hub.models import FarmHub
from .services import (
AccessControlServiceUnavailable,
authorize_feature,
get_authorization_action,
get_request_data,
get_route_feature_code,
)
class RouteFeatureAccessMiddleware(MiddlewareMixin):
def process_view(self, request, view_func, view_args, view_kwargs):
view_class = getattr(view_func, "view_class", None)
if view_class is None:
return None
if self._allows_anonymous(view_class):
return None
user = self._get_authenticated_user(request)
if user is None:
return None
app_label = view_class.__module__.split(".", 1)[0]
feature_code = get_route_feature_code(app_label)
if not feature_code:
return None
farm_uuid = view_kwargs.get("farm_uuid") or request.GET.get("farm_uuid") or get_request_data(request).get("farm_uuid")
farm = None
if farm_uuid:
try:
farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related(
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
).get(farm_uuid=farm_uuid, owner=user)
except FarmHub.DoesNotExist:
return JsonResponse(
{"code": 403, "msg": f"Access to route feature `{feature_code}` is denied."},
status=403,
)
try:
allowed = authorize_feature(
farm=farm,
user=user,
feature_code=feature_code,
action=get_authorization_action(request.method),
route=request.path,
)
except AccessControlServiceUnavailable as exc:
return JsonResponse({"code": 503, "msg": str(exc)}, status=503)
if not allowed:
return JsonResponse(
{"code": 403, "msg": f"Access to route feature `{feature_code}` is denied."},
status=403,
)
request.route_feature_code = feature_code
return None
@staticmethod
def _allows_anonymous(view_class):
for permission_class in getattr(view_class, "permission_classes", []):
if permission_class is AllowAny:
return True
return False
@staticmethod
def _get_authenticated_user(request):
if getattr(request, "user", None) is not None and request.user.is_authenticated:
return request.user
authenticator = JWTAuthentication()
try:
auth_result = authenticator.authenticate(request)
except (InvalidToken, TokenError):
return None
if auth_result is None:
return None
user, _token = auth_result
request.user = user
request._cached_user = user
return user
@@ -0,0 +1,69 @@
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("farm_hub", "0006_seed_expanded_product_catalog"),
("device_hub", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="AccessFeature",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.CharField(db_index=True, max_length=150, unique=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, default="")),
("feature_type", models.CharField(choices=[("page", "Page"), ("widget", "Widget"), ("action", "Action")], default="page", max_length=32)),
("default_enabled", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "access_features", "ordering": ["name"]},
),
migrations.CreateModel(
name="SubscriptionPlan",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.CharField(db_index=True, max_length=100, unique=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, default="")),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={"db_table": "access_subscription_plans", "ordering": ["name"]},
),
migrations.CreateModel(
name="AccessRule",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("uuid", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
("code", models.CharField(db_index=True, max_length=150, unique=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, default="")),
("priority", models.PositiveIntegerField(default=100)),
("effect", models.CharField(choices=[("allow", "Allow"), ("deny", "Deny")], default="allow", max_length=16)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("farm_types", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.farmtype")),
("features", models.ManyToManyField(blank=True, related_name="rules", to="access_control.accessfeature")),
("products", models.ManyToManyField(blank=True, related_name="access_rules", to="farm_hub.product")),
("sensor_catalogs", models.ManyToManyField(blank=True, related_name="access_rules", to="device_hub.sensorcatalog")),
("subscription_plans", models.ManyToManyField(blank=True, related_name="access_rules", to="access_control.subscriptionplan")),
],
options={"db_table": "access_rules", "ordering": ["priority", "name"]},
),
]
@@ -0,0 +1,25 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("access_control", "0001_initial"),
("farm_hub", "0006_seed_expanded_product_catalog"),
]
operations = [
migrations.CreateModel(
name="FarmAccessProfile",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("profile_data", models.JSONField(blank=True, default=dict)),
("resolved_from_profile", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("farm", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="access_profile", to="farm_hub.farmhub")),
("subscription_plan", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="farm_access_profiles", to="access_control.subscriptionplan")),
],
options={"db_table": "farm_access_profiles", "ordering": ["-updated_at"]},
),
]
@@ -0,0 +1,9 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("access_control", "0002_link_subscription_plan_to_farm"),
]
operations = []
@@ -0,0 +1,9 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("access_control", "0003_seed_default_access_rules"),
]
operations = []
@@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("access_control", "0004_enable_default_feature_access"),
("farm_hub", "0007_farmhub_subscription_plan"),
]
operations = []
+104
View File
@@ -0,0 +1,104 @@
import uuid as uuid_lib
from django.db import models
class SubscriptionPlan(models.Model):
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.CharField(max_length=100, unique=True, db_index=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "access_subscription_plans"
ordering = ["name"]
def __str__(self):
return self.name
class AccessFeature(models.Model):
PAGE = "page"
WIDGET = "widget"
ACTION = "action"
FEATURE_TYPES = [
(PAGE, "Page"),
(WIDGET, "Widget"),
(ACTION, "Action"),
]
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.CharField(max_length=150, unique=True, db_index=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
feature_type = models.CharField(max_length=32, choices=FEATURE_TYPES, default=PAGE)
default_enabled = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "access_features"
ordering = ["name"]
def __str__(self):
return self.code
class AccessRule(models.Model):
ALLOW = "allow"
DENY = "deny"
EFFECTS = [
(ALLOW, "Allow"),
(DENY, "Deny"),
]
uuid = models.UUIDField(default=uuid_lib.uuid4, unique=True, editable=False, db_index=True)
code = models.CharField(max_length=150, unique=True, db_index=True)
name = models.CharField(max_length=255)
description = models.TextField(blank=True, default="")
priority = models.PositiveIntegerField(default=100)
effect = models.CharField(max_length=16, choices=EFFECTS, default=ALLOW)
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
features = models.ManyToManyField("AccessFeature", related_name="rules", blank=True)
subscription_plans = models.ManyToManyField("SubscriptionPlan", related_name="access_rules", blank=True)
farm_types = models.ManyToManyField("farm_hub.FarmType", related_name="access_rules", blank=True)
products = models.ManyToManyField("farm_hub.Product", related_name="access_rules", blank=True)
sensor_catalogs = models.ManyToManyField("device_hub.DeviceCatalog", related_name="access_rules", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "access_rules"
ordering = ["priority", "name"]
def __str__(self):
return self.code
class FarmAccessProfile(models.Model):
farm = models.OneToOneField("farm_hub.FarmHub", on_delete=models.CASCADE, related_name="access_profile")
subscription_plan = models.ForeignKey(
"SubscriptionPlan",
on_delete=models.SET_NULL,
related_name="farm_access_profiles",
null=True,
blank=True,
)
profile_data = models.JSONField(default=dict, blank=True)
resolved_from_profile = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_access_profiles"
ordering = ["-updated_at"]
def __str__(self):
return f"Access profile for {self.farm_id}"
@@ -0,0 +1,50 @@
from rest_framework.permissions import BasePermission
from farm_hub.models import FarmHub
from .services import AccessControlServiceUnavailable, authorize_feature, get_authorization_action, get_request_data
class FeatureAccessPermission(BasePermission):
message = "Access denied."
def has_permission(self, request, view):
feature_code = getattr(view, "required_feature_code", None)
if not feature_code:
return True
farm_uuid = (
view.kwargs.get("farm_uuid")
or request.query_params.get("farm_uuid")
or get_request_data(request).get("farm_uuid")
)
if not farm_uuid:
self.message = f"Access to feature `{feature_code}` is denied."
return False
try:
farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related(
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
).get(farm_uuid=farm_uuid, owner=request.user)
except FarmHub.DoesNotExist:
self.message = f"Access to feature `{feature_code}` is denied."
return False
try:
allowed = authorize_feature(
farm,
request.user,
feature_code,
get_authorization_action(request.method),
route=request.path,
)
except AccessControlServiceUnavailable as exc:
self.message = str(exc)
return False
if not allowed:
self.message = f"Access to feature `{feature_code}` is denied."
return allowed
@@ -0,0 +1,17 @@
from rest_framework import serializers
from .models import SubscriptionPlan
class SubscriptionPlanSerializer(serializers.ModelSerializer):
class Meta:
model = SubscriptionPlan
fields = ["uuid", "code", "name"]
class FeatureAuthorizationRequestSerializer(serializers.Serializer):
features = serializers.ListField(
child=serializers.CharField(),
allow_empty=False,
)
action = serializers.CharField(required=False, allow_blank=False, default="view")
+430
View File
@@ -0,0 +1,430 @@
import hashlib
import json
import logging
import time
from functools import lru_cache
from pathlib import Path
from urllib.parse import urljoin
import requests
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured
from django.http import QueryDict
from farm_hub.models import FarmHub
from config.observability import classify_exception, log_event, observe_operation, record_metric
from .catalog import GOLD_PLAN_CODE
from .models import AccessFeature, AccessRule, FarmAccessProfile, SubscriptionPlan
logger = logging.getLogger(__name__)
class AccessControlError(Exception):
pass
class AccessControlServiceUnavailable(AccessControlError):
pass
ACTION_MAP = {
"GET": "view",
"HEAD": "view",
"OPTIONS": "view",
"POST": "create",
"PUT": "edit",
"PATCH": "edit",
"DELETE": "delete",
}
def _get_authz_cache_timeout():
return int(getattr(settings, "ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT", 300))
@lru_cache(maxsize=1)
def load_route_feature_map():
feature_map_path = Path(settings.BASE_DIR) / "config" / "feature.json"
with feature_map_path.open("r", encoding="utf-8") as feature_map_file:
return json.load(feature_map_file)
def get_route_feature_code(app_label):
if not app_label:
return None
return load_route_feature_map().get(app_label)
def _get_authorization_cache_key(farm, user, features, action, route):
raw_key = json.dumps(
{
"farm_uuid": str(getattr(farm, "farm_uuid", "")),
"user_id": getattr(user, "id", None),
"features": sorted(features),
"action": action,
"route": route or "",
},
sort_keys=True,
)
digest = hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
return f"access-control:authz:{digest}"
def get_default_subscription_plan():
return SubscriptionPlan.objects.filter(is_active=True, metadata__is_default=True).order_by("name").first()
def get_effective_subscription_plan(farm):
if farm.subscription_plan_id:
return farm.subscription_plan
default_plan = get_default_subscription_plan()
if default_plan is not None:
return default_plan
return SubscriptionPlan.objects.filter(code=GOLD_PLAN_CODE, is_active=True).order_by("name").first()
def _match_rule(rule, farm, subscription_plan, product_ids, sensor_catalog_ids, sensor_catalog_codes):
if not rule.is_active:
return False
if rule.subscription_plans.exists() and (subscription_plan is None or not rule.subscription_plans.filter(pk=subscription_plan.pk).exists()):
return False
if rule.farm_types.exists() and not rule.farm_types.filter(pk=farm.farm_type_id).exists():
return False
if rule.products.exists() and not rule.products.filter(pk__in=product_ids).exists():
return False
if rule.sensor_catalogs.exists() and not rule.sensor_catalogs.filter(pk__in=sensor_catalog_ids).exists():
return False
metadata_sensor_codes = rule.metadata.get("sensor_catalog_codes", [])
if metadata_sensor_codes and not set(metadata_sensor_codes).intersection(sensor_catalog_codes):
return False
return True
def build_farm_access_profile(farm):
farm = FarmHub.objects.select_related("farm_type", "subscription_plan").prefetch_related(
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
).get(pk=farm.pk)
subscription_plan = get_effective_subscription_plan(farm)
product_ids = list(farm.products.values_list("id", flat=True))
sensor_catalog_ids = set()
sensor_catalog_codes = set()
for sensor in farm.sensors.all():
for catalog in sensor.get_device_catalogs():
sensor_catalog_ids.add(catalog.id)
sensor_catalog_codes.add(catalog.code)
features = {
feature.code: {
"name": feature.name,
"type": feature.feature_type,
"enabled": feature.default_enabled,
"source": "default" if feature.default_enabled else None,
}
for feature in AccessFeature.objects.filter(is_active=True)
}
matched_rules = []
rules = AccessRule.objects.filter(is_active=True).prefetch_related(
"features",
"subscription_plans",
"farm_types",
"products",
"sensor_catalogs",
).order_by("priority", "id")
for rule in rules:
if not _match_rule(rule, farm, subscription_plan, product_ids, sensor_catalog_ids, sensor_catalog_codes):
continue
matched_rules.append(
{
"code": rule.code,
"name": rule.name,
"effect": rule.effect,
"priority": rule.priority,
}
)
for feature in rule.features.all():
feature_state = features.setdefault(
feature.code,
{
"name": feature.name,
"type": feature.feature_type,
"enabled": feature.default_enabled,
"source": "default" if feature.default_enabled else None,
},
)
feature_state["enabled"] = rule.effect == AccessRule.ALLOW
feature_state["source"] = rule.code
profile = {
"farm_uuid": str(farm.farm_uuid),
"subscription_plan": None,
"features": features,
"matched_rules": matched_rules,
"resolved_from_profile": True,
}
if subscription_plan is not None:
profile["subscription_plan"] = {
"uuid": str(subscription_plan.uuid),
"code": subscription_plan.code,
"name": subscription_plan.name,
}
FarmAccessProfile.objects.update_or_create(
farm=farm,
defaults={
"subscription_plan": subscription_plan,
"profile_data": profile,
"resolved_from_profile": True,
},
)
return profile
def build_opa_resource(farm):
if farm is None:
return {
"farm_id": None,
"subscription_plan_codes": [],
"farm_types": [],
"crop_types": [],
"cultivation_types": [],
"sensor_codes": [],
"power_sensor": [],
"customization": [],
}
subscription_plan = get_effective_subscription_plan(farm)
sensor_codes = list(
farm.sensors.exclude(sensor_catalog__isnull=True).values_list("sensor_catalog__code", flat=True)
)
power_sensor = []
for sensor in farm.sensors.all():
if isinstance(sensor.power_source, dict):
power_type = sensor.power_source.get("type")
if power_type:
power_sensor.append(power_type)
return {
"farm_id": str(farm.farm_uuid),
"subscription_plan_codes": [subscription_plan.code] if subscription_plan else [],
"farm_types": [farm.farm_type.name] if farm.farm_type_id else [],
"crop_types": list(farm.products.values_list("name", flat=True)),
"cultivation_types": [],
"sensor_codes": sensor_codes,
"power_sensor": power_sensor,
"customization": [],
}
def build_opa_user(user):
return {
"id": getattr(user, "id", None),
"username": getattr(user, "username", ""),
"email": getattr(user, "email", ""),
"phone_number": getattr(user, "phone_number", ""),
"is_staff": bool(getattr(user, "is_staff", False)),
"is_superuser": bool(getattr(user, "is_superuser", False)),
"role": "farmer",
}
def get_authorization_action(method):
return ACTION_MAP.get(method.upper(), "view")
def _opa_url(path):
base_url = getattr(settings, "ACCESS_CONTROL_AUTHZ_BASE_URL", "").strip()
if not base_url:
raise ImproperlyConfigured("ACCESS_CONTROL_AUTHZ_BASE_URL is not configured.")
return urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
def build_authorization_input(farm, user, features, action, route=None):
return {
"user": build_opa_user(user),
"resource": build_opa_resource(farm),
"features": list(features),
"action": action,
"route": route,
}
def request_opa_batch_authorization(farm, user, features, action, route=None):
if not getattr(settings, "ACCESS_CONTROL_AUTHZ_ENABLED", True):
return {"decisions": {feature: True for feature in features}}
if not features:
return {"decisions": {}}
payload = {"input": build_authorization_input(farm, user, features, action, route=route)}
with observe_operation(source="backend.access_control", provider="opa", operation="batch_authorization"):
started_at = time.monotonic()
try:
response = requests.post(
_opa_url(settings.ACCESS_CONTROL_AUTHZ_BATCH_PATH),
json=payload,
timeout=settings.ACCESS_CONTROL_AUTHZ_TIMEOUT,
)
response.raise_for_status()
except requests.RequestException as exc:
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="opa batch authorization request failed",
source="backend.access_control",
provider="opa",
operation="batch_authorization",
result_status="error",
duration_ms=(time.monotonic() - started_at) * 1000,
error_code=failure.error_code,
route=route,
feature_count=len(features),
)
record_metric("access_control.opa.failure", error_code=failure.error_code)
raise AccessControlServiceUnavailable("OPA authorization service is unavailable.") from exc
try:
result = response.json().get("result", {})
except ValueError as exc:
log_event(
level=logging.ERROR,
message="opa batch authorization returned invalid json",
source="backend.access_control",
provider="opa",
operation="batch_authorization",
result_status="error",
duration_ms=(time.monotonic() - started_at) * 1000,
error_code="parse_error",
route=route,
feature_count=len(features),
status_code=response.status_code,
)
record_metric("access_control.opa.invalid_json")
raise AccessControlServiceUnavailable("OPA authorization service returned invalid JSON.") from exc
if not result:
record_metric("access_control.opa.empty_result")
logger.warning("OPA returned empty authorization result for route=%s", route)
return result
def normalize_opa_batch_result(data, features):
decisions = data.get("decisions")
if isinstance(decisions, dict):
return {feature: bool(decisions.get(feature, False)) for feature in features}
feature_results = data.get("features")
if isinstance(feature_results, dict):
normalized = {}
for feature in features:
feature_result = feature_results.get(feature, {})
if isinstance(feature_result, dict):
normalized[feature] = bool(feature_result.get("allow", False))
else:
normalized[feature] = bool(feature_result)
return normalized
allowed_features = data.get("allowed_features")
if isinstance(allowed_features, list):
allowed = set(allowed_features)
return {feature: feature in allowed for feature in features}
if isinstance(data, dict) and all(isinstance(value, bool) for value in data.values()):
return {feature: bool(data.get(feature, False)) for feature in features}
raise AccessControlServiceUnavailable("OPA authorization service returned an unsupported payload.")
def batch_authorize_features(farm, user, features, action, route=None):
if not features:
return {}
cache_key = _get_authorization_cache_key(farm, user, features, action, route)
try:
cached_result = cache.get(cache_key)
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.WARNING,
message="authorization cache read failed",
source="backend.access_control",
provider="cache",
operation="batch_authorize_features",
result_status="error",
error_code=failure.error_code,
route=route,
)
cached_result = None
if isinstance(cached_result, dict):
return {feature: bool(cached_result.get(feature, False)) for feature in features}
result = request_opa_batch_authorization(farm, user, features, action, route=route)
decisions = normalize_opa_batch_result(result, features)
try:
cache.set(cache_key, decisions, timeout=_get_authz_cache_timeout())
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.WARNING,
message="authorization cache write failed",
source="backend.access_control",
provider="cache",
operation="batch_authorize_features",
result_status="error",
error_code=failure.error_code,
route=route,
)
return decisions
def authorize_feature(farm, user, feature_code, action, route=None):
return batch_authorize_features(farm, user, [feature_code], action, route=route).get(feature_code, False)
def get_request_data(request):
request_data = getattr(request, "data", None)
if isinstance(request_data, QueryDict):
return request_data
if isinstance(request_data, dict):
return request_data
cached_body = getattr(request, "_access_control_request_data", None)
if isinstance(cached_body, dict):
return cached_body
content_type = (getattr(request, "content_type", "") or "").split(";")[0].strip().lower()
body = getattr(request, "body", b"") or b""
if not body:
return {}
if content_type == "application/json":
try:
parsed_body = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return {}
if isinstance(parsed_body, dict):
request._access_control_request_data = parsed_body
return parsed_body
return {}
return {}
+142
View File
@@ -0,0 +1,142 @@
from types import SimpleNamespace
from unittest.mock import patch
from django.test import RequestFactory, SimpleTestCase, override_settings
from account.views import ProfileView
from config.observability import METRICS
from .middleware import RouteFeatureAccessMiddleware
from .services import batch_authorize_features, build_authorization_input
TEST_CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "access-control-tests",
}
}
@override_settings(CACHES=TEST_CACHES, ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300)
class AccessControlServiceTests(SimpleTestCase):
def tearDown(self):
METRICS.clear()
def test_batch_authorize_features_uses_cache_for_same_route(self):
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=7)
with patch("access_control.services.request_opa_batch_authorization") as mock_request:
mock_request.return_value = {"decisions": {"farm_dashboard": True}}
first_result = batch_authorize_features(
farm=farm,
user=user,
features=["farm_dashboard"],
action="view",
route="/api/farm-dashboard/",
)
second_result = batch_authorize_features(
farm=farm,
user=user,
features=["farm_dashboard"],
action="view",
route="/api/farm-dashboard/",
)
self.assertEqual(first_result, {"farm_dashboard": True})
self.assertEqual(second_result, {"farm_dashboard": True})
self.assertEqual(mock_request.call_count, 1)
def test_build_authorization_input_includes_route(self):
user = SimpleNamespace(
id=3,
username="tester",
email="tester@example.com",
phone_number="09120000000",
is_staff=False,
is_superuser=False,
)
payload = build_authorization_input(
farm=None,
user=user,
features=["account_management"],
action="view",
route="/api/account/profile/",
)
self.assertEqual(payload["route"], "/api/account/profile/")
self.assertEqual(payload["resource"]["sensor_codes"], [])
def test_batch_authorize_features_supports_nested_opa_feature_payload(self):
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=9)
with patch("access_control.services.request_opa_batch_authorization") as mock_request:
mock_request.return_value = {
"features": {
"feature1": {"allow": True, "allow_rules": [], "deny_rules": []},
"feature2": {"allow": False, "allow_rules": [], "deny_rules": []},
}
}
result = batch_authorize_features(
farm=farm,
user=user,
features=["feature1", "feature2", "feature3"],
action="view",
route="/api/farm-dashboard/",
)
self.assertEqual(
result,
{
"feature1": True,
"feature2": False,
"feature3": False,
},
)
@patch("access_control.services.requests.post")
@override_settings(ACCESS_CONTROL_AUTHZ_ENABLED=True, ACCESS_CONTROL_AUTHZ_BASE_URL="https://opa.example", ACCESS_CONTROL_AUTHZ_BATCH_PATH="/v1/data/authz", ACCESS_CONTROL_AUTHZ_TIMEOUT=1)
def test_request_opa_batch_authorization_records_invalid_json_metric(self, mock_post):
response = mock_post.return_value
response.raise_for_status.return_value = None
response.json.side_effect = ValueError("bad json")
farm = SimpleNamespace(farm_uuid="farm-uuid")
user = SimpleNamespace(id=7, username="u", email="", phone_number="", is_staff=False, is_superuser=False)
with self.assertRaises(Exception):
batch_authorize_features(
farm=farm,
user=user,
features=["farm_dashboard"],
action="view",
route="/api/farm-dashboard/",
)
self.assertEqual(METRICS["access_control.opa.invalid_json"], 1)
class RouteFeatureAccessMiddlewareTests(SimpleTestCase):
def test_middleware_passes_route_feature_and_method_to_service(self):
factory = RequestFactory()
request = factory.patch("/api/account/profile/")
request.user = SimpleNamespace(is_authenticated=True, id=11)
middleware = RouteFeatureAccessMiddleware(lambda req: None)
view = ProfileView.as_view()
with patch("access_control.middleware.authorize_feature", return_value=True) as mock_authorize:
response = middleware.process_view(request, view, (), {})
self.assertIsNone(response)
mock_authorize.assert_called_once_with(
farm=None,
user=request.user,
feature_code="account_management",
action="edit",
route="/api/account/profile/",
)
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .views import FarmFeatureAuthorizationView
urlpatterns = [
path("farms/<uuid:farm_uuid>/authorize/", FarmFeatureAuthorizationView.as_view(), name="farm-feature-authorization"),
]
+74
View File
@@ -0,0 +1,74 @@
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
from config.swagger import code_response
from farm_hub.models import FarmHub
from .serializers import FeatureAuthorizationRequestSerializer
from .services import AccessControlServiceUnavailable, request_opa_batch_authorization
class FarmFeatureAuthorizationView(APIView):
permission_classes = [IsAuthenticated]
@extend_schema(
tags=["Access Control"],
parameters=[
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH, default="11111111-1111-1111-1111-111111111111"),
],
request=FeatureAuthorizationRequestSerializer,
responses={200: code_response("FarmFeatureAuthorizationResponse")},
)
def post(self, request, farm_uuid):
serializer = FeatureAuthorizationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
farm = FarmHub.objects.select_related("subscription_plan", "farm_type").prefetch_related(
"products",
"sensors",
"sensors__sensor_catalog",
"sensors__device_catalogs",
).get(
farm_uuid=farm_uuid,
owner=request.user,
)
except FarmHub.DoesNotExist:
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
try:
opa_result = request_opa_batch_authorization(
farm=farm,
user=request.user,
features=serializer.validated_data["features"],
action=serializer.validated_data["action"],
route=request.path,
)
except AccessControlServiceUnavailable as exc:
return Response(
{"code": 503, "msg": str(exc)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return Response(
{
"code": 200,
"msg": "success",
"data": {
"farm_uuid": str(farm.farm_uuid),
"user": {
"id": request.user.id,
"username": request.user.username,
"email": request.user.email,
"phone_number": getattr(request.user, "phone_number", ""),
},
"features": serializer.validated_data["features"],
"action": serializer.validated_data["action"],
"decision": opa_result,
},
},
status=status.HTTP_200_OK,
)
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "account"
+25
View File
@@ -0,0 +1,25 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q
User = get_user_model()
class MultiFieldBackend(ModelBackend):
"""Authenticate with username, email, or phone_number."""
def authenticate(self, request, username=None, password=None, **kwargs):
if username is None or password is None:
return None
try:
user = User.objects.get(
Q(username=username) | Q(email=username) | Q(phone_number=username)
)
except (User.DoesNotExist, User.MultipleObjectsReturned):
User().set_password(password)
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,24 @@
from django.core.management.base import BaseCommand, CommandError
from account.seeds import ADMIN_USER_DATA
from farm_hub.seeds import seed_admin_farm
class Command(BaseCommand):
help = "Create or update the default admin user through the admin farm seeder."
def handle(self, *args, **options):
try:
farm, created = seed_admin_farm()
except ValueError as exc:
raise CommandError(str(exc)) from exc
action = "created" if created else "updated"
user = farm.owner
self.stdout.write(
self.style.SUCCESS(
f"Admin user {action}: username={user.username}, email={user.email}, "
f"phone_number={user.phone_number}, password={ADMIN_USER_DATA['password']}, "
f"farm_uuid={farm.farm_uuid}"
)
)
@@ -0,0 +1,134 @@
# Generated by Django 5.2.11 on 2026-03-18 14:09
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"phone_number",
models.CharField(db_index=True, max_length=32, unique=True),
),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"db_table": "users",
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.1.15 on 2026-03-23 18:48
import account.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AlterModelManagers(
name='user',
managers=[
('objects', account.models.CustomUserManager()),
],
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(db_index=True, max_length=254, unique=True, verbose_name='email address'),
),
]
+37
View File
@@ -0,0 +1,37 @@
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as BaseUserManager
from django.db.models import Q
from django.db import models
class CustomUserManager(BaseUserManager):
"""Manager that allows lookup by username, email, or phone_number."""
def get_by_natural_key(self, username):
return self.get(
Q(username=username) | Q(email=username) | Q(phone_number=username)
)
class User(AbstractUser):
phone_number = models.CharField(
max_length=32,
unique=True,
db_index=True,
)
email = models.EmailField(
"email address",
unique=True,
db_index=True,
)
USERNAME_FIELD = "username"
REQUIRED_FIELDS = ["email", "phone_number"]
objects = CustomUserManager()
class Meta:
db_table = "users"
def __str__(self):
return self.username
@@ -0,0 +1,146 @@
{
"info": {
"name": "Account",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "Account API. GET list, GET by uuid (detail), POST add, PATCH update, DELETE delete, PATCH profile. Authenticated user required."
},
"item": [
{
"name": "Update profile",
"request": {
"method": "PATCH",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"body": {
"mode": "raw",
"raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"email\": \"\"\n}"
},
"url": "{{baseUrl}}/api/account/profile/",
"description": "Update current user profile (first_name, last_name, email). Returns UpdateProfileResponse."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"id\": 0,\n \"username\": \"\",\n \"email\": \"\",\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phone_number\": \"\"\n }\n}"
}
]
},
{
"name": "List accounts",
"request": {
"method": "GET",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"url": "{{baseUrl}}/api/account/",
"description": "Get list of accounts. GET on base route."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {}\n}"
}
]
},
{
"name": "Get account detail (by uuid)",
"request": {
"method": "GET",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"url": "{{baseUrl}}/api/account/{{uuid}}/",
"description": "Get one account by uuid in path."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {}\n}"
}
]
},
{
"name": "Add account",
"request": {
"method": "POST",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"body": {
"mode": "raw",
"raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phones\": []\n}"
},
"url": "{{baseUrl}}/api/account/",
"description": "Add a new account. POST on base route."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}"
}
]
},
{
"name": "Update account",
"request": {
"method": "PATCH",
"header": [
{"key": "Content-Type", "value": "application/json"},
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"body": {
"mode": "raw",
"raw": "{\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phones\": []\n}"
},
"url": "{{baseUrl}}/api/account/{{uuid}}/",
"description": "Update account by uuid in path. PATCH."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}"
}
]
},
{
"name": "Delete account",
"request": {
"method": "DELETE",
"header": [
{"key": "Authorization", "value": "Bearer {{token}}", "description": "Required: user must be authenticated"}
],
"url": "{{baseUrl}}/api/account/{{uuid}}/",
"description": "Delete account by uuid in path."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\"\n}"
}
]
}
],
"variable": [
{"key": "baseUrl", "value": "http://localhost:8000"},
{"key": "token", "value": ""},
{"key": "uuid", "value": "550e8400-e29b-41d4-a716-446655440000"}
]
}
+44
View File
@@ -0,0 +1,44 @@
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
ADMIN_USER_DATA = {
"username": "admin",
"email": "admin@example.com",
"phone_number": "0912345678",
"first_name": "admin",
"last_name": "admin",
"password": "admin123456",
}
@transaction.atomic
def seed_admin_user():
user_model = get_user_model()
lookup = (
Q(username=ADMIN_USER_DATA["username"])
| Q(email=ADMIN_USER_DATA["email"])
| Q(phone_number=ADMIN_USER_DATA["phone_number"])
)
matched_users = list(user_model.objects.filter(lookup).order_by("id"))
if len(matched_users) > 1:
raise ValueError(
"Multiple users matched the admin seeder lookup. Resolve duplicates before seeding."
)
created = not matched_users
user = matched_users[0] if matched_users else user_model()
user.username = ADMIN_USER_DATA["username"]
user.email = ADMIN_USER_DATA["email"]
user.phone_number = ADMIN_USER_DATA["phone_number"]
user.first_name = ADMIN_USER_DATA["first_name"]
user.last_name = ADMIN_USER_DATA["last_name"]
user.is_staff = True
user.is_superuser = True
user.is_active = True
user.set_password(ADMIN_USER_DATA["password"])
user.save()
return user, created
+16
View File
@@ -0,0 +1,16 @@
"""
Account API serializers.
UpdateProfile request/response shapes aligned with frontend types.
"""
from rest_framework import serializers
class UpdateProfileSerializer(serializers.Serializer):
"""
Request body for PATCH /api/account/profile/ (UpdateProfilePayload).
"""
first_name = serializers.CharField(max_length=150, required=False, allow_blank=True)
last_name = serializers.CharField(max_length=150, required=False, allow_blank=True)
email = serializers.EmailField(required=False, allow_blank=True)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import AccountView, ProfileView
urlpatterns = [
path("profile/", ProfileView.as_view(), name="profile-update"),
# path("<uuid:uuid>/", AccountView.as_view(), name="account-detail"),
# path("", AccountView.as_view(), name="account-list"),
]
+160
View File
@@ -0,0 +1,160 @@
"""
Account API module.
CRUD endpoints for user account profile.
"""
from rest_framework import serializers
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_view
from auth.serializers import AuthUserSerializer
from config.swagger import code_response
from .serializers import UpdateProfileSerializer
def _auth_user_to_data(user):
"""Build AuthUser-shaped dict from Django User."""
if user is None or not getattr(user, "pk", None):
return None
return {
"id": user.id,
"username": getattr(user, "username", "") or "",
"email": getattr(user, "email", "") or "",
"first_name": getattr(user, "first_name", "") or "",
"last_name": getattr(user, "last_name", "") or "",
"phone_number": getattr(user, "phone_number", "") or "",
}
@extend_schema_view(
patch=extend_schema(
tags=["Account"],
request=UpdateProfileSerializer,
responses={200: code_response("ProfileUpdateResponse", data=AuthUserSerializer())},
),
)
class ProfileView(APIView):
"""
PATCH /api/account/profile/
UpdateProfilePayload: first_name, last_name, email.
UpdateProfileResponse: code, msg, data (AuthUser).
"""
permission_classes = [IsAuthenticated]
def patch(self, request):
serializer = UpdateProfileSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
user = request.user
for field in ("first_name", "last_name", "email"):
if field in serializer.validated_data:
setattr(user, field, serializer.validated_data[field])
user.save(update_fields=[
f for f in ("first_name", "last_name", "email")
if f in serializer.validated_data
])
data = _auth_user_to_data(user)
if data is None:
data = {
"id": 0,
"username": "",
"email": "",
"first_name": "",
"last_name": "",
"phone_number": "",
}
return Response(
{"code": 200, "msg": "success", "data": data},
status=status.HTTP_200_OK,
)
@extend_schema_view(
get=extend_schema(
tags=["Account"],
responses={200: code_response("AccountGetResponse", data=serializers.JSONField())},
),
post=extend_schema(
tags=["Account"],
request=OpenApiTypes.OBJECT,
responses={200: code_response("AccountCreateResponse")},
),
patch=extend_schema(
tags=["Account"],
request=OpenApiTypes.OBJECT,
responses={200: code_response("AccountUpdateResponse")},
),
delete=extend_schema(
tags=["Account"],
responses={200: code_response("AccountDeleteResponse")},
),
)
class AccountView(APIView):
"""
Account CRUD endpoints. Dispatch by HTTP method and path (uuid for detail/update/delete).
No processing, validation, or transformation is applied to any input.
All endpoints return HTTP 200 only. Response format: {"code": 200, "msg": "success"} or {"code": 200, "msg": "success", "data": {}}.
Routes:
- GET "" → List: returns status "success", data {}.
- GET "<uuid>/" → Detail: uuid (path). Returns status "success", data {}.
- POST "" → Create: body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field.
- PATCH "<uuid>/" → Update: uuid (path), body/query may contain first_name, last_name, phones; not used. Returns status "success". No data field.
- DELETE "<uuid>/" → Delete: uuid (path). Returns status "success". No data field.
"""
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
"""
List or detail account.
List (GET on base URL):
- Input parameters: none required. Query params if sent are not processed.
- Response: {"code": 200, "msg": "success", "data": {}}.
- No processing or validation is performed on inputs.
Detail (GET on <uuid>/):
- Input parameters: uuid (path, UUID). Description: identifier for the account resource.
- Response: {"code": 200, "msg": "success", "data": {}}.
- No processing or validation is performed on inputs.
"""
return Response({"code": 200, "msg": "success", "data": {}}, status=status.HTTP_200_OK)
def post(self, request, *args, **kwargs):
"""
Create account.
Input parameters (body, JSON): first_name (string), last_name (string), phones (array of strings).
Description: intended for user first name, last name, and phone numbers. Not processed or validated.
Response: {"code": 200, "msg": "success"}. No data field.
No processing or validation is performed on inputs.
"""
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
def patch(self, request, *args, **kwargs):
"""
Update account.
Input parameters: uuid (path, UUID), body (JSON) may contain first_name, last_name, phones.
Description: identifier in path; body fields intended for updated profile. Not processed or validated.
Response: {"code": 200, "msg": "success"}. No data field.
No processing or validation is performed on inputs.
"""
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
def delete(self, request, *args, **kwargs):
"""
Delete account.
Input parameters: uuid (path, UUID). Description: identifier for the account resource to delete.
Response: {"code": 200, "msg": "success"}. No data field.
No processing or validation is performed on inputs.
"""
return Response({"code": 200, "msg": "success"}, status=status.HTTP_200_OK)
@@ -0,0 +1,232 @@
# گزارش تغییرات API در ۶ کامیت اخیر
این فایل تغییرات مربوط به سه فایل زیر را نسبت به **۶ کامیت قبل** (`HEAD~6`) مستند می‌کند:
- `irrigation/urls.py`
- `fertilization/apps.py`
- `farm_ai_assistant/views.py`
## بازه مقایسه
- مبدا: `HEAD~6`
- مقصد: `HEAD`
## نتیجه سریع
- در `irrigation/urls.py`، API آبیاری از مدل دارای endpoint بررسی وضعیت task فاصله گرفته و دو endpoint جدید برای لیست روش‌های آبیاری و water stress اضافه شده است.
- در `fertilization/apps.py`، در این بازه **هیچ تغییری** ثبت نشده است.
- در `farm_ai_assistant/views.py`، API چت از flow مبتنی بر task/polling به flow مستقیم request/response تغییر کرده و پشتیبانی از `history`، `image_urls` و ورودی‌های multipart/JSON بهتر شده است.
## 1) تغییرات `irrigation/urls.py`
### وضعیت قبلی
مسیرهای زیر وجود داشتند:
- `config/` -> `ConfigView`
- `recommend/` -> `RecommendView`
- `recommend/status/<str:task_id>/` -> `RecommendTaskStatusView`
### وضعیت فعلی
مسیرهای فعلی:
- `` -> `IrrigationMethodListView`
- `config/` -> `ConfigView`
- `recommend/` -> `RecommendView`
- `water-stress/` -> `WaterStressView`
### تغییرات دقیق
#### الف) اضافه شدن endpoint ریشه برای لیست روش‌های آبیاری
مسیر جدید:
- `GET irrigation/`
- view: `IrrigationMethodListView`
- name: `irrigation-method-list`
اثر:
- حالا این app علاوه بر recommendation، یک endpoint مستقل برای دریافت لیست روش‌های آبیاری هم دارد.
#### ب) حذف endpoint بررسی وضعیت task
مسیر حذف‌شده:
- `recommend/status/<str:task_id>/`
- view: `RecommendTaskStatusView`
- name: `irrigation-recommendation-task-status`
اثر:
- دیگر API رسمی‌ای در `urls.py` برای polling وضعیت task recommendation تعریف نشده است.
- این تغییر از نظر معماری شبیه حذف flow تسک‌محور در بخش AI assistant است.
#### ج) اضافه شدن endpoint جدید water stress
مسیر جدید:
- `POST irrigation/water-stress/`
- view: `WaterStressView`
- name: `irrigation-water-stress`
اثر:
- یک قابلیت جدید در API آبیاری اضافه شده که به‌صورت جداگانه water stress را محاسبه/برمی‌گرداند.
### چیزهایی که تغییر نکرده‌اند
- `config/`
- `recommend/`
## 2) تغییرات `fertilization/apps.py`
در بازه `HEAD~6..HEAD` برای این فایل **هیچ diffای وجود ندارد**.
### وضعیت فعلی و قبلی یکسان است
مقدارهای مهم بدون تغییر مانده‌اند:
- `default_auto_field = "django.db.models.BigAutoField"`
- `name = "fertilization"`
- `verbose_name = "Fertilization Recommendation"`
### نتیجه
- از نظر ثبت app در Django، در این ۶ کامیت اخیر تغییری در `fertilization/apps.py` اعمال نشده است.
- اگر منظورت بررسی APIهای recommendation بوده، این فایل خودش route یا view API ندارد و فقط تنظیمات app را نگه می‌دارد.
## 3) تغییرات `farm_ai_assistant/views.py`
بزرگ‌ترین تغییرات این بازه در این فایل اتفاق افتاده است.
### خلاصه معماری
API چت از این مدل:
1. ثبت درخواست چت
2. ساخت task در سرویس AI
3. دریافت `task_id`
4. polling برای status
به این مدل تغییر کرده:
1. ارسال مستقیم درخواست چت
2. دریافت مستقیم پاسخ assistant
3. ذخیره هم‌زمان پیام کاربر و پیام assistant
### تغییرات مهم
#### الف) حذف flow مبتنی بر task
کلاس‌های زیر حذف شده‌اند:
- `ChatTaskCreateView`
- `ChatTaskStatusView`
رفتار قبلی:
- `ChatTaskCreateView` درخواست را به endpoint زیر در سرویس بیرونی می‌فرستاد:
- `/rag/chat/generate`
- سپس `ChatTaskStatusView` وضعیت task را از endpoint زیر می‌گرفت:
- `/tasks/{task_id}/status`
رفتار جدید:
- این دو کلاس حذف شده‌اند و flow task-based از این فایل کنار رفته است.
اثر:
- دیگر پاسخ چت در دو مرحله create/status مدیریت نمی‌شود.
- polling مبتنی بر `task_id` از منطق این viewها حذف شده است.
#### ب) مستقیم شدن درخواست چت در `ChatView`
در `ChatView.post`، اکنون درخواست مستقیم به سرویس AI ارسال می‌شود:
- endpoint جدید آداپتر: `/api/rag/chat/`
این یعنی:
- به‌جای submit task و پیگیری وضعیت آن، پاسخ assistant در همان request برگردانده می‌شود.
#### ج) تغییر مدل ورودی از `content` به `query`
در payload ارسالی به adapter، حالا فیلد اصلی متن کاربر این است:
- `query`
در نسخه قبلی، از `content` برای ساخت payload استفاده می‌شد.
الان:
- `content` در منطق اصلی جای خود را به `query` داده است.
اثر:
- کلاینتی که هنوز payload قدیمی مبتنی بر `content` می‌فرستد، باید با قرارداد جدید view/serializer هماهنگ شود.
#### د) پشتیبانی از `history`
قابلیت جدید:
- history پیام‌ها به‌صورت ساختاریافته دریافت، نرمال‌سازی و به adapter ارسال می‌شود.
تغییرات مرتبط:
- اضافه شدن `_serialize_history_messages`
- اضافه شدن `_merge_history`
- اضافه شدن `history` به payload ارسالی به سرویس بیرونی
اثر:
- API حالا می‌تواند context مکالمه را شفاف‌تر و مستقل از صرفاً conversation id به سرویس AI بفرستد.
- اگر history در ورودی باشد، استفاده می‌شود؛ در غیر این صورت از پیام‌های conversation برای ساخت history استفاده می‌شود.
#### ه) پشتیبانی از `image_urls` و فایل آپلودی
قابلیت‌های جدید:
- `image_urls` به payload اضافه شده است.
- فایل‌های آپلودشده نیز جمع‌آوری و به payload الصاق می‌شوند.
تغییرات مرتبط:
- اضافه شدن `_attach_uploaded_files`
- تبدیل `history` و `image_urls` به JSON string هنگام multipart submission
- ادغام `image_urls` و `images` در ذخیره پیام کاربر
اثر:
- API چت حالا هم لینک تصویر و هم فایل تصویر آپلودی را پشتیبانی می‌کند.
- درخواست‌های multipart برای سناریوهای image-based chat بهتر پشتیبانی می‌شوند.
#### و) مدیریت بهتر JSON و خطای Parse
قابلیت‌های جدید:
- import شدن `ParseError`
- اضافه شدن `_parse_json_array`
- اضافه شدن `_prepare_chat_input`
- پاسخ ۴۰۰ اختصاصی برای JSON نامعتبر
رفتار جدید:
- اگر body نامعتبر باشد، API این پیام را برمی‌گرداند:
- invalid JSON / extra trailing characters
- فیلدهایی مثل `history` و `image_urls` اگر به شکل string JSON بیایند، parse می‌شوند.
اثر:
- endpoint چت در برابر فرمت‌های مختلف درخواست مقاوم‌تر شده است.
- احتمال خطا برای کلاینت‌هایی که multipart یا JSON string می‌فرستند کمتر شده است.
#### ز) تغییر در ساخت conversation جدید
رفتار جدید:
- عنوان conversation با `_generate_conversation_title(query)` ساخته می‌شود.
- اگر query خالی باشد، عنوان پیش‌فرض `Image` می‌شود.
- برای conversation جدید، `farm_context` به‌صورت خالی `{}` ست می‌شود.
رفتار حذف‌شده:
- دیگر `farm_context` و `title` مستقیماً از payload برای update/create conversation استفاده نمی‌شوند.
- منطق قبلی که conversation موجود را با `farm_context` یا `title` آپدیت می‌کرد حذف شده است.
اثر:
- کنترل عنوان مکالمه بیشتر به منطق داخلی view منتقل شده است.
- payload کلاینت اختیار کمتری روی title/farm_context conversation دارد.
#### ح) بهبود نرمال‌سازی پاسخ assistant
در نرمال‌سازی sectionها، این کلیدها هم پشتیبانی می‌شوند:
- `primaryAction`
- `validityPeriod`
اثر:
- ساختار response assistant برای UIهای غنی‌تر آماده‌تر شده است.
#### ط) حذف وابستگی به mock chat response
حذف شده:
- `CHAT_RESPONSE_DATA`
- متد `_build_mock_assistant_payload`
اثر:
- منطق چت بیشتر به پاسخ واقعی adapter متکی شده و از mock response داخلی فاصله گرفته است.
#### ی) logging بیشتر برای دیباگ integration
موارد جدید:
- import شدن `logging`
- تعریف `logger`
- ثبت log برای response adapter و برخی وضعیت‌های payload parsing/extraction
اثر:
- عیب‌یابی integration با سرویس AI ساده‌تر شده است.
## جمع‌بندی نهایی
در این ۶ کامیت اخیر:
- `irrigation/urls.py`
- endpoint بررسی وضعیت task حذف شده
- endpoint ریشه برای لیست روش‌های آبیاری اضافه شده
- endpoint جدید `water-stress/` اضافه شده
- `fertilization/apps.py`
- هیچ تغییری نداشته است
- `farm_ai_assistant/views.py`
- flow چت task-based حذف شده
- درخواست مستقیم به `/api/rag/chat/` جایگزین شده
- پشتیبانی از `history`، `image_urls` و فایل آپلودی اضافه شده
- مدیریت JSON/multipart بهتر شده
- title conversation از `query` ساخته می‌شود
- نرمال‌سازی response assistant گسترش یافته است
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "auth"
label = "auth_api" # Avoid clash with django.contrib.auth (label "auth")
+59
View File
@@ -0,0 +1,59 @@
{
"info": {
"name": "Auth",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "Auth API. Request OTP, Verify OTP."
},
"item": [
{
"name": "Request OTP",
"request": {
"method": "POST",
"header": [
{"key": "Content-Type", "value": "application/json"}
],
"body": {
"mode": "raw",
"raw": "{\n \"phone_number\": \"\"\n}"
},
"url": "{{baseUrl}}/api/auth/request-otp/",
"description": "Request OTP for the given phone number. In DEBUG mode, response includes debug_note."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"token\": \"\"\n}"
}
]
},
{
"name": "Verify OTP",
"request": {
"method": "POST",
"header": [
{"key": "Content-Type", "value": "application/json"}
],
"body": {
"mode": "raw",
"raw": "{\n \"token\": \"\",\n \"otp_code\": \"\"\n}"
},
"url": "{{baseUrl}}/api/auth/verify-otp/",
"description": "Verify OTP with token from request-otp and otp_code sent to user."
},
"response": [
{
"name": "Success",
"status": "OK",
"code": 200,
"body": "{\n \"code\": 200,\n \"msg\": \"success\",\n \"data\": {\n \"id\": 0,\n \"username\": \"\",\n \"email\": \"\",\n \"first_name\": \"\",\n \"last_name\": \"\",\n \"phone_number\": \"\"\n },\n \"token\": \"\"\n}"
}
]
}
],
"variable": [
{"key": "baseUrl", "value": "http://localhost:8000"},
{"key": "token", "value": ""}
]
}
+49
View File
@@ -0,0 +1,49 @@
from rest_framework import serializers
# --- Register ---
class RegisterSerializer(serializers.Serializer):
"""Request body for POST /api/auth/register/."""
username = serializers.CharField(max_length=150)
email = serializers.EmailField()
phone_number = serializers.CharField(max_length=32)
password = serializers.CharField(min_length=8, write_only=True)
first_name = serializers.CharField(max_length=150, required=False, default="")
last_name = serializers.CharField(max_length=150, required=False, default="")
# --- Login ---
class LoginSerializer(serializers.Serializer):
"""Request body for POST /api/auth/login/.
identifier can be username, email, or phone_number."""
identifier = serializers.CharField()
password = serializers.CharField(min_length=8, write_only=True)
# --- RequestOTP (request-otp/) ---
class RequestOTPSerializer(serializers.Serializer):
"""Request body for POST /api/auth/request-otp/."""
phone_number = serializers.CharField(max_length=32)
# --- VerifyOTP (verify-otp/) ---
class VerifyOTPSerializer(serializers.Serializer):
"""Request body for POST /api/auth/verify-otp/."""
token = serializers.CharField()
otp_code = serializers.CharField(max_length=10)
# --- AuthUser (used in VerifyOTPResponse and UpdateProfileResponse) ---
class AuthUserSerializer(serializers.Serializer):
"""User data returned in auth/account responses."""
id = serializers.IntegerField()
username = serializers.CharField()
email = serializers.EmailField(allow_blank=True)
first_name = serializers.CharField()
last_name = serializers.CharField()
phone_number = serializers.CharField()
+59
View File
@@ -0,0 +1,59 @@
import http.client
import json
import logging
from django.conf import settings
logger = logging.getLogger(__name__)
def send_otp_sms(phone_number: str, otp_code: str) -> bool:
"""Send OTP code via SMS.ir bulk API.
Returns True on success, False on failure.
"""
api_key = getattr(settings, "SMS_IR_API_KEY", "")
line_number = getattr(settings, "SMS_IR_LINE_NUMBER", 300000000000)
if not api_key:
logger.error("SMS_IR_API_KEY is not configured.")
return False
message_text = f"کد تایید شما: {otp_code}"
payload = json.dumps({
"lineNumber": line_number,
"messageText": message_text,
"mobiles": [phone_number],
"sendDateTime": None,
})
headers = {
"X-API-KEY": api_key,
"Content-Type": "application/json",
}
try:
conn = http.client.HTTPSConnection("api.sms.ir")
conn.request("POST", "/v1/send/bulk", payload, headers)
res = conn.getresponse()
data = res.read().decode("utf-8")
conn.close()
response = json.loads(data)
status_code = response.get("status")
if res.status == 200 and status_code == 1:
logger.info("SMS sent successfully to %s", phone_number)
return True
logger.warning(
"SMS.ir returned unexpected response: HTTP %s, body: %s",
res.status,
data,
)
return False
except Exception:
logger.exception("Failed to send SMS to %s", phone_number)
return False
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .views import AuthenticationView, LoginView, RegisterView
urlpatterns = [
path("register/", RegisterView.as_view(), name="register"),
path("login/", LoginView.as_view(), name="login"),
# path("request-otp/", AuthenticationView.as_view(), name="request-otp"),
# path("verify-otp/", AuthenticationView.as_view(), name="verify-otp"),
]
+208
View File
@@ -0,0 +1,208 @@
import secrets
from django.conf import settings
from django.contrib.auth import authenticate
from django.core.cache import cache
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.db import IntegrityError
from rest_framework import serializers, status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework_simplejwt.tokens import AccessToken
from account.models import User
from config.swagger import code_response
from .serializers import (
AuthUserSerializer,
LoginSerializer,
RegisterSerializer,
RequestOTPSerializer,
VerifyOTPSerializer,
)
from .sms_service import send_otp_sms
OTP_TTL_SECONDS = 300
OTP_SIGNER = TimestampSigner(salt="auth.otp")
def _auth_user_to_data(user):
if user is None or not getattr(user, "pk", None):
return None
return {
"id": user.id,
"username": getattr(user, "username", "") or "",
"email": getattr(user, "email", "") or "",
"first_name": getattr(user, "first_name", "") or "",
"last_name": getattr(user, "last_name", "") or "",
"phone_number": getattr(user, "phone_number", "") or "",
}
def _issue_token(user):
return str(AccessToken.for_user(user))
@extend_schema_view(
post=extend_schema(
tags=["Authentication"],
request=RegisterSerializer,
responses={
201: code_response("RegisterResponse", data=AuthUserSerializer(), token=True),
400: code_response("RegisterErrorResponse"),
},
),
)
class RegisterView(APIView):
permission_classes = [AllowAny]
def post(self, request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
user = User.objects.create_user(
username=data["username"],
email=data["email"],
phone_number=data["phone_number"],
password=data["password"],
first_name=data.get("first_name", ""),
last_name=data.get("last_name", ""),
)
except IntegrityError as exc:
msg = str(exc).lower()
if "username" in msg:
detail = "A user with this username already exists."
elif "email" in msg:
detail = "A user with this email already exists."
elif "phone_number" in msg:
detail = "A user with this phone number already exists."
else:
detail = "A user with these credentials already exists."
return Response({"code": 400, "msg": detail}, status=status.HTTP_400_BAD_REQUEST)
return Response(
{
"code": 201,
"msg": "success",
"data": _auth_user_to_data(user),
"token": _issue_token(user),
},
status=status.HTTP_201_CREATED,
)
@extend_schema_view(
post=extend_schema(
tags=["Authentication"],
request=LoginSerializer,
responses={
200: code_response("LoginResponse", data=AuthUserSerializer(), token=True),
401: code_response("LoginErrorResponse"),
},
),
)
class LoginView(APIView):
permission_classes = [AllowAny]
def post(self, request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identifier = serializer.validated_data["identifier"]
password = serializer.validated_data["password"]
user = authenticate(request, username=identifier, password=password)
if user is None:
return Response({"code": 401, "msg": "Invalid credentials."}, status=status.HTTP_401_UNAUTHORIZED)
return Response(
{
"code": 200,
"msg": "success",
"data": _auth_user_to_data(user),
"token": _issue_token(user),
},
status=status.HTTP_200_OK,
)
@extend_schema_view(
post=extend_schema(
tags=["Authentication"],
request=RequestOTPSerializer,
responses={
200: code_response(
"RequestOtpResponse",
extra_fields={
"token": serializers.CharField(),
"sms_warning": serializers.CharField(required=False),
"debug_otp": serializers.CharField(required=False),
},
),
400: code_response("RequestOtpErrorResponse"),
},
),
)
class AuthenticationView(APIView):
permission_classes = [AllowAny]
def post(self, request):
if "verify-otp" in request.path:
return self._verify_otp(request)
return self._request_otp(request)
def _request_otp(self, request):
serializer = RequestOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
phone_number = serializer.validated_data["phone_number"].strip()
otp_code = f"{secrets.randbelow(1_000_000):06d}"
cache.set(f"otp_code:{phone_number}", otp_code, timeout=OTP_TTL_SECONDS)
otp_token = OTP_SIGNER.sign(phone_number)
sms_sent = send_otp_sms(phone_number, otp_code)
payload = {"code": 200, "msg": "success", "token": otp_token}
if not sms_sent:
payload["sms_warning"] = "SMS delivery failed; OTP stored server-side."
if settings.DEBUG:
payload["debug_otp"] = otp_code
return Response(payload, status=status.HTTP_200_OK)
def _verify_otp(self, request):
serializer = VerifyOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
token = serializer.validated_data["token"]
otp_code = serializer.validated_data["otp_code"].strip()
try:
phone_number = OTP_SIGNER.unsign(token, max_age=OTP_TTL_SECONDS)
except (BadSignature, SignatureExpired):
return Response({"code": 400, "msg": "Token is invalid or expired."}, status=status.HTTP_400_BAD_REQUEST)
cached_otp = cache.get(f"otp_code:{phone_number}")
if cached_otp is None or cached_otp != otp_code:
return Response({"code": 400, "msg": "OTP code is invalid or expired."}, status=status.HTTP_400_BAD_REQUEST)
cache.delete(f"otp_code:{phone_number}")
user, created = User.objects.get_or_create(
phone_number=phone_number,
defaults={
"username": phone_number,
"email": f"{phone_number}@otp.local",
},
)
return Response(
{
"code": 200,
"msg": "success",
"data": _auth_user_to_data(user),
"token": _issue_token(user),
},
status=status.HTTP_200_OK,
)
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()
+9
View File
@@ -0,0 +1,9 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class FailureContract:
status: str = "error"
error_code: str = "internal_error"
message: str = ""
source: str = "application"
warnings: list[str] = field(default_factory=list)
retriable: bool = False
details: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
payload = {
"status": self.status,
"error_code": self.error_code,
"message": self.message,
"source": self.source,
"warnings": list(self.warnings),
"retriable": self.retriable,
}
if self.details:
payload["details"] = self.details
return payload
class StructuredServiceError(Exception):
def __init__(
self,
*,
error_code: str,
message: str,
source: str,
warnings: list[str] | None = None,
retriable: bool = False,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.contract = FailureContract(
error_code=error_code,
message=message,
source=source,
warnings=warnings or [],
retriable=retriable,
details=details or {},
)
def to_dict(self) -> dict[str, Any]:
return self.contract.to_dict()
+18
View File
@@ -0,0 +1,18 @@
{
"auth": "auth_access",
"account": "account_management",
"farm_hub": "farm_management",
"access_control": "access_control",
"sensor_catalog": "sensor_catalog",
"dashboard": "farm_dashboard",
"crop_zoning": "crop_zoning",
"plant_simulator": "plant_simulator",
"pest_detection": "pest_detection",
"sensor_7_in_1": "sensor-7-in-1",
"irrigation": "irrigation",
"fertilization": "fertilization",
"farm_ai_assistant": "farm_ai_assistant",
"notifications": "notifications",
"external_api_adapter": "external_api_adapter",
"sensor_external_api": "sensor_external_api"
}
@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
def _isoformat(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
return value
def build_integration_meta(
*,
flow_type: str,
source_type: str,
source_service: str,
ownership: str,
live: bool,
cached: bool,
generated_at: Any = None,
snapshot_at: Any = None,
sync_attempted: bool | None = None,
sync_status: str | None = None,
notes: list[str] | None = None,
) -> dict[str, Any]:
meta = {
"flow_type": flow_type,
"source_type": source_type,
"source_service": source_service,
"ownership": ownership,
"live": live,
"cached": cached,
}
if generated_at is not None:
meta["generated_at"] = _isoformat(generated_at)
if snapshot_at is not None:
meta["snapshot_at"] = _isoformat(snapshot_at)
if sync_attempted is not None:
meta["sync_attempted"] = sync_attempted
if sync_status is not None:
meta["sync_status"] = sync_status
if notes:
meta["notes"] = notes
return meta
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import logging
import time
from collections import Counter
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
_request_id_ctx: ContextVar[str | None] = ContextVar("backend_request_id", default=None)
METRICS: Counter[str] = Counter()
def set_request_id(request_id: str | None) -> None:
_request_id_ctx.set(request_id)
def get_request_id() -> str | None:
return _request_id_ctx.get()
def record_metric(name: str, value: int = 1, **tags: Any) -> None:
suffix = ",".join(f"{key}={tags[key]}" for key in sorted(tags) if tags[key] is not None)
metric_key = f"{name}|{suffix}" if suffix else name
METRICS[metric_key] += value
@dataclass
class ClassifiedFailure:
error_code: str
failure_type: str
retriable: bool
def classify_exception(exc: Exception) -> ClassifiedFailure:
exc_name = exc.__class__.__name__.lower()
message = str(exc).lower()
if "timeout" in exc_name or "timeout" in message:
return ClassifiedFailure("timeout", "timeout", True)
if "json" in exc_name or "json" in message:
return ClassifiedFailure("parse_error", "parse_error", False)
if "validation" in exc_name or "invalid" in message:
return ClassifiedFailure("validation_failure", "validation_failure", False)
if "connection" in exc_name or "unavailable" in message:
return ClassifiedFailure("dependency_unavailable", "dependency_unavailable", True)
return ClassifiedFailure("provider_error", "provider_error", True)
def log_event(
*,
level: int,
message: str,
source: str,
provider: str | None,
operation: str,
result_status: str,
duration_ms: float | None = None,
error_code: str | None = None,
**extra: Any,
) -> None:
payload = {
"source": source,
"provider": provider,
"operation": operation,
"result_status": result_status,
"duration_ms": round(duration_ms, 2) if duration_ms is not None else None,
"error_code": error_code,
"request_id": get_request_id(),
}
payload.update({key: value for key, value in extra.items() if value is not None})
logger.log(level, message, extra={"event": payload})
class observe_operation:
def __init__(self, *, source: str, provider: str | None, operation: str):
self.source = source
self.provider = provider
self.operation = operation
self.started_at = 0.0
def __enter__(self):
self.started_at = time.monotonic()
log_event(
level=logging.INFO,
message="backend operation started",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="started",
)
return self
def __exit__(self, exc_type, exc, _tb):
duration_ms = (time.monotonic() - self.started_at) * 1000
if exc is None:
log_event(
level=logging.INFO,
message="backend operation completed",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="success",
duration_ms=duration_ms,
)
record_metric("backend.operation.success", source=self.source, provider=self.provider, operation=self.operation)
return False
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="backend operation failed",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="error",
duration_ms=duration_ms,
error_code=failure.error_code,
failure_type=failure.failure_type,
)
record_metric(
"backend.operation.failure",
source=self.source,
provider=self.provider,
operation=self.operation,
error_code=failure.error_code,
)
return False
+294
View File
@@ -0,0 +1,294 @@
import os
from datetime import timedelta
from pathlib import Path
from dotenv import load_dotenv
from celery.schedules import crontab
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(exist_ok=True)
def _get_csv_env(name, default=""):
return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()]
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
DEBUG = os.environ.get("DEBUG", "0") == "1"
ALLOWED_HOSTS = list(
dict.fromkeys(
_get_csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0")
+ ["web", "backend-web", os.environ.get("HOSTNAME", "")]
)
)
AUTH_USER_MODEL = "account.User"
AUTHENTICATION_BACKENDS = [
"account.backends.MultiFieldBackend",
]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"auth.apps.AuthConfig",
"account.apps.AccountConfig",
"farm_hub.apps.FarmHubConfig",
"device_hub.apps.DeviceHubConfig",
"access_control.apps.AccessControlConfig",
"dashboard",
"crop_health.apps.CropHealthConfig",
"soil.apps.SoilConfig",
"crop_zoning",
"pest_detection",
"water.apps.WaterConfig",
"irrigation",
"yield_harvest.apps.YieldHarvestConfig",
"economic_overview.apps.EconomicOverviewConfig",
"farm_alerts.apps.FarmAlertsConfig",
"fertilization",
"farm_ai_assistant",
"notifications.apps.NotificationsConfig",
"plants.apps.PlantsConfig",
"farmer_calendar.apps.FarmerCalendarConfig",
"farmer_todos.apps.FarmerTodosConfig",
"external_api_adapter.apps.ExternalApiAdapterConfig",
"rest_framework",
"drf_spectacular",
"drf_spectacular_sidecar",
"corsheaders",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"access_control.middleware.RouteFeatureAccessMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
DATABASES = {
"default": {
"ENGINE": os.environ.get("DB_ENGINE", "django.db.backends.mysql"),
"NAME": os.environ.get("DB_NAME", "croplogic"),
"USER": os.environ.get("DB_USER", "croplogic"),
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
"HOST": os.environ.get("DB_HOST", "127.0.0.1"),
"PORT": os.environ.get("DB_PORT", "3306"),
"OPTIONS": {
"charset": "utf8mb4",
},
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.getenv("CACHE_URL", os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")),
"KEY_PREFIX": "croplogic",
}
}
PEST_DISEASE_RISK_SUMMARY_CACHE_TTL = int(os.getenv("PEST_DISEASE_RISK_SUMMARY_CACHE_TTL", "14400"))
WATER_NEED_PREDICTION_CACHE_TTL = int(os.getenv("WATER_NEED_PREDICTION_CACHE_TTL", "14400"))
SOIL_SUMMARY_CACHE_TTL = int(os.getenv("SOIL_SUMMARY_CACHE_TTL", "14400"))
SOIL_ANOMALIES_CACHE_TTL = int(os.getenv("SOIL_ANOMALIES_CACHE_TTL", "14400"))
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
"access_control.permissions.FeatureAccessPermission",
],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {
"TITLE": "CropLogic API",
"DESCRIPTION": "Swagger/OpenAPI documentation for all CropLogic API endpoints.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"SWAGGER_UI_DIST": "SIDECAR",
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
"SCHEMA_PATH_PREFIX": r"/api/",
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"APPEND_COMPONENTS": {
"securitySchemes": {
"SensorExternalApiKey": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "Use API key 12345 for sensor external API endpoints.",
}
}
},
"SWAGGER_UI_SETTINGS": {
"persistAuthorization": True,
},
}
SMS_IR_API_KEY = os.environ.get("SMS_IR_API_KEY", "")
SMS_IR_LINE_NUMBER = int(os.environ.get("SMS_IR_LINE_NUMBER", "300000000000"))
CORS_ALLOW_ALL_ORIGINS = DEBUG
USE_EXTERNAL_API_MOCK = os.getenv("USE_EXTERNAL_API_MOCK", "false").lower() == "true"
EXTERNAL_API_TIMEOUT = int(os.getenv("EXTERNAL_API_TIMEOUT", "30"))
ACCESS_CONTROL_AUTHZ_ENABLED = os.getenv("ACCESS_CONTROL_AUTHZ_ENABLED", "true").lower() == "true"
ACCESS_CONTROL_AUTHZ_BASE_URL = os.getenv(
"ACCESS_CONTROL_AUTHZ_BASE_URL",
"http://croplogic-accsess-opa:8181",
)
ACCESS_CONTROL_AUTHZ_BATCH_PATH = os.getenv("ACCESS_CONTROL_AUTHZ_BATCH_PATH", "/v1/data/croplogic/authz/batch_decision")
ACCESS_CONTROL_AUTHZ_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_TIMEOUT", str(EXTERNAL_API_TIMEOUT)))
ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT", "300"))
EXTERNAL_SERVICES = {
"ai": {
"base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"),
"api_key": os.getenv("AI_SERVICE_API_KEY", ""),
"host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"),
},
"farm_hub": {
"base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""),
"api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""),
"host_header": os.getenv("FARM_HUB_SERVICE_HOST_HEADER", ""),
},
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(days=7),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": False,
"BLACKLIST_AFTER_ROTATION": False,
}
CROP_ZONE_CHUNK_AREA_SQM = float(os.getenv("CROP_ZONE_CHUNK_AREA_SQM", "10000"))
CROP_ZONE_TASK_STALE_SECONDS = int(os.getenv("CROP_ZONE_TASK_STALE_SECONDS", "300"))
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0")
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", CELERY_BROKER_URL)
NOTIFICATION_REDIS_URL = os.getenv("NOTIFICATION_REDIS_URL", CELERY_BROKER_URL)
EXTERNAL_NOTIFICATION_API_KEY = os.getenv("EXTERNAL_NOTIFICATION_API_KEY", "12345")
SENSOR_EXTERNAL_API_KEY = os.getenv("SENSOR_EXTERNAL_API_KEY", "12345")
FARM_DATA_API_HOST = os.getenv("FARM_DATA_API_HOST", "")
FARM_DATA_API_PORT = os.getenv("FARM_DATA_API_PORT", "")
FARM_DATA_API_PATH = os.getenv("FARM_DATA_API_PATH", "/api/farm-data/")
FARM_DATA_API_KEY = os.getenv("FARM_DATA_API_KEY", "")
FARM_DATA_API_TIMEOUT = int(os.getenv("FARM_DATA_API_TIMEOUT", str(EXTERNAL_API_TIMEOUT)))
CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "default")
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = int(os.getenv("CELERY_WORKER_PREFETCH_MULTIPLIER", "1"))
CELERY_TASK_TIME_LIMIT = int(os.getenv("CELERY_TASK_TIME_LIMIT", "120"))
CELERY_TASK_SOFT_TIME_LIMIT = int(os.getenv("CELERY_TASK_SOFT_TIME_LIMIT", "90"))
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = os.getenv("CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP", "true").lower() == "true"
FARM_ALERTS_AI_SYNC_CRON_MINUTE = os.getenv("FARM_ALERTS_AI_SYNC_CRON_MINUTE", "0")
FARM_ALERTS_AI_SYNC_CRON_HOUR = os.getenv("FARM_ALERTS_AI_SYNC_CRON_HOUR", "*")
CELERY_BEAT_SCHEDULE = {
"sync-farm-alert-trackers": {
"task": "farm_alerts.tasks.sync_farm_alert_trackers",
"schedule": crontab(
minute=FARM_ALERTS_AI_SYNC_CRON_MINUTE,
hour=FARM_ALERTS_AI_SYNC_CRON_HOUR,
),
}
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
},
},
"handlers": {
"farm_ai_assistant_file": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": LOG_DIR / "farm_ai_assistant.log",
"formatter": "standard",
},
"farm_alerts_file": {
"level": "INFO",
"class": "logging.FileHandler",
"filename": LOG_DIR / "farm_alerts.log",
"formatter": "standard",
},
"external_api_adapter_file": {
"level": "WARNING",
"class": "logging.FileHandler",
"filename": LOG_DIR / "external_api_adapter.log",
"formatter": "standard",
},
},
"loggers": {
"farm_ai_assistant": {
"handlers": ["farm_ai_assistant_file"],
"level": "WARNING",
"propagate": False,
},
"farm_alerts": {
"handlers": ["farm_alerts_file"],
"level": "INFO",
"propagate": False,
},
"external_api_adapter": {
"handlers": ["external_api_adapter_file"],
"level": "WARNING",
"propagate": False,
},
},
}
+55
View File
@@ -0,0 +1,55 @@
from rest_framework import serializers
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, inline_serializer
FARM_UUID_DEFAULT = "11111111-1111-1111-1111-111111111111"
class AuthTokenSerializer(serializers.Serializer):
token = serializers.CharField()
def code_response(name, data=None, token=False, extra_fields=None):
fields = {
"code": serializers.IntegerField(),
"msg": serializers.CharField(),
}
if data is not None:
fields["data"] = data
if token:
fields["token"] = serializers.CharField()
if extra_fields:
fields.update(extra_fields)
return inline_serializer(name=name, fields=fields)
def status_response(name, data=None):
fields = {
"status": serializers.CharField(default="success"),
}
if data is not None:
fields["data"] = data
return inline_serializer(name=name, fields=fields)
def farm_uuid_query_param(required=False, description="UUID of the farm."):
return OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=required,
description=description,
default=FARM_UUID_DEFAULT,
)
def sensor_uuid_query_param(required=False, description="Optional sensor UUID."):
return OpenApiParameter(
name="sensor_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=required,
description=description,
)
+44
View File
@@ -0,0 +1,44 @@
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
urlpatterns = [
path("admin/", admin.site.urls),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/docs/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
path("api/auth/", include("auth.urls")),
path("api/account/", include("account.urls")),
path("api/farm-hub/", include("farm_hub.urls")),
path("api/access-control/", include("access_control.urls")),
path("api/device-hub/", include("device_hub.urls")),
path("api/sensor-catalog/", include("device_hub.sensor_catalog_urls")),
path("api/farm-dashboard-config/", include("dashboard.urls_config")),
path("api/farm-dashboard/", include("dashboard.urls")),
path("api/crop-health/", include("crop_health.urls")),
path("api/soil/", include("soil.urls")),
path("api/crop-zoning/", include("crop_zoning.urls")),
# path("api/yield-harvest/", include("yield_harvest.urls")),
path("api/yield-harvest/", include("yield_harvest.crop_simulation_urls")),
path("api/pest-detection/", include("pest_detection.urls")),
path("api/pest-disease/", include("pest_detection.pest_disease_urls")),
path("api/sensor-7-in-1/", include("device_hub.sensor_7_in_1_urls")),
path("api/sensors/", include("device_hub.comparison_urls")),
path("api/irrigation/", include("irrigation.urls")),
path("api/weather/", include("water.weather_urls")),
path("api/water/", include("water.urls")),
path("api/economy/", include("economic_overview.urls")),
path("api/fertilization/", include("fertilization.urls")),
path("api/farm-ai-assistant/", include("farm_ai_assistant.urls")),
path("api/notifications/", include("notifications.urls")),
path("api/farm-alerts/", include("farm_alerts.urls")),
path("api/plants/", include("plants.urls")),
path("api/events/", include("farmer_calendar.urls")),
path("api/farmer-todos/", include("farmer_todos.urls")),
path("api/sensor-external-api/", include("device_hub.sensor_external_api_urls")),
]
+7
View File
@@ -0,0 +1,7 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()
+1
View File
@@ -0,0 +1 @@
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CropHealthConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "crop_health"
verbose_name = "Crop Health"
+27
View File
@@ -0,0 +1,27 @@
FARM_HEALTH_SCORE = {
"id": "farm_health_score",
"title": "امتیاز سلامت مزرعه",
"subtitle": "تحلیل هوشمند",
"stats": "87%",
"avatarColor": "success",
"avatarIcon": "tabler-heartbeat",
"chipText": "خوب",
"chipColor": "success",
}
NDVI_HEALTH_CARD = {
"ndviIndex": 0.78,
"mean_ndvi": 0.78,
"ndvi_map": {
"type": "FeatureCollection",
"features": [],
},
"vegetation_health_class": "Healthy",
"observation_date": "2026-04-10",
"satellite_source": "sentinel-2",
"healthData": [
{"title": "تنش نیتروژن", "value": "پایین", "color": "success", "icon": "tabler-leaf"},
{"title": "سلامت محصول", "value": "خوب", "color": "success", "icon": "tabler-plant"},
],
}
+2
View File
@@ -0,0 +1,2 @@
from django.db import models
@@ -0,0 +1,38 @@
from rest_framework import serializers
class CropHealthRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(help_text="UUID مزرعه برای دریافت تحلیل سلامت گیاه.")
class HealthDataItemSerializer(serializers.Serializer):
title = serializers.CharField(required=False, allow_blank=True, help_text="عنوان آیتم سلامت.")
value = serializers.JSONField(required=False, help_text="مقدار آیتم سلامت؛ می‌تواند عدد، متن یا ساختار JSON باشد.")
color = serializers.CharField(required=False, allow_blank=True, help_text="رنگ نمایشی آیتم سلامت.")
icon = serializers.CharField(required=False, allow_blank=True, help_text="آیکون نمایشی آیتم سلامت.")
class NdviHealthCardSerializer(serializers.Serializer):
ndviIndex = serializers.FloatField(required=False, help_text="شاخص NDVI نرمال‌شده برای مزرعه.")
mean_ndvi = serializers.FloatField(required=False, help_text="میانگین NDVI محاسبه‌شده.")
ndvi_map = serializers.JSONField(required=False, help_text="لایه یا متادیتای نقشه NDVI.")
vegetation_health_class = serializers.CharField(required=False, allow_blank=True, help_text="کلاس سلامت پوشش گیاهی.")
observation_date = serializers.DateField(required=False, help_text="تاریخ مشاهده ماهواره‌ای.")
satellite_source = serializers.CharField(required=False, allow_blank=True, help_text="منبع تصویر ماهواره‌ای.")
healthData = HealthDataItemSerializer(many=True, required=False)
class FarmHealthScoreSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True)
title = serializers.CharField(required=False, allow_blank=True)
subtitle = serializers.CharField(required=False, allow_blank=True)
stats = serializers.CharField(required=False, allow_blank=True)
avatarColor = serializers.CharField(required=False, allow_blank=True)
avatarIcon = serializers.CharField(required=False, allow_blank=True)
chipText = serializers.CharField(required=False, allow_blank=True)
chipColor = serializers.CharField(required=False, allow_blank=True)
class CropHealthSummarySerializer(serializers.Serializer):
ndviHealthCard = NdviHealthCardSerializer(required=False)
farmHealthScore = FarmHealthScoreSerializer(required=False)
+10
View File
@@ -0,0 +1,10 @@
from copy import deepcopy
from .mock_data import FARM_HEALTH_SCORE, NDVI_HEALTH_CARD
def get_crop_health_summary_data(farm=None):
return {
"ndviHealthCard": deepcopy(NDVI_HEALTH_CARD),
"farmHealthScore": deepcopy(FARM_HEALTH_SCORE),
}
+110
View File
@@ -0,0 +1,110 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import Resolver404, resolve
from rest_framework.test import APIRequestFactory, force_authenticate
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType
from unittest.mock import patch
from .views import CropHealthSummaryView, NdviHealthView
class NdviHealthViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="ndvi-user",
password="secret123",
email="ndvi@example.com",
phone_number="09120000020",
)
self.other_user = get_user_model().objects.create_user(
username="ndvi-other-user",
password="secret123",
email="ndvi-other@example.com",
phone_number="09120000021",
)
self.farm_type = FarmType.objects.create(name="NDVI Farm Type")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="NDVI Farm",
)
@patch("crop_health.views.external_api_request")
def test_post_ndvi_health_returns_expected_payload(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"ndviIndex": 0.78, "mean_ndvi": 0.78, "vegetation_health_class": "Healthy", "satellite_source": "sentinel-2"}}},
)
request = self.factory.post(
"/api/crop-health/ndvi-health/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = NdviHealthView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["msg"], "success")
self.assertEqual(response.data["data"]["ndviIndex"], 0.78)
self.assertEqual(response.data["data"]["mean_ndvi"], 0.78)
self.assertEqual(response.data["data"]["vegetation_health_class"], "Healthy")
self.assertEqual(response.data["data"]["satellite_source"], "sentinel-2")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/soil-data/ndvi-health/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid)},
)
def test_post_ndvi_health_requires_farm_uuid(self):
request = self.factory.post("/api/crop-health/ndvi-health/", {}, format="json")
force_authenticate(request, user=self.user)
response = NdviHealthView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertIn("farm_uuid", response.data)
def test_post_ndvi_health_returns_404_for_missing_farm(self):
request = self.factory.post(
"/api/crop-health/ndvi-health/",
{"farm_uuid": "11111111-1111-1111-1111-111111111111"},
format="json",
)
force_authenticate(request, user=self.user)
response = NdviHealthView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["msg"], "Farm not found.")
def test_post_ndvi_health_does_not_expose_other_users_farm(self):
request = self.factory.post(
"/api/crop-health/ndvi-health/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.other_user)
response = NdviHealthView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["msg"], "Farm not found.")
def test_crop_health_routes_exist(self):
self.assertIs(resolve("/api/crop-health/ndvi-health/").func.view_class, NdviHealthView)
self.assertIs(resolve("/api/crop-health/summary/").func.view_class, CropHealthSummaryView)
def test_removed_soil_health_alias_routes_no_longer_resolve(self):
with self.assertRaises(Resolver404):
resolve("/api/soil/health/ndvi-health/")
with self.assertRaises(Resolver404):
resolve("/api/soil/health/summary/")
with self.assertRaises(Resolver404):
resolve("/api/soil-data/ndvi-health/")
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .views import CropHealthSummaryView, NdviHealthView
urlpatterns = [
path("ndvi-health/", NdviHealthView.as_view(), name="crop-health-ndvi-health"),
path("summary/", CropHealthSummaryView.as_view(), name="crop-health-summary"),
]
+82
View File
@@ -0,0 +1,82 @@
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.swagger import farm_uuid_query_param, status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from .serializers import CropHealthRequestSerializer, CropHealthSummarySerializer, NdviHealthCardSerializer
from .services import get_crop_health_summary_data
class CropHealthSummaryView(APIView):
@extend_schema(
tags=["Crop Health"],
parameters=[
farm_uuid_query_param(required=False, description="UUID of the farm for crop health data."),
],
responses={200: status_response("CropHealthSummaryResponse", data=CropHealthSummarySerializer())},
)
def get(self, request):
return Response(
{"status": "success", "data": get_crop_health_summary_data()},
status=status.HTTP_200_OK,
)
class NdviHealthView(APIView):
permission_classes = [IsAuthenticated]
@staticmethod
def _extract_result(adapter_data):
if not isinstance(adapter_data, dict):
return {}
data = adapter_data.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), dict):
return data["result"]
if isinstance(data, dict):
return data
result = adapter_data.get("result")
if isinstance(result, dict):
return result
return adapter_data
@extend_schema(
tags=["Crop Health"],
request=CropHealthRequestSerializer,
responses={200: status_response("NdviHealthResponse", data=NdviHealthCardSerializer())},
)
def post(self, request):
serializer = CropHealthRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
farm = FarmHub.objects.get(farm_uuid=serializer.validated_data["farm_uuid"], owner=request.user)
except FarmHub.DoesNotExist:
return Response({"code": 404, "msg": "Farm not found."}, status=status.HTTP_404_NOT_FOUND)
adapter_response = external_api_request(
"ai",
"/api/soil-data/ndvi-health/",
method="POST",
payload={"farm_uuid": str(farm.farm_uuid)},
)
if adapter_response.status_code >= 400:
response_data = (
adapter_response.data
if isinstance(adapter_response.data, dict)
else {"message": str(adapter_response.data)}
)
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
data = self._extract_result(adapter_response.data)
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
@@ -0,0 +1,883 @@
# Crop Zoning Code Logic
این فایل یک توضیح کامل و شفاف از منطق سه فایل زیر است:
- `crop_zoning/views.py`
- `crop_zoning/services.py`
- `crop_zoning/tests.py`
هدف این داکیومنت این است که بدون نیاز به خواندن مستقیم کد، بتوان فهمید هر endpoint چه می‌کند، داده‌ها چگونه ساخته می‌شوند، taskها چگونه مدیریت می‌شوند، و تست‌ها چه رفتارهایی را پوشش می‌دهند.
---
## تصویر کلی ماژول
ماژول `crop_zoning` برای این ساخته شده که:
1. یک polygon مربوط به زمین را دریافت یا پیدا کند.
2. آن را به چند zone مربعی تقسیم کند.
3. برای هر zone داده اولیه تولید کند.
4. برای هر zone یک task پردازش جداگانه ثبت کند.
5. خروجی مناسب برای فرانت برگرداند تا هم وضعیت پردازش را بداند و هم zoneها را روی نقشه نمایش دهد.
این ماژول دو نوع داده برای zoneها دارد:
- داده اولیه و rule-based که سریع ساخته می‌شود و برای خالی نبودن UI استفاده می‌شود.
- داده تحلیلی که بعدا از طریق task و داده خاک تکمیل می‌شود.
---
## منطق `crop_zoning/views.py`
فایل `views.py` فقط لایه HTTP است.
یعنی کار اصلی را خودش انجام نمی‌دهد، بلکه:
- ورودی request را می‌خواند
- آن را validate می‌کند یا به serviceها می‌سپارد
- خروجی مناسب را به صورت JSON response برمی‌گرداند
### 1) `AreaView`
این مهم‌ترین endpoint ماژول است.
### کار این view
- `farm_uuid` را از query params می‌گیرد.
- `page` و `page_size` را هم از query params می‌گیرد.
- از service می‌خواهد مطمئن شود برای این sensor یک area معتبر و zoneهای آن وجود دارند.
- اگر zoneها وجود نداشته باشند، ساخته می‌شوند.
- اگر taskهای پردازش لازم باشند، dispatch می‌شوند.
- در نهایت خروجی area + zoneهای همان صفحه + اطلاعات pagination را برمی‌گرداند.
### ورودی‌های `AreaView`
- `farm_uuid`: اجباری
- `page`: اختیاری، پیش‌فرض `1`
- `page_size`: اختیاری، پیش‌فرض `10`
### خروجی `AreaView`
خروجی سه بخش مهم دارد:
- `task`: وضعیت پردازش کل area
- `area`: polygon اصلی زمین
- `zones`: فقط zoneهای مربوط به همان صفحه
- `pagination`: اطلاعات صفحه‌بندی zoneها
### مدیریت خطا در `AreaView`
اگر هر کدام از این موارد رخ بدهد، خطای `400` داده می‌شود:
- `farm_uuid` ارسال نشده باشد
- `farm_uuid` معتبر نباشد یا farm پیدا نشود
- `page` نامعتبر باشد
- `page_size` نامعتبر باشد
اگر تنظیمات سمت سرور مشکل داشته باشند، خطای `500` داده می‌شود.
---
### 2) `ProductsView`
این endpoint لیست محصولات قابل کشت را برمی‌گرداند.
### کار این view
- از service می‌خواهد محصولات پیش‌فرض داخل دیتابیس sync شوند.
- سپس لیست محصولات را به فرمت مناسب فرانت برمی‌گرداند.
این view ساده است و منطق تحلیلی ندارد.
---
### 3) `ZonesInitialView`
این view برای ساخت zoneها از روی یک polygon ورودی استفاده می‌شود.
### کار این view
- polygon را از یکی از این کلیدها می‌گیرد:
- `area`
- `area_geojson`
- `boundary`
- اگر هیچ‌کدام نباشد، از area پیش‌فرض mock استفاده می‌کند.
- در صورت ارسال، `cell_side_km` را هم می‌گیرد.
- service را صدا می‌زند تا area و zoneها ساخته شوند.
- response اولیه zoneها را برمی‌گرداند.
### تفاوت با `AreaView`
- `AreaView` بر اساس `farm_uuid` کار می‌کند و وضعیت taskها را هم برمی‌گرداند.
- `ZonesInitialView` بیشتر برای ساخت اولیه zoneها از روی polygon مناسب است.
---
### 4) `ZonesWaterNeedView`
این view لایه نیاز آبی zoneها را برمی‌گرداند.
### کار این view
- از request، `zoneIds` را می‌گیرد.
- service را صدا می‌زند.
- برای هر zone، level و value و color مربوط به آب را برمی‌گرداند.
---
### 5) `ZonesSoilQualityView`
این view لایه کیفیت خاک zoneها را برمی‌گرداند.
### خروجی اصلی
برای هر zone:
- `level`
- `score`
- `color`
---
### 6) `ZonesCultivationRiskView`
این view لایه ریسک کشت zoneها را برمی‌گرداند.
### خروجی اصلی
برای هر zone:
- `level`
- `color`
---
### 7) `ZoneDetailsView`
این endpoint جزئیات یک zone را برمی‌گرداند.
### کار این view
- `zone_id` را از URL می‌گیرد.
- جزئیات recommendation آن zone را از service می‌خواند.
- اگر zone پیدا نشود، `404` برمی‌گرداند.
### خروجی اصلی
- crop پیشنهادی
- درصد تطابق
- نیاز آبی
- سود تخمینی
- reason
- criteria
- مساحت zone
---
## منطق `crop_zoning/services.py`
این فایل قلب اصلی ماژول است.
بیشتر منطق واقعی اینجا پیاده‌سازی شده.
برای فهم بهتر، این فایل را می‌توان به 8 بخش تقسیم کرد.
---
## بخش 1: تنظیمات و utilityهای اولیه
### ثابت‌ها
چند constant اصلی در ابتدای فایل تعریف شده‌اند:
- `DEFAULT_CELL_SIDE_KM`: اندازه پیش‌فرض ضلع هر zone
- `DEFAULT_ZONE_PAGE_SIZE`: تعداد پیش‌فرض zoneها در هر صفحه response
- `RULE_BASED_ALGORITHM`: نام الگوریتم rule-based
- `RULE_BASED_PRODUCTS`: داده اولیه محصولات و اطلاعات نمایشی آنها
### `get_default_cell_side_km()`
این تابع اندازه پیش‌فرض ضلع هر zone را مشخص می‌کند.
اولویت‌ها:
1. اگر `CROP_ZONE_CELL_SIDE_KM` در settings وجود داشته باشد، همان استفاده می‌شود.
2. اگر نبود، از `CROP_ZONE_CHUNK_AREA_SQM` استفاده می‌کند و از روی آن ضلع مربع را حساب می‌کند.
3. اگر هیچ‌کدام نباشند، از `DEFAULT_CELL_SIDE_KM` استفاده می‌شود.
### `get_task_stale_seconds()`
این تابع مشخص می‌کند بعد از چند ثانیه یک task ممکن است stale محسوب شود.
یعنی اگر task گیر کرده باشد، دوباره dispatch شود.
### `get_cell_side_km(cell_side_km=None)`
اگر کاربر اندازه zone را داده باشد، آن را validate می‌کند.
اگر نداده باشد، مقدار پیش‌فرض را برمی‌گرداند.
### `get_chunk_area_sqm(cell_side_km=None)`
مساحت zone را از روی ضلع آن حساب می‌کند:
- ضلع بر حسب کیلومتر دریافت می‌شود
- به متر تبدیل می‌شود
- مربع آن به عنوان مساحت zone برگردانده می‌شود
### `parse_positive_int(...)`
برای validate کردن پارامترهای عددی مثبت استفاده می‌شود.
الان برای `page` و `page_size` استفاده می‌شود.
### `get_zone_page_request_params(query_params)`
این تابع پارامترهای pagination را از query params می‌گیرد:
- `page`
- `page_size`
اگر ارسال نشده باشند، از default استفاده می‌کند.
اگر نامعتبر باشند، `ValueError` می‌دهد.
---
## بخش 2: آماده‌سازی polygon و محاسبات هندسی
این بخش مسئول کار با GeoJSON و polygon است.
### `get_default_area_feature()`
یک area پیش‌فرض از داده mock برمی‌گرداند.
### `normalize_area_feature(area_feature)`
این تابع ورودی area را normalize می‌کند تا همیشه ساختار `Feature` داشته باشد.
### کارهای این تابع
- بررسی می‌کند ورودی null نباشد
- بررسی می‌کند ورودی dict باشد
- اگر ورودی از نوع `Feature` نباشد، آن را به `Feature` تبدیل می‌کند
- بررسی می‌کند geometry از نوع `Polygon` باشد
- بررسی می‌کند polygon حداقل 4 نقطه داشته باشد
### `get_polygon_ring(area_feature)`
حلقه اصلی polygon را استخراج می‌کند.
### `polygon_area_sqm(ring)`
مساحت polygon را به متر مربع حساب می‌کند.
برای این کار نقاط جغرافیایی را به مختصات مسطح تقریبی تبدیل می‌کند و فرمول shoelace را اجرا می‌کند.
### `normalize_points(ring)`
اگر آخر polygon با نقطه اول بسته شده باشد، نقطه تکراری آخر را حذف می‌کند.
### `calculate_center(points)`
مرکز تقریبی polygon یا مربع را از میانگین نقاط حساب می‌کند.
### `get_bbox(points)`
کمینه و بیشینه طول و عرض جغرافیایی را برمی‌گرداند تا محدوده کلی polygon مشخص شود.
### `meters_to_latitude_delta(meters)` و `meters_to_longitude_delta(meters, latitude)`
این دو تابع فاصله متر را به اختلاف latitude و longitude تبدیل می‌کنند.
برای ساخت grid مربعی از این دو تابع استفاده می‌شود.
---
## بخش 3: تشخیص برخورد polygon و cell
این بخش مشخص می‌کند که آیا یک مربع grid واقعا با polygon زمین برخورد دارد یا نه.
### `point_in_polygon(point, polygon_points)`
چک می‌کند یک نقطه داخل polygon هست یا نه.
### `_orientation`, `_on_segment`, `segments_intersect`
این توابع utilityهای هندسی برای تشخیص برخورد دو خط هستند.
### `rectangle_contains_point(point, cell_points)`
چک می‌کند یک نقطه داخل مربع cell قرار دارد یا نه.
### `polygon_intersects_cell(polygon_points, cell_points)`
این مهم‌ترین تابع تقاطع است.
اگر یکی از شرایط زیر برقرار باشد، cell معتبر در نظر گرفته می‌شود:
- مرکز cell داخل polygon باشد
- یکی از گوشه‌های cell داخل polygon باشد
- یکی از نقاط polygon داخل cell باشد
- یکی از اضلاع polygon با اضلاع cell برخورد داشته باشد
نتیجه: فقط مربع‌هایی zone می‌شوند که واقعا با زمین هم‌پوشانی داشته باشند.
---
## بخش 4: ساخت zoneها از روی area
### `build_square_points(...)`
چهار گوشه یک مربع را از روی مرزهای آن می‌سازد.
### `build_zone_square(area_points, center, zone_area_sqm)`
اگر area خیلی کوچک باشد یا zoneی تولید نشود، یک مربع fallback حول center area ساخته می‌شود.
### `split_area_into_zones(area_feature, cell_side_km=None)`
این تابع مهم‌ترین بخش ساخت zoneها است.
### مراحل اجرای آن
1. polygon area را می‌گیرد.
2. center و bbox و total area را حساب می‌کند.
3. اندازه ضلع zone را مشخص می‌کند.
4. روی bbox یک grid مربعی می‌سازد.
5. هر cell را با `polygon_intersects_cell` بررسی می‌کند.
6. اگر cell با polygon تقاطع داشته باشد، یک zone جدید می‌سازد.
7. برای هر zone این داده‌ها تولید می‌شود:
- `zone_id`
- `geometry`
- `points`
- `center`
- `area_sqm`
- `area_hectares`
- `sequence`
8. اگر هیچ zoneی ساخته نشود، یک zone fallback می‌سازد.
9. در نهایت area summary و لیست zoneها را برمی‌گرداند.
### نکته مهم
در این پروژه zoneها grid-based هستند، نه بر اساس تقسیم واقعی shape زمین.
یعنی ابتدا grid مربعی ساخته می‌شود و بعد فقط مربع‌هایی که با زمین برخورد دارند نگه داشته می‌شوند.
---
## بخش 5: تولید recommendation و لایه‌های تحلیلی
این بخش داده پیشنهادی هر zone را تولید می‌کند.
### `build_rule_based_zone_metrics(index, coords)`
این تابع بدون نیاز به API خارجی، برای هر zone یک recommendation اولیه می‌سازد.
### هدف آن
وقتی zone تازه ساخته می‌شود، فرانت از همان ابتدا داده خالی نداشته باشد.
### خروجی آن
- `recommended_crop`
- `match_percent`
- `water_need_level`
- `water_need_value`
- `soil_quality_score`
- `soil_level`
- `cultivation_risk_level`
- `estimated_profit`
- `reason`
- `criteria`
این داده‌ها از روی مختصات zone و `sequence` به صورت deterministic ساخته می‌شوند.
### `build_initial_zone_payload(zone)`
خروجی سبک و اولیه برای endpoint ساخت zoneها تولید می‌کند.
### `build_area_zone_payload(zone)`
خروجی کامل‌تر برای `AreaView` تولید می‌کند و این بخش‌ها را شامل می‌شود:
- geometry
- center
- area
- processing status
- crop recommendation
- water layer
- soil layer
- risk layer
### `persist_zone_analysis_metrics(zone, metrics)`
metrics را داخل مدل‌های مختلف ذخیره می‌کند:
- recommendation
- criteria
- water need layer
- soil quality layer
- cultivation risk layer
### `ensure_rule_based_zone_data(zone, force=False)`
اگر zone هنوز recommendation نداشته باشد، با rule-based data آن را پر می‌کند.
---
## بخش 6: تحلیل خاک واقعی و ذخیره نتیجه
### `_get_level_color_map(...)`
رنگ هر level را برای سه لایه water / soil / risk برمی‌گرداند.
### `_pick_level(...)`
از روی score مشخص می‌کند level برابر `low` یا `medium` یا `high` است.
### `_format_range(...)`
برای ساخت رشته‌هایی مثل `3000-4000 m³/ha` استفاده می‌شود.
### `_derive_analysis_metrics(depths)`
این تابع از داده depthهای خاک، recommendation نهایی را می‌سازد.
### ورودی آن
آرایه‌ای از depthها که از سرویس خارجی خاک می‌آید.
### محاسبات مهم آن
از میانگین این فیلدها استفاده می‌کند:
- `phh2o`
- `soc`
- `clay`
- `nitrogen`
- `wv0033`
بعد از اینها محاسبه می‌شود:
- کیفیت خاک
- نیاز آبی
- ریسک کشت
- محصول پیشنهادی
- درصد تطابق
- reason
- criteria
### `fetch_soil_data_for_zone(zone)`
برای یک zone به سرویس خارجی AI درخواست می‌زند و داده خاک می‌گیرد.
### payload ارسالی
- longitude
- latitude
- geometry zone
- center
- area
### `analyze_and_store_zone_soil_data(zone_id)`
این تابع منطق اصلی پردازش هر zone در worker است.
### مراحل آن
1. zone از دیتابیس خوانده می‌شود.
2. اگر قبلا کامل شده باشد، دوباره کاری نمی‌کند.
3. status روی `processing` می‌رود.
4. از API خارجی داده خاک می‌گیرد.
5. depthها را استخراج می‌کند.
6. recommendation واقعی‌تر را از روی خاک می‌سازد.
7. نتیجه را داخل مدل‌های analysis و recommendation ذخیره می‌کند.
8. status را `completed` می‌کند.
9. اگر هر خطایی رخ دهد، status روی `failed` می‌رود و متن خطا ذخیره می‌شود.
---
## بخش 7: مدیریت taskهای zone
چون هر zone جداگانه پردازش می‌شود، باید taskها مدیریت شوند.
### `_get_stale_zone_ids(zones)`
این تابع zoneهایی را پیدا می‌کند که task آنها stale شده است.
### چه zoneهایی stale محسوب می‌شوند؟
- zone کامل نشده باشد
- task_id داشته باشد
- task خیلی قدیمی شده باشد
- یا task_id آن با task یک zone completed مشترک باشد
- یا state task در celery یکی از stateهای نامعتبر برای ادامه باشد
### `dispatch_zone_processing_tasks(crop_area_id=None, zone_ids=None, force=False)`
این تابع برای zoneهای انتخاب‌شده task celery ثبت می‌کند.
### رفتار آن
- zoneهای completed را رد می‌کند
- اگر zone pending/processing باشد و task_id معتبر داشته باشد، دوباره dispatch نمی‌کند مگر `force=True`
- برای هر zone یک task جدا ثبت می‌کند
- اگر celery broker در دسترس نباشد، باز هم یک `task_id` محلی تولید می‌کند
- متن خطا را در `processing_error` ذخیره می‌کند
### اهمیت این طراحی
این باعث می‌شود:
- هر zone مستقل پردازش شود
- fail شدن یک zone بقیه را متوقف نکند
- فرانت بتواند وضعیت هر zone را جدا ببیند
---
## بخش 8: ساخت area، بازیابی area و ساخت payload response
### `create_missing_zones_for_area(crop_area)`
اگر area در دیتابیس وجود داشته باشد ولی zoneهایش از بین رفته باشند یا ساخته نشده باشند، دوباره از روی geometry آن zoneها را می‌سازد.
### `get_farm_for_uuid(farm_uuid)`
اعتبارسنجی می‌کند که:
- `farm_uuid` ارسال شده باشد
- farm واقعا در دیتابیس وجود داشته باشد
### `ensure_latest_area_ready_for_processing(farm_uuid, area_feature=None)`
این یکی از مهم‌ترین توابع کل فایل است.
### منطق آن
1. sensor را پیدا می‌کند.
2. آخرین area مربوط به آن sensor را می‌گیرد.
3. اگر area وجود نداشته باشد:
- area پیش‌فرض یا area ورودی را می‌گیرد
- area و zoneها را می‌سازد
- taskها را dispatch می‌کند
4. اگر area وجود داشته باشد:
- مطمئن می‌شود zoneها وجود دارند
- برای هر zone، rule-based data را در صورت نبود ایجاد می‌کند
- zoneهای stale را پیدا می‌کند
- zoneهای لازم را دوباره dispatch می‌کند
- area تازه از دیتابیس خوانده می‌شود و برگردانده می‌شود
### نتیجه این تابع
وقتی `AreaView` این تابع را صدا می‌زند، همیشه یک area آماده برای نمایش و پردازش دارد.
### `create_zones_and_dispatch(area_feature, cell_side_km=None, sensor=None)`
این تابع area جدید را می‌سازد.
### مراحل آن
1. productها sync می‌شوند.
2. area normalize می‌شود.
3. area به zoneها تقسیم می‌شود.
4. داخل transaction:
- یک `CropArea` ساخته می‌شود
- همه `CropZone`ها bulk create می‌شوند
5. zoneها دوباره از دیتابیس خوانده می‌شوند
6. rule-based data برای هر zone ساخته می‌شود
7. taskهای پردازش dispatch می‌شوند
8. area و zones برگردانده می‌شوند
### `_zones_queryset(zone_ids=None)`
یک queryset آماده برمی‌گرداند که relationهای لازم را از قبل load می‌کند:
- recommendation
- product
- criteria
- water layer
- soil layer
- risk layer
این باعث می‌شود responseسازی سریع‌تر و با query کمتر انجام شود.
### `get_latest_area_payload(area=None, page=1, page_size=DEFAULT_ZONE_PAGE_SIZE)`
این تابع خروجی نهایی `AreaView` را می‌سازد.
### کارهای این تابع
1. area را پیدا می‌کند.
2. وضعیت همه zoneها را می‌خواند.
3. تعداد completed / pending / processing / failed را حساب می‌کند.
4. `task.status` را تعیین می‌کند.
5. `stage` و `stage_label` را تعیین می‌کند.
6. درصد پیشرفت را حساب می‌کند.
7. zoneهای همان صفحه را با slicing برمی‌دارد.
8. `pagination` را می‌سازد.
9. payload نهایی را برمی‌گرداند.
### منطق `task.status`
- اگر zone failed داشته باشیم: `FAILURE`
- اگر همه complete باشند: `SUCCESS`
- اگر بخشی complete یا processing باشند: `PROCESSING`
- در غیر این صورت: `PENDING`
### منطق pagination
- `page` و `page_size` از request گرفته می‌شوند
- `total_pages` از تقسیم تعداد کل zoneها بر `page_size` محاسبه می‌شود
- فقط zoneهای همان بازه برگردانده می‌شوند
- اطلاعات page فعلی، تعداد صفحات و وجود صفحه قبل/بعد در body قرار می‌گیرد
### `get_initial_zones_payload(crop_area)`
payload ساده‌تر برای endpoint اولیه zoneها می‌سازد.
### `get_water_need_payload(zone_ids=None)`
خروجی لایه نیاز آبی را برمی‌گرداند.
### `get_soil_quality_payload(zone_ids=None)`
خروجی لایه کیفیت خاک را برمی‌گرداند.
### `get_cultivation_risk_payload(zone_ids=None)`
خروجی لایه ریسک کشت را برمی‌گرداند.
### `get_zone_details_payload(zone_id)`
خروجی دیتیل یک zone را می‌سازد.
---
## جریان کامل اجرای `GET /api/crop-zoning/area/`
اگر بخواهیم کل flow را از ابتدا تا انتها خیلی ساده توضیح بدهیم:
1. فرانت `farm_uuid` و احتمالا `page` و `page_size` را می‌فرستد.
2. `AreaView` پارامترها را می‌خواند.
3. `ensure_latest_area_ready_for_processing` اجرا می‌شود.
4. اگر area وجود نداشته باشد، area و zoneها ساخته می‌شوند.
5. اگر zoneها ناقص باشند، کامل می‌شوند.
6. اگر recommendation اولیه نباشد، ساخته می‌شود.
7. اگر taskهای لازم وجود نداشته باشند یا stale باشند، dispatch می‌شوند.
8. `get_latest_area_payload` اجرا می‌شود.
9. وضعیت کلی task و zoneهای صفحه فعلی ساخته می‌شود.
10. response نهایی به فرانت برمی‌گردد.
---
## منطق `crop_zoning/tests.py`
این فایل تست رفتار کلیدی API را پوشش می‌دهد.
تست‌ها با `Django TestCase` و `APIRequestFactory` نوشته شده‌اند.
### تنظیمات مشترک تست‌ها
در تست‌ها از این تنظیمات استفاده شده:
- `USE_EXTERNAL_API_MOCK=True`
- `CROP_ZONE_CHUNK_AREA_SQM=200000`
هدف این است که:
- وابستگی به API خارجی واقعی حذف شود
- zoneها با اندازه مشخص و قابل پیش‌بینی ساخته شوند
---
## کلاس `ZonesInitialViewTests`
### `test_post_accepts_area_geojson_alias`
این تست بررسی می‌کند که اگر polygon با کلید `area_geojson` ارسال شود:
- endpoint آن را قبول کند
- پاسخ `200` بدهد
- zone ساخته شود
- تعداد zoneهای خروجی با `zone_count` یکسان باشد
این تست در عمل alias بودن `area_geojson` را validate می‌کند.
---
## کلاس `AreaViewTests`
این کلاس رفتارهای اصلی `AreaView` را تست می‌کند.
### `setUp`
در شروع هر تست:
- یک user ساخته می‌شود
- یک sensor برای آن user ساخته می‌شود
- `APIRequestFactory` آماده می‌شود
### `_create_area(...)`
یک helper برای ساخت سریع `CropArea` در تست‌ها است.
### `_request()`
یک request استاندارد برای `AreaView` با `farm_uuid` معتبر می‌سازد.
### `_request_with_pagination(page, page_size)`
یک request برای تست pagination می‌سازد.
---
### تست‌های اصلی `AreaView`
#### `test_get_requires_farm_uuid`
بررسی می‌کند اگر `farm_uuid` ارسال نشود، پاسخ `400` برگردد.
#### `test_get_returns_pending_task_status_until_all_zones_complete`
بررسی می‌کند اگر zoneها pending و processing باشند:
- status کلی `PROCESSING` باشد
- area برگردد
- zoneها در response باشند
- فیلد `processing_status` برای zone موجود باشد
#### `test_get_returns_area_when_all_tasks_complete`
بررسی می‌کند وقتی همه zoneها complete باشند:
- status کلی `SUCCESS` باشد
- zoneها برگردند
- فیلدهای recommendation و layerها موجود باشند
#### `test_get_returns_paginated_zones`
تست جدید pagination است.
بررسی می‌کند که:
- با `page=2` و `page_size=1`
- فقط zone دوم برگردد
- اطلاعات pagination درست باشد
- `total_pages`, `has_next`, `has_previous` درست باشند
#### `test_get_rejects_invalid_pagination_params`
بررسی می‌کند اگر `page=0` باشد:
- پاسخ `400` بدهد
- پیام خطا مناسب برگردد
#### `test_get_dispatches_zone_task_when_task_id_is_missing`
با mock کردن `dispatch_zone_processing_tasks` بررسی می‌کند که:
- اگر zone task_id نداشته باشد
- در زمان فراخوانی `AreaView`
- dispatch انجام شود
#### `test_get_creates_area_when_sensor_has_no_data`
با mock کردن `create_zones_and_dispatch` بررسی می‌کند که:
- اگر sensor هنوز area نداشته باشد
- سیستم area جدید بسازد
- همان sensor را به service پاس بدهد
#### `test_each_zone_gets_its_own_task`
بررسی می‌کند برای دو zone جدا:
- دو task مستقل ایجاد شود
- هر zone task_id جدا داشته باشد
این تست خیلی مهم است چون تایید می‌کند taskها shared نیستند و per-zone هستند.
#### `test_get_generates_local_task_id_when_broker_is_unavailable`
با mock کردن celery و ایجاد `OperationalError` بررسی می‌کند که:
- حتی وقتی broker در دسترس نیست
- سیستم task_id محلی بسازد
- response خراب نشود
- وضعیت کلی درست بماند
#### `test_get_stores_task_id_and_reuses_it_on_next_request`
بررسی می‌کند:
- وقتی اولین request task_id را ثبت کرد
- request بعدی دوباره task تازه نسازد
- همان task_id قبلی reuse شود
این تست جلوی dispatch تکراری را می‌گیرد.
#### `test_get_redispatches_pending_zone_when_shared_task_already_completed`
این تست سناریوی قدیمی یا خراب را پوشش می‌دهد.
سناریو:
- یک zone completed شده
- zone دیگر pending مانده
- هر دو task_id یکسان دارند
در این حالت سیستم باید zone stale را دوباره dispatch کند.
این تست نشان می‌دهد منطق stale detection واقعا کار می‌کند.
---
## جمع‌بندی معماری
اگر خیلی خلاصه بخواهیم نقش هر فایل را بگوییم:
### `views.py`
لایه HTTP است.
- request را می‌گیرد
- service مناسب را صدا می‌زند
- response برمی‌گرداند
### `services.py`
لایه business logic است.
- area را validate می‌کند
- polygon را به zone تبدیل می‌کند
- recommendation اولیه و نهایی می‌سازد
- taskها را مدیریت می‌کند
- payload response را می‌سازد
### `tests.py`
لایه اطمینان از رفتار سیستم است.
- ساخت area
- ساخت zone
- status task
- dispatch task
- stale task
- pagination
- خطاهای ورودی
را تست می‌کند.
---
## نکات مهم برای فرانت
- endpoint `area` الان pagination دارد و `zones` همیشه همه zoneها را برنمی‌گرداند.
- تعداد کل zoneها از `task.total_zones` یا `pagination.total_zones` قابل خواندن است.
- تعداد کل صفحه‌ها از `pagination.total_pages` قابل خواندن است.
- برای نمایش progress باید از `task.progress_percent` و `task.status` استفاده شود.
- `task.status` وضعیت کلی area است، نه وضعیت تک‌تک zoneها.
- وضعیت هر zone داخل `processing_status` قرار دارد.
- در صورت نیاز به جزئیات بیشتر برای یک zone باید `ZoneDetailsView` صدا زده شود.
---
## نکات مهم برای بک‌اند
- منطق grid سازی و پردازش zoneها تقریبا کامل داخل `services.py` متمرکز شده است.
- `AreaView` عمدا thin نگه داشته شده تا business logic وارد view نشود.
- rule-based data نقش fallback سریع برای UI را دارد.
- data واقعی‌تر بعدا با taskهای تحلیل خاک جایگزین یا تکمیل می‌شود.
- تست‌ها بیشتر روی پایداری flow پردازش و task dispatch تمرکز دارند.
@@ -0,0 +1,285 @@
# Crop Zoning API Guide For Frontend
این فایل برای تیم فرانت نوشته شده و رفتار endpointهای ماژول `crop-zoning` را به صورت کاربردی توضیح می‌دهد.
## Base Path
```text
/api/crop-zoning/
```
## Authentication
- همه endpointها با تنظیم فعلی پروژه نیاز به احراز هویت دارند.
- هدر پیشنهادی:
```http
Authorization: Bearer <access_token>
Content-Type: application/json
```
## Flow پیشنهادی فرانت
1. ابتدا `GET /area/` را با `farm_uuid` صدا بزنید.
2. اگر `task.status` برابر `PENDING` یا `PROCESSING` بود، polling انجام دهید.
3. وقتی `task.status` برابر `SUCCESS` شد:
- `area` را برای polygon اصلی زمین استفاده کنید.
- `zones` را برای grid map و کارت‌های overview استفاده کنید.
4. برای legend محصولات، `GET /products/` را بزنید.
## وضعیت‌های Task
- `IDLE`: هنوز area/taskی برای مزرعه وجود ندارد.
- `PENDING`: تسک ساخته شده ولی پردازش هنوز شروع نشده یا در صف است.
- `PROCESSING`: بخشی از زون‌ها در حال پردازش هستند یا برخی کامل شده‌اند.
- `SUCCESS`: همه زون‌ها کامل پردازش شده‌اند.
- `FAILURE`: یک یا چند زون با خطا مواجه شده‌اند.
## Stageهای Task
- `waiting_to_start`
- `queued`
- `processing_zones`
- `continuing_processing`
- `completed`
- `failed`
فیلد `stage_label` متن فارسی آماده برای نمایش در UI است.
---
## 1) Get Area
```http
GET /api/crop-zoning/area/?farm_uuid=<farm_uuid>&page=1&page_size=10
```
### Query Params
- `farm_uuid`: اجباری، UUID مزرعه
- `page`: اختیاری، شماره صفحه زون‌ها. پیش‌فرض `1`
- `page_size`: اختیاری، تعداد زون در هر صفحه. پیش‌فرض `10`
### کاربرد
- گرفتن آخرین area مربوط به مزرعه
- ساخت area و zoneها در صورت نبود داده
- دریافت وضعیت task
- دریافت لیست `zones` به صورت صفحه‌بندی‌شده برای نمایش روی نقشه
- دریافت اطلاعات pagination برای ساخت pager یا infinite loading در فرانت
### نمونه پاسخ موفق
```json
{
"status": "success",
"data": {
"task": {
"status": "SUCCESS",
"stage": "completed",
"stage_label": "پردازش همه زون‌ها کامل شده است",
"area_uuid": "c0eaa4d7-92bf-4542-a60d-6010b45e7c96",
"total_zones": 364,
"completed_zones": 364,
"processing_zones": 0,
"pending_zones": 0,
"failed_zones": 0,
"remaining_zones": 0,
"progress_percent": 100,
"summary": {
"done": 364,
"in_progress": 0,
"remaining": 0,
"failed": 0
},
"message": "از مجموع 364 زون، 364 زون پردازش شده، 0 زون در حال پردازش و 0 زون باقی مانده است.",
"failed_zone_errors": [],
"cell_side_km": 0.1
},
"area": {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[[51.418934, 35.706815], [51.423054, 35.691062], [51.384258, 35.689389], [51.418934, 35.706815]]]
},
"properties": {
"center": {
"latitude": 35.69575533,
"longitude": 51.40874867
},
"area_sqm": 3109868.97,
"cell_side_km": 0.1,
"area_hectares": 310.9869
}
},
"zones": [
{
"zoneId": "zone-0",
"zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940",
"geometry": {
"type": "Polygon",
"coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]]
},
"center": {
"latitude": 35.68983816,
"longitude": 51.38481102
},
"area_sqm": 9999.91,
"area_hectares": 1,
"sequence": 0,
"processing_status": "completed",
"processing_error": "",
"crop": "wheat",
"matchPercent": 89,
"waterNeed": "4820-5820 m³/ha",
"estimatedProfit": "۱۵-۲۵ میلیون/هکتار",
"waterNeedLayer": {
"level": "medium",
"value": "4820-5820 m³/ha",
"color": "#0ea5e9"
},
"soilQualityLayer": {
"level": "high",
"score": 89,
"color": "#22c55e"
},
"cultivationRiskLayer": {
"level": "low",
"color": "#22c55e"
}
}
],
"pagination": {
"page": 1,
"page_size": 10,
"total_pages": 37,
"total_zones": 364,
"returned_zones": 10,
"has_next": true,
"has_previous": false
}
}
}
```
### رفتار pagination
- `zones` فقط شامل زون‌های همان صفحه‌ای است که در query param فرستاده شده
- `task.total_zones` تعداد کل زون‌های area را نشان می‌دهد، نه فقط زون‌های همان صفحه
- `pagination.total_pages` تعداد کل صفحه‌ها را برای فرانت مشخص می‌کند
- `pagination.returned_zones` تعداد آیتم‌های برگشتی در همان response را نشان می‌دهد
- اگر `page` بزرگ‌تر از `total_pages` باشد، response خطا نمی‌دهد و فقط `zones` خالی برمی‌گردد
### مثال‌ها
#### صفحه اول با 10 زون در هر صفحه
```http
GET /api/crop-zoning/area/?farm_uuid=<farm_uuid>&page=1&page_size=10
```
#### صفحه سوم با 25 زون در هر صفحه
```http
GET /api/crop-zoning/area/?farm_uuid=<farm_uuid>&page=3&page_size=25
```
### فیلدهای مهم `zones`
- `zoneId`: شناسه نمایشی زون، مثل `zone-0`
- `zoneUuid`: UUID داخلی زون
- `geometry`: polygon زون
- `center`: مرکز زون
- `area_sqm`: مساحت به متر مربع
- `area_hectares`: مساحت به هکتار
- `sequence`: ترتیب زون
- `processing_status`: یکی از `pending`, `processing`, `completed`, `failed`
- `processing_error`: متن خطا در صورت failure
- `crop`: محصول پیشنهادی
- `matchPercent`: درصد تطابق
- `waterNeed`: نیاز آبی پیشنهادی
- `estimatedProfit`: سود تخمینی
- `waterNeedLayer`: داده layer نیاز آبی
- `soilQualityLayer`: داده layer کیفیت خاک
- `cultivationRiskLayer`: داده layer ریسک کشت
### فیلدهای مهم `pagination`
- `page`: شماره صفحه فعلی
- `page_size`: تعداد زون در هر صفحه
- `total_pages`: تعداد کل صفحه‌ها
- `total_zones`: تعداد کل زون‌های area
- `returned_zones`: تعداد زون‌های برگشتی در response فعلی
- `has_next`: آیا صفحه بعدی وجود دارد یا نه
- `has_previous`: آیا صفحه قبلی وجود دارد یا نه
### خطاها
#### وقتی `farm_uuid` ارسال نشود
```json
{
"status": "error",
"message": "farm_uuid is required."
}
```
#### وقتی مزرعه پیدا نشود
```json
{
"status": "error",
"message": "Farm not found."
}
```
#### وقتی `page` یا `page_size` نامعتبر باشد
```json
{
"status": "error",
"message": "page must be a positive integer."
}
```
- همین رفتار برای `page_size` هم وجود دارد و پیام خطا به صورت
`page_size must be a positive integer.` برمی‌گردد.
---
## 2) Get Products
```http
GET /api/crop-zoning/products/
```
### کاربرد
- گرفتن لیست محصولات برای legend و labelها
### نمونه پاسخ
```json
{
"status": "success",
"data": {
"products": [
{
"id": "wheat",
"label": "گندم",
"color": "#6bcb77"
},
{
"id": "canola",
"label": "کلزا",
"color": "#ffd93d"
},
{
"id": "saffron",
"label": "زعفران",
"color": "#9b59b6"
}
]
}
}
```
@@ -0,0 +1,282 @@
# Crop Zoning Layer Area API Changes For Frontend
این فایل برای تیم فرانت نوشته شده و فقط تغییرات جدیدی را توضیح می‌دهد که برای endpointهای لایه‌ای ماژول `crop-zoning` اضافه شده‌اند.
## خلاصه تغییر
سه API جدید اضافه شده‌اند که از نظر ساختار response دقیقا شبیه `GET /area/` هستند:
- `GET /api/crop-zoning/water-need/`
- `GET /api/crop-zoning/soil-quality/`
- `GET /api/crop-zoning/cultivation-risk/`
هر سه endpoint:
- به `farm_uuid` نیاز دارند
- از `page` و `page_size` پشتیبانی می‌کنند
- در صورت نبود داده، همان روند ساخت area و zone و dispatch task را مثل `area/` انجام می‌دهند
- همان ساختار `task`, `area`, `zones`, `pagination` را برمی‌گردانند
## هدف این تغییر
قبلا فرانت برای داده‌های لایه‌ای بیشتر به endpointهای `zones/...` متکی بود که خروجی آن‌ها فقط لیست زون‌ها بود.
الان برای هر لایه یک endpoint جدید دارید که خروجی آن برای صفحه map دقیقا با `area/` هم‌فرمت است و استفاده در UI ساده‌تر می‌شود.
## Base Path
```text
/api/crop-zoning/
```
## Authentication
```http
Authorization: Bearer <access_token>
Content-Type: application/json
```
## Endpointهای جدید
### 1) Water Need
```http
GET /api/crop-zoning/water-need/?farm_uuid=<farm_uuid>&page=1&page_size=10
```
### 2) Soil Quality
```http
GET /api/crop-zoning/soil-quality/?farm_uuid=<farm_uuid>&page=1&page_size=10
```
### 3) Cultivation Risk
```http
GET /api/crop-zoning/cultivation-risk/?farm_uuid=<farm_uuid>&page=1&page_size=10
```
## Query Params
- `farm_uuid`: اجباری، UUID مزرعه
- `page`: اختیاری، شماره صفحه زون‌ها، پیش‌فرض `1`
- `page_size`: اختیاری، تعداد زون در هر صفحه، پیش‌فرض `10`
## ساختار کلی response
ساختار کلی هر سه API:
```json
{
"status": "success",
"data": {
"task": {},
"area": {},
"zones": [],
"pagination": {}
}
}
```
یعنی برای فرانت:
- `task` دقیقا مثل `area/` است
- `area` دقیقا مثل `area/` است
- `pagination` دقیقا مثل `area/` است
- فقط shape آیتم‌های داخل `zones` بر اساس نوع لایه فرق می‌کند
## تفاوت `zones` در هر endpoint
### `GET /water-need/`
هر آیتم در `zones` این فیلدها را دارد:
- `zoneId`
- `zoneUuid`
- `geometry`
- `center`
- `area_sqm`
- `area_hectares`
- `sequence`
- `processing_status`
- `processing_error`
- `waterNeedLayer`
نمونه:
```json
{
"zoneId": "zone-0",
"zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940",
"geometry": {
"type": "Polygon",
"coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]]
},
"center": {
"latitude": 35.68983816,
"longitude": 51.38481102
},
"area_sqm": 9999.91,
"area_hectares": 1,
"sequence": 0,
"processing_status": "completed",
"processing_error": "",
"waterNeedLayer": {
"level": "medium",
"value": "4820-5820 m³/ha",
"color": "#0ea5e9"
}
}
```
### `GET /soil-quality/`
هر آیتم در `zones` این فیلدها را دارد:
- `zoneId`
- `zoneUuid`
- `geometry`
- `center`
- `area_sqm`
- `area_hectares`
- `sequence`
- `processing_status`
- `processing_error`
- `soilQualityLayer`
نمونه:
```json
{
"zoneId": "zone-0",
"zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940",
"geometry": {
"type": "Polygon",
"coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]]
},
"center": {
"latitude": 35.68983816,
"longitude": 51.38481102
},
"area_sqm": 9999.91,
"area_hectares": 1,
"sequence": 0,
"processing_status": "completed",
"processing_error": "",
"soilQualityLayer": {
"level": "high",
"score": 89,
"color": "#22c55e"
}
}
```
### `GET /cultivation-risk/`
هر آیتم در `zones` این فیلدها را دارد:
- `zoneId`
- `zoneUuid`
- `geometry`
- `center`
- `area_sqm`
- `area_hectares`
- `sequence`
- `processing_status`
- `processing_error`
- `cultivationRiskLayer`
نمونه:
```json
{
"zoneId": "zone-0",
"zoneUuid": "d7a9a19b-b3ec-4721-b514-9aae5c9ea940",
"geometry": {
"type": "Polygon",
"coordinates": [[[51.384258, 35.689389], [51.38536404, 35.689389], [51.38536404, 35.69028731], [51.384258, 35.69028731], [51.384258, 35.689389]]]
},
"center": {
"latitude": 35.68983816,
"longitude": 51.38481102
},
"area_sqm": 9999.91,
"area_hectares": 1,
"sequence": 0,
"processing_status": "completed",
"processing_error": "",
"cultivationRiskLayer": {
"level": "low",
"color": "#22c55e"
}
}
```
## نکته مهم برای فرانت
این endpointها عمدا شبیه `area/` طراحی شده‌اند تا فرانت بتواند با یک data flow یکسان کار کند:
- polygon اصلی را از `data.area` بگیرد
- task status را از `data.task` بخواند
- pagination را از `data.pagination` بخواند
- فقط renderer مربوط به هر لایه را روی `data.zones` اعمال کند
## پیشنهاد استفاده در UI
### اگر صفحه overview اصلی دارید
- همچنان `GET /area/` بهترین گزینه برای صفحه overview کامل است، چون علاوه بر layerها فیلدهای crop و recommendation را هم داخل هر zone دارد.
### اگر صفحه یا tab مخصوص هر layer دارید
- برای تب نیاز آبی: `GET /water-need/`
- برای تب کیفیت خاک: `GET /soil-quality/`
- برای تب ریسک کشت: `GET /cultivation-risk/`
این کار باعث می‌شود فرانت فقط داده موردنیاز همان layer را بگیرد.
## وضعیت backward compatibility
- endpoint قدیمی `GET /area/` بدون تغییر باقی مانده است
- endpointهای جدید breaking change ایجاد نمی‌کنند
- فقط سه مسیر جدید به API اضافه شده است
## خطاها
رفتار خطاها مثل `area/` است.
### نبودن `farm_uuid`
```json
{
"status": "error",
"message": "farm_uuid is required."
}
```
### پیدا نشدن مزرعه
```json
{
"status": "error",
"message": "Farm not found."
}
```
### نامعتبر بودن `page` یا `page_size`
```json
{
"status": "error",
"message": "page must be a positive integer."
}
```
## جمع‌بندی
تغییر جدید برای فرانت این است که الان به جز `area/`، سه API جدید هم دارید که:
- از نظر query params شبیه `area/` هستند
- از نظر response wrapper شبیه `area/` هستند
- فقط payload داخلی `zones` را بر اساس نوع layer تخصصی می‌کنند
در نتیجه اگر UI شما برای `area/` آماده است، اتصال این سه endpoint جدید باید با کمترین تغییر انجام شود.
+10
View File
@@ -0,0 +1,10 @@
from django.apps import AppConfig
class CropZoningConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "crop_zoning"
verbose_name = "Crop Zoning"
def ready(self):
from . import tasks # noqa: F401
+27
View File
@@ -0,0 +1,27 @@
DEFAULT_AREA_FEATURE = {
"area": {
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.68],
[51.405, 35.672],
[51.41, 35.695],
[51.385, 35.71],
[51.365, 35.688],
[51.38, 35.68],
]
],
},
}
}
DEFAULT_PRODUCTS_PAYLOAD = {
"products": [
{"id": "wheat", "label": "گندم", "color": "#6bcb77"},
{"id": "canola", "label": "کلزا", "color": "#ffd93d"},
{"id": "saffron", "label": "زعفران", "color": "#9b59b6"},
]
}
@@ -0,0 +1,54 @@
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name='CropArea',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
('geometry', models.JSONField(default=dict)),
('points', models.JSONField(default=list)),
('center', models.JSONField(default=dict)),
('area_sqm', models.FloatField()),
('area_hectares', models.FloatField()),
('chunk_area_sqm', models.FloatField()),
('zone_count', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'db_table': 'crop_areas',
'ordering': ['-created_at', '-id'],
},
),
migrations.CreateModel(
name='CropZone',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True)),
('zone_id', models.CharField(max_length=64)),
('geometry', models.JSONField(default=dict)),
('points', models.JSONField(default=list)),
('center', models.JSONField(default=dict)),
('area_sqm', models.FloatField()),
('area_hectares', models.FloatField()),
('sequence', models.PositiveIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('crop_area', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='zones', to='crop_zoning.croparea')),
],
options={
'db_table': 'crop_zones',
'ordering': ['sequence', 'id'],
'constraints': [models.UniqueConstraint(fields=('crop_area', 'zone_id'), name='unique_crop_area_zone_id')],
},
),
]
@@ -0,0 +1,99 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("crop_zoning", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="CropProduct",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("product_id", models.CharField(max_length=64, unique=True)),
("label", models.CharField(max_length=255)),
("color", models.CharField(max_length=32)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "crop_products",
"ordering": ["id"],
},
),
migrations.CreateModel(
name="CropZoneCultivationRiskLayer",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)),
("color", models.CharField(max_length=32)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="cultivation_risk_layer", to="crop_zoning.cropzone")),
],
options={
"db_table": "crop_zone_cultivation_risk_layers",
"ordering": ["crop_zone_id"],
},
),
migrations.CreateModel(
name="CropZoneRecommendation",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("match_percent", models.PositiveIntegerField()),
("water_need", models.CharField(max_length=128)),
("estimated_profit", models.CharField(max_length=128)),
("reason", models.TextField(blank=True, default="")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="recommendation", to="crop_zoning.cropzone")),
("product", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="zone_recommendations", to="crop_zoning.cropproduct")),
],
options={
"db_table": "crop_zone_recommendations",
"ordering": ["crop_zone_id"],
},
),
migrations.CreateModel(
name="CropZoneSoilQualityLayer",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)),
("score", models.PositiveIntegerField()),
("color", models.CharField(max_length=32)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="soil_quality_layer", to="crop_zoning.cropzone")),
],
options={
"db_table": "crop_zone_soil_quality_layers",
"ordering": ["crop_zone_id"],
},
),
migrations.CreateModel(
name="CropZoneWaterNeedLayer",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("level", models.CharField(choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], max_length=16)),
("value", models.CharField(max_length=128)),
("color", models.CharField(max_length=32)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="water_need_layer", to="crop_zoning.cropzone")),
],
options={
"db_table": "crop_zone_water_need_layers",
"ordering": ["crop_zone_id"],
},
),
migrations.CreateModel(
name="CropZoneCriteria",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=128)),
("value", models.PositiveIntegerField()),
("sequence", models.PositiveIntegerField(default=0)),
("recommendation", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="criteria", to="crop_zoning.cropzonerecommendation")),
],
options={
"db_table": "crop_zone_criteria",
"ordering": ["sequence", "id"],
},
),
]
@@ -0,0 +1,49 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("crop_zoning", "0002_crop_zoning_mock_schema"),
]
operations = [
migrations.AddField(
model_name="cropzone",
name="processing_error",
field=models.TextField(blank=True, default=""),
),
migrations.AddField(
model_name="cropzone",
name="processing_status",
field=models.CharField(
choices=[("pending", "Pending"), ("processing", "Processing"), ("completed", "Completed"), ("failed", "Failed")],
default="pending",
max_length=16,
),
),
migrations.AddField(
model_name="cropzone",
name="task_id",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.CreateModel(
name="CropZoneAnalysis",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("source", models.CharField(blank=True, default="", max_length=64)),
("external_record_id", models.CharField(blank=True, default="", max_length=64)),
("latitude", models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)),
("longitude", models.DecimalField(blank=True, decimal_places=6, max_digits=10, null=True)),
("raw_response", models.JSONField(blank=True, default=dict)),
("depths", models.JSONField(blank=True, default=list)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("crop_zone", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name="analysis", to="crop_zoning.cropzone")),
],
options={
"db_table": "crop_zone_analyses",
"ordering": ["crop_zone_id"],
},
),
]
@@ -0,0 +1,23 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("farm_hub", "0002_seed_default_catalog"),
("crop_zoning", "0003_zone_processing_and_analysis"),
]
operations = [
migrations.AddField(
model_name="croparea",
name="farm",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="crop_areas",
to="farm_hub.farmhub",
),
),
]
+354
View File
@@ -0,0 +1,354 @@
"""
Static mock data for Crop Zoning API.
Matches CROP_ZONING_APIS.md. No database, no dynamic values.
"""
# ---------------------------------------------------------------------------
# GET /api/crop-zoning/area/
# منطقهٔ ثابت — کاربر امکان رسم ندارد
# ---------------------------------------------------------------------------
AREA_RESPONSE_DATA = {
"area": {
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.68],
[51.405, 35.672],
[51.41, 35.695],
[51.385, 35.71],
[51.365, 35.688],
[51.38, 35.68],
]
],
},
}
}
# ---------------------------------------------------------------------------
# GET /api/crop-zoning/products/
# ---------------------------------------------------------------------------
PRODUCTS_RESPONSE_DATA = {
"products": [
{"id": "wheat", "label": "گندم", "color": "#6bcb77"},
{"id": "canola", "label": "کلزا", "color": "#ffd93d"},
{"id": "saffron", "label": "زعفران", "color": "#9b59b6"},
]
}
# ---------------------------------------------------------------------------
# POST /api/crop-zoning/zones/initial/
# دیتای اولیه برای نقشه و هاور/tooltip — بدون reason و criteria
# ---------------------------------------------------------------------------
ZONES_INITIAL_RESPONSE_DATA = {
"total_area_hectares": 23.45,
"total_area_sqm": 234500,
"zone_count": 3,
"zones": [
{
"zoneId": "zone-0",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.68],
[51.3815, 35.68],
[51.3815, 35.6815],
[51.38, 35.6815],
[51.38, 35.68],
]
],
},
"crop": "wheat",
"matchPercent": 85,
"waterNeed": "۴۵۰۰-۵۵۰۰ m³/ha",
"estimatedProfit": "۱۵-۲۵ میلیون/هکتار",
},
{
"zoneId": "zone-1",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.3815, 35.68],
[51.383, 35.68],
[51.383, 35.6815],
[51.3815, 35.6815],
[51.3815, 35.68],
]
],
},
"crop": "canola",
"matchPercent": 78,
"waterNeed": "۵۰۰۰-۶۰۰۰ m³/ha",
"estimatedProfit": "۲۰-۳۵ میلیون/هکتار",
},
{
"zoneId": "zone-2",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.6815],
[51.3815, 35.6815],
[51.3815, 35.683],
[51.38, 35.683],
[51.38, 35.6815],
]
],
},
"crop": "saffron",
"matchPercent": 92,
"waterNeed": "۳۰۰۰-۴۰۰۰ m³/ha",
"estimatedProfit": "۵۰-۱۵۰ میلیون/هکتار",
},
],
}
# ---------------------------------------------------------------------------
# POST /api/crop-zoning/zones/water-need/
# نیاز آبی هر منطقه برای لایهٔ نیاز آبی
# ---------------------------------------------------------------------------
ZONES_WATER_NEED_RESPONSE_DATA = {
"zones": [
{
"zoneId": "zone-0",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.68],
[51.3815, 35.68],
[51.3815, 35.6815],
[51.38, 35.6815],
[51.38, 35.68],
]
],
},
"level": "medium",
"value": "۴۵۰۰-۵۵۰۰ m³/ha",
"color": "#0ea5e9",
},
{
"zoneId": "zone-1",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.3815, 35.68],
[51.383, 35.68],
[51.383, 35.6815],
[51.3815, 35.6815],
[51.3815, 35.68],
]
],
},
"level": "high",
"value": "۵۰۰۰-۶۰۰۰ m³/ha",
"color": "#0369a1",
},
{
"zoneId": "zone-2",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.6815],
[51.3815, 35.6815],
[51.3815, 35.683],
[51.38, 35.683],
[51.38, 35.6815],
]
],
},
"level": "low",
"value": "۳۰۰۰-۴۰۰۰ m³/ha",
"color": "#7dd3fc",
},
]
}
# ---------------------------------------------------------------------------
# POST /api/crop-zoning/zones/soil-quality/
# کیفیت خاک هر منطقه برای لایهٔ کیفیت خاک
# ---------------------------------------------------------------------------
ZONES_SOIL_QUALITY_RESPONSE_DATA = {
"zones": [
{
"zoneId": "zone-0",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.68],
[51.3815, 35.68],
[51.3815, 35.6815],
[51.38, 35.6815],
[51.38, 35.68],
]
],
},
"level": "high",
"score": 88,
"color": "#22c55e",
},
{
"zoneId": "zone-1",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.3815, 35.68],
[51.383, 35.68],
[51.383, 35.6815],
[51.3815, 35.6815],
[51.3815, 35.68],
]
],
},
"level": "medium",
"score": 62,
"color": "#eab308",
},
{
"zoneId": "zone-2",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.6815],
[51.3815, 35.6815],
[51.3815, 35.683],
[51.38, 35.683],
[51.38, 35.6815],
]
],
},
"level": "high",
"score": 95,
"color": "#22c55e",
},
]
}
# ---------------------------------------------------------------------------
# POST /api/crop-zoning/zones/cultivation-risk/
# ریسک کشت هر منطقه برای لایهٔ ریسک کشت
# ---------------------------------------------------------------------------
ZONES_CULTIVATION_RISK_RESPONSE_DATA = {
"zones": [
{
"zoneId": "zone-0",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.68],
[51.3815, 35.68],
[51.3815, 35.6815],
[51.38, 35.6815],
[51.38, 35.68],
]
],
},
"level": "low",
"color": "#22c55e",
},
{
"zoneId": "zone-1",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.3815, 35.68],
[51.383, 35.68],
[51.383, 35.6815],
[51.3815, 35.6815],
[51.3815, 35.68],
]
],
},
"level": "medium",
"color": "#f59e0b",
},
{
"zoneId": "zone-2",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.38, 35.6815],
[51.3815, 35.6815],
[51.3815, 35.683],
[51.38, 35.683],
[51.38, 35.6815],
]
],
},
"level": "low",
"color": "#22c55e",
},
]
}
# ---------------------------------------------------------------------------
# GET /api/crop-zoning/zones/:zoneId/details/
# دیتای تکمیلی برای پنل جزئیات — شامل reason و criteria
# منطبق با createZonedGrid و MOCK_AREA_GEOJSON
# ---------------------------------------------------------------------------
ZONE_DETAILS_BY_ID = {
"zone-0": {
"zoneId": "zone-0",
"crop": "wheat",
"matchPercent": 85,
"waterNeed": "۴۵۰۰-۵۵۰۰ m³/ha",
"estimatedProfit": "۱۵-۲۵ میلیون/هکتار",
"reason": "دمای مناسب، خاک حاصلخیز، دسترسی به آب کافی",
"criteria": [
{"name": "دما", "value": 82},
{"name": "بارش", "value": 75},
{"name": "خاک", "value": 88},
{"name": "آب", "value": 70},
],
"area_hectares": 2.25,
},
"zone-1": {
"zoneId": "zone-1",
"crop": "canola",
"matchPercent": 78,
"waterNeed": "۵۰۰۰-۶۰۰۰ m³/ha",
"estimatedProfit": "۲۰-۳۵ میلیون/هکتار",
"reason": "شرایط اقلیمی مساعد، نیاز آبی قابل تأمین",
"criteria": [
{"name": "دما", "value": 75},
{"name": "بارش", "value": 72},
{"name": "خاک", "value": 80},
{"name": "آب", "value": 78},
],
"area_hectares": 2.25,
},
"zone-2": {
"zoneId": "zone-2",
"crop": "saffron",
"matchPercent": 92,
"waterNeed": "۳۰۰۰-۴۰۰۰ m³/ha",
"estimatedProfit": "۵۰-۱۵۰ میلیون/هکتار",
"reason": "ارتفاع و آب و هوای خشک مناسب، پتانسیل سود بالا",
"criteria": [
{"name": "دما", "value": 90},
{"name": "بارش", "value": 65},
{"name": "خاک", "value": 95},
{"name": "آب", "value": 85},
],
"area_hectares": 2.25,
},
}
+224
View File
@@ -0,0 +1,224 @@
import uuid
from django.db import models
from farm_hub.models import FarmHub
class CropArea(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
farm = models.ForeignKey(
FarmHub,
on_delete=models.CASCADE,
related_name="crop_areas",
null=True,
blank=True,
db_index=True,
)
geometry = models.JSONField(default=dict)
points = models.JSONField(default=list)
center = models.JSONField(default=dict)
area_sqm = models.FloatField()
area_hectares = models.FloatField()
chunk_area_sqm = models.FloatField()
zone_count = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_areas"
ordering = ["-created_at", "-id"]
def __str__(self):
return f"Area {self.uuid}"
class CropZone(models.Model):
STATUS_PENDING = "pending"
STATUS_PROCESSING = "processing"
STATUS_COMPLETED = "completed"
STATUS_FAILED = "failed"
STATUS_CHOICES = (
(STATUS_PENDING, "Pending"),
(STATUS_PROCESSING, "Processing"),
(STATUS_COMPLETED, "Completed"),
(STATUS_FAILED, "Failed"),
)
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
crop_area = models.ForeignKey(
CropArea,
on_delete=models.CASCADE,
related_name="zones",
)
zone_id = models.CharField(max_length=64)
geometry = models.JSONField(default=dict)
points = models.JSONField(default=list)
center = models.JSONField(default=dict)
area_sqm = models.FloatField()
area_hectares = models.FloatField()
sequence = models.PositiveIntegerField()
processing_status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
processing_error = models.TextField(blank=True, default="")
task_id = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_zones"
ordering = ["sequence", "id"]
constraints = [
models.UniqueConstraint(fields=["crop_area", "zone_id"], name="unique_crop_area_zone_id"),
]
def __str__(self):
return self.zone_id
class CropProduct(models.Model):
product_id = models.CharField(max_length=64, unique=True)
label = models.CharField(max_length=255)
color = models.CharField(max_length=32)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_products"
ordering = ["id"]
def __str__(self):
return self.label
class CropZoneRecommendation(models.Model):
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="recommendation",
)
product = models.ForeignKey(
CropProduct,
on_delete=models.PROTECT,
related_name="zone_recommendations",
)
match_percent = models.PositiveIntegerField()
water_need = models.CharField(max_length=128)
estimated_profit = models.CharField(max_length=128)
reason = models.TextField(blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_zone_recommendations"
ordering = ["crop_zone_id"]
def __str__(self):
return f"{self.crop_zone.zone_id} -> {self.product.product_id}"
class CropZoneCriteria(models.Model):
recommendation = models.ForeignKey(
CropZoneRecommendation,
on_delete=models.CASCADE,
related_name="criteria",
)
name = models.CharField(max_length=128)
value = models.PositiveIntegerField()
sequence = models.PositiveIntegerField(default=0)
class Meta:
db_table = "crop_zone_criteria"
ordering = ["sequence", "id"]
def __str__(self):
return f"{self.name}: {self.value}"
class CropZoneWaterNeedLayer(models.Model):
LEVEL_LOW = "low"
LEVEL_MEDIUM = "medium"
LEVEL_HIGH = "high"
LEVEL_CHOICES = (
(LEVEL_LOW, "Low"),
(LEVEL_MEDIUM, "Medium"),
(LEVEL_HIGH, "High"),
)
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="water_need_layer",
)
level = models.CharField(max_length=16, choices=LEVEL_CHOICES)
value = models.CharField(max_length=128)
color = models.CharField(max_length=32)
class Meta:
db_table = "crop_zone_water_need_layers"
ordering = ["crop_zone_id"]
class CropZoneSoilQualityLayer(models.Model):
LEVEL_LOW = "low"
LEVEL_MEDIUM = "medium"
LEVEL_HIGH = "high"
LEVEL_CHOICES = (
(LEVEL_LOW, "Low"),
(LEVEL_MEDIUM, "Medium"),
(LEVEL_HIGH, "High"),
)
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="soil_quality_layer",
)
level = models.CharField(max_length=16, choices=LEVEL_CHOICES)
score = models.PositiveIntegerField()
color = models.CharField(max_length=32)
class Meta:
db_table = "crop_zone_soil_quality_layers"
ordering = ["crop_zone_id"]
class CropZoneCultivationRiskLayer(models.Model):
LEVEL_LOW = "low"
LEVEL_MEDIUM = "medium"
LEVEL_HIGH = "high"
LEVEL_CHOICES = (
(LEVEL_LOW, "Low"),
(LEVEL_MEDIUM, "Medium"),
(LEVEL_HIGH, "High"),
)
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="cultivation_risk_layer",
)
level = models.CharField(max_length=16, choices=LEVEL_CHOICES)
color = models.CharField(max_length=32)
class Meta:
db_table = "crop_zone_cultivation_risk_layers"
ordering = ["crop_zone_id"]
class CropZoneAnalysis(models.Model):
source = models.CharField(max_length=64, blank=True, default="")
external_record_id = models.CharField(max_length=64, blank=True, default="")
latitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True)
longitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True)
raw_response = models.JSONField(default=dict, blank=True)
depths = models.JSONField(default=list, blank=True)
crop_zone = models.OneToOneField(
CropZone,
on_delete=models.CASCADE,
related_name="analysis",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "crop_zone_analyses"
ordering = ["crop_zone_id"]
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
from celery import shared_task
from django.db import transaction
from .services import analyze_and_store_zone_soil_data
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
)
def process_zone_soil_data(self, zone_id):
with transaction.atomic():
analyze_and_store_zone_soil_data(zone_id=zone_id)
return {"zone_id": zone_id, "status": "processed"}
+419
View File
@@ -0,0 +1,419 @@
from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from kombu.exceptions import OperationalError
from rest_framework.test import APIRequestFactory, force_authenticate
from crop_zoning.models import CropArea, CropZone
from crop_zoning.views import (
AreaView,
CultivationRiskView,
SoilQualityView,
WaterNeedView,
ZonesInitialView,
)
from farm_hub.models import FarmHub, FarmType
AREA_GEOJSON = {
"type": "Feature",
"properties": {},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[51.418934, 35.706815],
[51.423054, 35.691062],
[51.384258, 35.689389],
[51.418934, 35.706815],
]
],
},
}
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class ZonesInitialViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
def test_post_accepts_area_geojson_alias(self):
request = self.factory.post(
"/api/crop-zoning/zones/initial/",
{"area_geojson": AREA_GEOJSON},
format="json",
)
response = ZonesInitialView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
self.assertGreater(response.data["data"]["zone_count"], 1)
self.assertEqual(
response.data["data"]["zone_count"],
len(response.data["data"]["zones"]),
)
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class AreaViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="farmer",
password="secret123",
email="farmer@example.com",
phone_number="09120000000",
)
self.other_user = get_user_model().objects.create_user(
username="other-farmer",
password="secret123",
email="other@example.com",
phone_number="09120000001",
)
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, name="farm-1", farm_type=self.farm_type)
self.other_farm = FarmHub.objects.create(owner=self.other_user, name="farm-2", farm_type=self.farm_type)
def _create_area(self, **kwargs):
defaults = {
"farm": self.farm,
"geometry": AREA_GEOJSON,
"points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
"center": {"longitude": 51.40874867, "latitude": 35.69575533},
"area_sqm": 300000,
"area_hectares": 30,
"chunk_area_sqm": 200000,
"zone_count": 2,
}
defaults.update(kwargs)
return CropArea.objects.create(**defaults)
def _request(self):
request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
return request
def _request_with_pagination(self, page=1, page_size=10):
request = self.factory.get(
f"/api/crop-zoning/area/?farm_uuid={self.farm.farm_uuid}&page={page}&page_size={page_size}"
)
force_authenticate(request, user=self.user)
return request
def test_get_requires_farm_uuid(self):
request = self.factory.get("/api/crop-zoning/area/")
force_authenticate(request, user=self.user)
response = AreaView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "farm_uuid is required.")
def test_get_rejects_foreign_farm_uuid(self):
request = self.factory.get(f"/api/crop-zoning/area/?farm_uuid={self.other_farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = AreaView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "Farm not found.")
def test_get_returns_pending_task_status_until_all_zones_complete(self):
crop_area = self._create_area()
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="celery-task-1",
)
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-1",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=100000,
area_hectares=10,
sequence=1,
processing_status=CropZone.STATUS_PROCESSING,
task_id="celery-task-1",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
self.assertEqual(response.data["data"]["task"]["status"], "PROCESSING")
self.assertEqual(response.data["data"]["task"]["total_zones"], 2)
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
self.assertEqual(len(response.data["data"]["zones"]), 2)
self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-0")
self.assertIn("processing_status", response.data["data"]["zones"][0])
def test_get_returns_area_when_all_tasks_complete(self):
crop_area = self._create_area()
for sequence in range(2):
CropZone.objects.create(
crop_area=crop_area,
zone_id=f"zone-{sequence}",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957},
area_sqm=150000,
area_hectares=15,
sequence=sequence,
processing_status=CropZone.STATUS_COMPLETED,
task_id="celery-task-1",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
self.assertEqual(len(response.data["data"]["zones"]), 2)
self.assertEqual(response.data["data"]["zones"][1]["zoneId"], "zone-1")
self.assertIn("crop", response.data["data"]["zones"][0])
self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
def test_get_returns_paginated_zones(self):
crop_area = self._create_area(zone_count=3, area_sqm=300000, area_hectares=30)
for sequence in range(3):
CropZone.objects.create(
crop_area=crop_area,
zone_id=f"zone-{sequence}",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087 + (sequence * 0.0001), "latitude": 35.6957},
area_sqm=100000,
area_hectares=10,
sequence=sequence,
processing_status=CropZone.STATUS_COMPLETED,
task_id=f"celery-task-{sequence}",
)
response = AreaView.as_view()(self._request_with_pagination(page=2, page_size=1))
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertEqual(response.data["data"]["zones"][0]["zoneId"], "zone-1")
self.assertEqual(response.data["data"]["pagination"]["page"], 2)
self.assertEqual(response.data["data"]["pagination"]["page_size"], 1)
self.assertEqual(response.data["data"]["pagination"]["total_pages"], 3)
self.assertTrue(response.data["data"]["pagination"]["has_next"])
self.assertTrue(response.data["data"]["pagination"]["has_previous"])
def test_get_rejects_invalid_pagination_params(self):
response = AreaView.as_view()(self._request_with_pagination(page=0, page_size=10))
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "page must be a positive integer.")
@patch("crop_zoning.services.dispatch_zone_processing_tasks")
def test_get_dispatches_zone_task_when_task_id_is_missing(self, mock_dispatch):
crop_area = self._create_area(zone_count=1, area_sqm=200000, area_hectares=20)
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["status"], "success")
mock_dispatch.assert_called_once()
@patch("crop_zoning.services.create_zones_and_dispatch")
def test_get_creates_area_when_farm_has_no_data(self, mock_create):
created_area = self._create_area(zone_count=0)
mock_create.return_value = (created_area, [])
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
mock_create.assert_called_once()
self.assertEqual(mock_create.call_args.kwargs["farm"], self.farm)
@patch("crop_zoning.tasks.process_zone_soil_data.delay")
def test_each_zone_gets_its_own_task(self, mock_delay):
crop_area = self._create_area()
zone0 = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
zone1 = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-1",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4088, "latitude": 35.6958},
area_sqm=100000,
area_hectares=10,
sequence=1,
processing_status=CropZone.STATUS_PENDING,
task_id="",
)
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_delay.call_count, 2)
zone0.refresh_from_db()
zone1.refresh_from_db()
self.assertTrue(zone0.task_id)
self.assertTrue(zone1.task_id)
self.assertNotEqual(zone0.task_id, zone1.task_id)
@patch("crop_zoning.services.AsyncResult")
def test_stale_tasks_are_redispatched(self, mock_async_result):
crop_area = self._create_area()
stale_time = timezone.now() - timedelta(minutes=10)
stale_zone = CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=200000,
area_hectares=20,
sequence=0,
processing_status=CropZone.STATUS_PROCESSING,
task_id="stale-task",
)
CropZone.objects.filter(id=stale_zone.id).update(updated_at=stale_time)
mock_async_result.side_effect = OperationalError("broker down")
with patch("crop_zoning.services.dispatch_zone_processing_tasks") as mock_dispatch:
response = AreaView.as_view()(self._request())
self.assertEqual(response.status_code, 200)
mock_dispatch.assert_called_once_with(zone_ids=[stale_zone.id], force=True)
@override_settings(
USE_EXTERNAL_API_MOCK=True,
CROP_ZONE_CHUNK_AREA_SQM=200000,
)
class LayerAreaViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="layer-farmer",
password="secret123",
email="layer@example.com",
phone_number="09120000002",
)
self.farm_type = FarmType.objects.create(name="باغی")
self.farm = FarmHub.objects.create(owner=self.user, name="layer-farm", farm_type=self.farm_type)
def _create_area(self, **kwargs):
defaults = {
"farm": self.farm,
"geometry": AREA_GEOJSON,
"points": AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
"center": {"longitude": 51.40874867, "latitude": 35.69575533},
"area_sqm": 300000,
"area_hectares": 30,
"chunk_area_sqm": 200000,
"zone_count": 1,
}
defaults.update(kwargs)
return CropArea.objects.create(**defaults)
def _create_completed_zone(self):
crop_area = self._create_area()
CropZone.objects.create(
crop_area=crop_area,
zone_id="zone-0",
geometry=AREA_GEOJSON["geometry"],
points=AREA_GEOJSON["geometry"]["coordinates"][0][:-1],
center={"longitude": 51.4087, "latitude": 35.6957},
area_sqm=300000,
area_hectares=30,
sequence=0,
processing_status=CropZone.STATUS_COMPLETED,
task_id="celery-task-1",
)
return crop_area
def _request(self, path):
request = self.factory.get(f"{path}?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
return request
def test_water_need_view_requires_farm_uuid(self):
request = self.factory.get("/api/crop-zoning/water-need/")
force_authenticate(request, user=self.user)
response = WaterNeedView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["message"], "farm_uuid is required.")
def test_water_need_view_returns_area_style_payload(self):
self._create_completed_zone()
response = WaterNeedView.as_view()(self._request("/api/crop-zoning/water-need/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(response.data["data"]["area"], AREA_GEOJSON)
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0])
self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0])
def test_soil_quality_view_returns_area_style_payload(self):
self._create_completed_zone()
response = SoilQualityView.as_view()(self._request("/api/crop-zoning/soil-quality/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("soilQualityLayer", response.data["data"]["zones"][0])
self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("cultivationRiskLayer", response.data["data"]["zones"][0])
def test_cultivation_risk_view_returns_area_style_payload(self):
self._create_completed_zone()
response = CultivationRiskView.as_view()(self._request("/api/crop-zoning/cultivation-risk/"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["task"]["status"], "SUCCESS")
self.assertEqual(len(response.data["data"]["zones"]), 1)
self.assertIn("cultivationRiskLayer", response.data["data"]["zones"][0])
self.assertNotIn("waterNeedLayer", response.data["data"]["zones"][0])
self.assertNotIn("soilQualityLayer", response.data["data"]["zones"][0])
+43
View File
@@ -0,0 +1,43 @@
from django.urls import path
from .views import (
AreaView,
CultivationRiskView,
ProductsView,
SoilQualityView,
WaterNeedView,
ZoneDetailsView,
ZonesCultivationRiskView,
ZonesInitialView,
ZonesSoilQualityView,
ZonesWaterNeedView,
)
urlpatterns = [
path("area/", AreaView.as_view(), name="crop-zoning-area"),
path("water-need/", WaterNeedView.as_view(), name="crop-zoning-water-need"),
path("soil-quality/", SoilQualityView.as_view(), name="crop-zoning-soil-quality"),
path("cultivation-risk/", CultivationRiskView.as_view(), name="crop-zoning-cultivation-risk"),
path("products/", ProductsView.as_view(), name="crop-zoning-products"),
# path("zones/initial/", ZonesInitialView.as_view(), name="crop-zoning-zones-initial"),
# path(
# "zones/water-need/",
# ZonesWaterNeedView.as_view(),
# name="crop-zoning-zones-water-need",
# ),
# path(
# "zones/soil-quality/",
# ZonesSoilQualityView.as_view(),
# name="crop-zoning-zones-soil-quality",
# ),
# path(
# "zones/cultivation-risk/",
# ZonesCultivationRiskView.as_view(),
# name="crop-zoning-zones-cultivation-risk",
# ),
path(
"zones/<str:zone_id>/details/",
ZoneDetailsView.as_view(),
name="crop-zoning-zone-details",
),
]
+215
View File
@@ -0,0 +1,215 @@
from django.core.exceptions import ImproperlyConfigured
from django.http import Http404
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from config.swagger import status_response
from .services import (
create_zones_and_dispatch,
ensure_latest_area_ready_for_processing,
get_latest_cultivation_risk_payload,
get_cultivation_risk_payload,
get_default_area_feature,
get_initial_zones_payload,
get_latest_area_payload,
get_latest_soil_quality_payload,
get_latest_water_need_payload,
get_products_payload,
get_soil_quality_payload,
get_water_need_payload,
get_zone_details_payload,
get_zone_page_request_params,
)
AREA_QUERY_PARAMETERS = [
OpenApiParameter(
name="farm_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
required=True,
description="UUID مزرعه برای گرفتن يا ساخت آخرين پردازش محدوده همان مزرعه.",
default="11111111-1111-1111-1111-111111111111"),
OpenApiParameter(
name="page",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="شماره صفحه زون ها. مقدار پيش فرض 1 است.",
),
OpenApiParameter(
name="page_size",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
required=False,
description="تعداد زون در هر صفحه. مقدار پيش فرض 10 است.",
),
]
class BaseAreaDataView(APIView):
payload_getter = None
def get(self, request):
farm_uuid = request.query_params.get("farm_uuid")
try:
page, page_size = get_zone_page_request_params(request.query_params)
crop_area = ensure_latest_area_ready_for_processing(farm_uuid=farm_uuid, owner=request.user)
except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response(
{"status": "success", "data": self.payload_getter(crop_area, page=page, page_size=page_size)},
status=status.HTTP_200_OK,
)
class AreaView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_area_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningAreaResponse", data=serializers.JSONField()),
400: status_response("CropZoningAreaValidationError", data=serializers.JSONField()),
500: status_response("CropZoningAreaServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class WaterNeedView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_water_need_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningWaterNeedResponse", data=serializers.JSONField()),
400: status_response("CropZoningWaterNeedValidationError", data=serializers.JSONField()),
500: status_response("CropZoningWaterNeedServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class SoilQualityView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_soil_quality_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningSoilQualityResponse", data=serializers.JSONField()),
400: status_response("CropZoningSoilQualityValidationError", data=serializers.JSONField()),
500: status_response("CropZoningSoilQualityServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class CultivationRiskView(BaseAreaDataView):
payload_getter = staticmethod(get_latest_cultivation_risk_payload)
@extend_schema(
tags=["Crop Zoning"],
parameters=AREA_QUERY_PARAMETERS,
responses={
200: status_response("CropZoningCultivationRiskResponse", data=serializers.JSONField()),
400: status_response("CropZoningCultivationRiskValidationError", data=serializers.JSONField()),
500: status_response("CropZoningCultivationRiskServerError", data=serializers.JSONField()),
},
)
def get(self, request):
return super().get(request)
class ProductsView(APIView):
@extend_schema(
tags=["Crop Zoning"],
responses={200: status_response("CropZoningProductsResponse", data=serializers.JSONField())},
)
def get(self, request):
return Response({"status": "success", "data": get_products_payload()}, status=status.HTTP_200_OK)
class ZonesInitialView(APIView):
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesInitialResponse", data=serializers.JSONField())},
)
def post(self, request):
area_feature = (
request.data.get("area")
or request.data.get("area_geojson")
or request.data.get("boundary")
or get_default_area_feature()
)
cell_side_km = request.data.get("cell_side_km")
try:
crop_area, _zones = create_zones_and_dispatch(area_feature, cell_side_km=cell_side_km)
except ValueError as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except ImproperlyConfigured as exc:
return Response({"status": "error", "message": str(exc)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({"status": "success", "data": get_initial_zones_payload(crop_area)}, status=status.HTTP_200_OK)
class ZonesWaterNeedView(APIView):
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesWaterNeedResponse", data=serializers.JSONField())},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_water_need_payload(zone_ids)}, status=status.HTTP_200_OK)
class ZonesSoilQualityView(APIView):
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesSoilQualityResponse", data=serializers.JSONField())},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_soil_quality_payload(zone_ids)}, status=status.HTTP_200_OK)
class ZonesCultivationRiskView(APIView):
@extend_schema(
tags=["Crop Zoning"],
request=OpenApiTypes.OBJECT,
responses={200: status_response("CropZoningZonesCultivationRiskResponse", data=serializers.JSONField())},
)
def post(self, request):
zone_ids = request.data.get("zoneIds")
return Response({"status": "success", "data": get_cultivation_risk_payload(zone_ids)}, status=status.HTTP_200_OK)
class ZoneDetailsView(APIView):
@extend_schema(
tags=["Crop Zoning"],
responses={200: status_response("CropZoningZoneDetailsResponse", data=serializers.JSONField())},
)
def get(self, request, zone_id):
try:
data = get_zone_details_payload(zone_id)
except Exception as exc:
if exc.__class__.__name__ == "DoesNotExist":
raise Http404("Zone not found")
raise
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class DashboardConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "dashboard"
verbose_name = "Farm Dashboard"
+42
View File
@@ -0,0 +1,42 @@
from copy import deepcopy
VALID_ROW_IDS = [
"overviewKpis",
"weatherAlerts",
"sensorMonitoring",
"sensorCharts",
"alertsWater",
"predictions",
"soilHeatmap",
"ndviRecommendations",
"economic",
]
VALID_CARD_IDS = [
"farmOverviewKpis",
"farmWeatherCard",
"farmAlertsTracker",
"sensorValuesList",
"sensorRadarChart",
"sensorComparisonChart",
"anomalyDetectionCard",
"farmAlertsTimeline",
"waterNeedPrediction",
"harvestPredictionCard",
"yieldPredictionChart",
"soilMoistureHeatmap",
"ndviHealthCard",
"recommendationsList",
"economicOverview",
]
DEFAULT_CONFIG = {
"disabled_card_ids": [],
"row_order": VALID_ROW_IDS.copy(),
"enable_drag_reorder": True,
}
def get_default_dashboard_config():
return deepcopy(DEFAULT_CONFIG)
@@ -0,0 +1,36 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("farm_hub", "0002_seed_default_catalog"),
]
operations = [
migrations.CreateModel(
name="FarmDashboardConfig",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("disabled_card_ids", models.JSONField(blank=True, default=list)),
("row_order", models.JSONField(default=list)),
("enable_drag_reorder", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"farm",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="dashboard_config",
to="farm_hub.farmhub",
),
),
],
options={
"db_table": "farm_dashboard_configs",
"ordering": ["-updated_at", "-id"],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-04-25 21:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='farmdashboardconfig',
name='row_order',
field=models.JSONField(blank=True, default=list),
),
]

Some files were not shown because too many files have changed in this diff Show More