UPDATE
This commit is contained in:
@@ -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 app’s 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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,16 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.venv
|
||||
venv
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov
|
||||
*.log
|
||||
media
|
||||
staticfiles
|
||||
.cursor
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
[submodule "Schemas"]
|
||||
path = Schemas
|
||||
url = ssh://git@git.crop-logic.ir:2222/sajad-dev/Schemas.git
|
||||
branch = develop
|
||||
@@ -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 Django’s built-in test runner (`unittest` style). Place tests in each app’s `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/*` |
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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 = []
|
||||
@@ -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")
|
||||
@@ -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 {}
|
||||
@@ -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/",
|
||||
)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "account"
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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 گسترش یافته است
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
@@ -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": ""}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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.
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"
|
||||
@@ -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"},
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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/")
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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 جدید باید با کمترین تغییر انجام شود.
|
||||
@@ -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
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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"}
|
||||
@@ -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])
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
Reference in New Issue
Block a user