This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
@@ -0,0 +1,492 @@
# مستند API آبیاری و محصولات انتخاب‌شده
این فایل برای تحویل به فرانت نوشته شده و endpointهای مرتبط با آبیاری را به‌صورت کامل توضیح می‌دهد.
محدوده این مستند:
- همه endpointهای `irrigation/urls.py`
- endpoint دریافت محصولات انتخاب‌شده مزرعه: `GET /api/plants/selected/`
## نکات عمومی
- همه endpointها نیاز به authentication کاربر دارند، مگر اینکه در gateway یا لایه بالاتر خلاف آن تنظیم شده باشد.
- در همه endpointهای وابسته به مزرعه، `farm_uuid` باید متعلق به همان کاربر لاگین‌شده باشد.
- فرمت کلی پاسخ‌های موفق در این backend معمولاً به شکل زیر است:
```json
{
"code": 200,
"msg": "success",
"data": {}
}
```
- در خطاهای اعتبارسنجی معمولاً ساختار زیر برمی‌گردد:
```json
{
"farm_uuid": [
"This field is required."
]
}
```
یا در بعضی endpointها:
```json
{
"code": 404,
"msg": "error",
"data": {
"farm_uuid": [
"Farm not found."
]
}
}
```
---
# 1) محصولات انتخاب‌شده مزرعه
## GET `/api/plants/selected/`
این endpoint برای گرفتن محصول/محصولات انتخاب‌شده یک مزرعه استفاده می‌شود؛ یعنی همان محصولاتی که روی خود `FarmHub.products` ذخیره شده‌اند.
این endpoint برای فرانت مفید است وقتی می‌خواهید:
- محصول فعلی مزرعه را نمایش دهید
- لیست گیاه‌های متصل به مزرعه را برای انتخاب stage یا recommendation استفاده کنید
- قبل از درخواست recommendation، محصول‌های مرتبط با همان مزرعه را بخوانید
### Query Params
#### `farm_uuid`
- نوع: `string (uuid)`
- اجباری: بله
- توضیح: شناسه مزرعه برای خواندن محصولات انتخاب‌شده آن.
### نمونه درخواست
```bash
curl -s "http://localhost:8000/api/plants/selected/?farm_uuid=11111111-1111-1111-1111-111111111111" \
-H "accept: application/json" \
-H "Authorization: Bearer <token>"
```
### پاسخ موفق
```json
{
"code": 200,
"msg": "success",
"data": [
{
"name": "گوجه فرنگی",
"icon": "tabler-carrot",
"growth_stages": ["رویشی", "گلدهی", "میوه دهی"]
}
]
}
```
### فیلدهای هر آیتم
#### `name`
- نوع: `string`
- توضیح: نام محصول.
#### `icon`
- نوع: `string`
- توضیح: آیکون پیشنهادی برای UI.
#### `growth_stages`
- نوع: `array<string>`
- توضیح: مراحل رشد قابل استفاده برای فرانت.
### خطاهای رایج
#### اگر `farm_uuid` ارسال نشود
```json
{
"farm_uuid": ["This field is required."]
}
```
#### اگر مزرعه متعلق به کاربر نباشد یا پیدا نشود
```json
{
"farm_uuid": ["Farm not found."]
}
```
# 5) تولید recommendation آبیاری
## POST `/api/irrigation/recommend/`
این endpoint recommendation آبیاری را تولید می‌کند و خروجی آن با UI فعلی recommendation هماهنگ شده است.
نکته مهم:
- روش آبیاری از body فرانت خوانده نمی‌شود.
- backend روش آبیاری را از خود مزرعه (`FarmHub.irrigation_method_id` و `FarmHub.irrigation_method_name`) برمی‌دارد.
- بنابراین قبل از صدا زدن این endpoint، فرانت باید روش آبیاری انتخاب‌شده را روی مزرعه ذخیره کرده باشد.
## ساختار کلی پاسخ
```json
{
"code": 200,
"msg": "success",
"data": {
"recommendation_uuid": "...",
"crop_id": "گوجه فرنگی",
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
"irrigation_method_name": "آبیاری قطره ای",
"status": "pending_confirmation",
"status_label": "منتظر تایید",
"plan": {},
"water_balance": {},
"timeline": [],
"sections": []
}
}
```
## Request
### حداقل payload پیشنهادی
```json
{
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی"
}
```
### فیلدهای Request
### `farm_uuid`
- نوع: `string`
- اجباری: بله
- توضیح: شناسه یکتای مزرعه.
### `sensor_uuid`
- نوع: `string`
- اجباری: خیر
- توضیح: نام قدیمی برای `farm_uuid`. اگر `farm_uuid` ارسال نشده باشد، این مقدار به جای آن استفاده می‌شود.
### `plant_name`
- نوع: `string`
- اجباری: خیر
- توضیح: نام گیاه هدف برای تولید recommendation.
### `growth_stage`
- نوع: `string`
- اجباری: خیر
- توضیح: مرحله رشد گیاه، مثل `رویشی`، `گلدهی` یا `میوه دهی`.
## فیلدهای `data`
### `recommendation_uuid`
- نوع: `string (uuid)`
- توضیح: شناسه recommendation ذخیره‌شده برای history/detail.
### `crop_id`
- نوع: `string`
- توضیح: نام/شناسه گیاه ذخیره‌شده روی recommendation.
### `plant_name`
- نوع: `string`
- توضیح: معادل `crop_id` برای مصرف آسان‌تر در UI.
### `growth_stage`
- نوع: `string`
- توضیح: مرحله رشد ذخیره‌شده همراه recommendation.
### `irrigation_method_name`
- نوع: `string`
- توضیح: نام روش آبیاری خوانده‌شده از مزرعه.
### `status`
- نوع: `string`
- توضیح: وضعیت recommendation. مقادیر فعلی:
- `in_progress`
- `pending_confirmation`
- `completed`
- `error`
### `status_label`
- نوع: `string`
- توضیح: متن فارسی وضعیت برای نمایش مستقیم در UI.
### `plan`
- نوع: `object`
- توضیح: خلاصه اصلی recommendation برای کارت بالای UI.
### `water_balance`
- نوع: `object`
- توضیح: تراز آب و خروجی محاسبات روزانه.
### `timeline`
- نوع: `array`
- توضیح: مراحل اجرایی recommendation برای stepper.
### `sections`
- نوع: `array`
- توضیح: هشدارها و نکات تکمیلی.
## نمونه پاسخ حداقلی قابل استفاده
```json
{
"code": 200,
"msg": "success",
"data": {
"recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11",
"crop_id": "گوجه فرنگی",
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
"irrigation_method_name": "آبیاری قطره ای",
"status": "pending_confirmation",
"status_label": "منتظر تایید",
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 38,
"bestTimeOfDay": "05:30 تا 08:00 صبح",
"moistureLevel": 72,
"warning": "در ساعات گرم روز آبیاری انجام نشود"
},
"water_balance": {
"active_kc": 0.93,
"crop_profile": {
"kc_initial": 0.55,
"kc_mid": 1.05,
"kc_end": 0.78
},
"daily": [
{
"forecast_date": "2025-02-12",
"et0_mm": 5.4,
"etc_mm": 4.9,
"effective_rainfall_mm": 0,
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 07:00"
}
]
},
"timeline": [
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود"
}
],
"sections": [
{
"title": "هشدار تبخیر بالا",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": "در ساعات گرم روز آبیاری انجام نشود"
},
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند"
}
]
}
}
```
---
# 6) لیست recommendationهای آبیاری
## GET `/api/irrigation/recommendations/`
این endpoint history recommendationهای آبیاری یک مزرعه را برمی‌گرداند.
### Query Params
#### `farm_uuid`
- نوع: `string (uuid)`
- اجباری: بله
#### `page`
- نوع: `number`
- اجباری: خیر
- پیش‌فرض: `1`
#### `page_size`
- نوع: `number`
- اجباری: خیر
- پیش‌فرض backend: `10`
- حداکثر: `100`
### نمونه درخواست
```bash
curl -s "http://localhost:8000/api/irrigation/recommendations/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10" \
-H "accept: application/json" \
-H "Authorization: Bearer <token>"
```
### پاسخ موفق نمونه
```json
{
"code": 200,
"msg": "success",
"data": [
{
"recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11",
"crop_id": "گوجه فرنگی",
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
"irrigation_method_name": "آبیاری قطره ای",
"status": "pending_confirmation",
"status_label": "منتظر تایید",
"requested_at": "2025-02-12T09:30:00Z"
}
],
"pagination": {
"page": 1,
"page_size": 10,
"total_pages": 1,
"total_items": 1,
"has_next": false,
"has_previous": false,
"next": null,
"previous": null
}
}
```
### فیلدهای هر آیتم
#### `recommendation_uuid`
- نوع: `string (uuid)`
- توضیح: شناسه recommendation برای باز کردن جزئیات.
#### `crop_id`
- نوع: `string`
- توضیح: نام/شناسه گیاه.
#### `plant_name`
- نوع: `string`
- توضیح: معادل `crop_id`.
#### `growth_stage`
- نوع: `string`
- توضیح: مرحله رشد ثبت‌شده.
#### `irrigation_method_name`
- نوع: `string`
- توضیح: نام روش آبیاری.
#### `status`
- نوع: `string`
- توضیح: وضعیت recommendation.
#### `status_label`
- نوع: `string`
- توضیح: متن فارسی وضعیت.
#### `requested_at`
- نوع: `string(datetime)`
- توضیح: زمان ساخت recommendation.
---
# 7) جزئیات یک recommendation آبیاری
## GET `/api/irrigation/recommendations/{recommendation_uuid}/`
این endpoint جزئیات یک recommendation ذخیره‌شده را با همان shape endpoint اصلی recommendation برمی‌گرداند.
### Path Params
#### `recommendation_uuid`
- نوع: `string (uuid)`
- اجباری: بله
- توضیح: شناسه recommendation.
### پاسخ موفق نمونه
```json
{
"code": 200,
"msg": "success",
"data": {
"recommendation_uuid": "8a4c22d8-3f75-4aef-8e04-b40f6b4a2d11",
"crop_id": "گوجه فرنگی",
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
"irrigation_method_name": "آبیاری قطره ای",
"status": "completed",
"status_label": "پایان یافته",
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 30
},
"water_balance": {
"active_kc": 0.93,
"daily": []
},
"timeline": [
{
"step_number": 1,
"title": "مرحله اول",
"description": "اجرا شود"
}
],
"sections": [
{
"type": "tip",
"title": "نکته",
"content": "صبح زود آبیاری شود"
}
]
}
}
```
### خطای عدم وجود recommendation
```json
{
"code": 404,
"msg": "Recommendation not found."
}
```
---
# 9) پیشنهاد جریان استفاده در فرانت
برای صفحه recommendation آبیاری، ترتیب پیشنهادی این است:
1. با `GET /api/irrigation/` لیست روش‌های آبیاری را بگیرید.
2. کاربر یکی از روش‌ها را انتخاب کند.
3. روش انتخاب‌شده را روی مزرعه ذخیره کنید (`irrigation_method_id` و `irrigation_method_name`).
4. با `GET /api/plants/selected/?farm_uuid=...` محصولات انتخاب‌شده مزرعه را بگیرید.
5. کاربر محصول و مرحله رشد را انتخاب کند.
6. `POST /api/irrigation/recommend/` را فقط با `farm_uuid` و `plant_name` و `growth_stage` صدا بزنید.
7. برای history از `GET /api/irrigation/recommendations/` و برای جزئیات از `GET /api/irrigation/recommendations/{recommendation_uuid}/` استفاده کنید.
---
# 10) جمع‌بندی سریع endpointها
| Method | Path | کاربرد |
|---|---|---|
| GET | `/api/plants/selected/` | گرفتن محصولات انتخاب‌شده مزرعه |
| GET | `/api/irrigation/` | گرفتن لیست روش‌های آبیاری |
| POST | `/api/irrigation/` | ایجاد روش آبیاری جدید در upstream |
| GET | `/api/irrigation/config/` | گرفتن config اولیه صفحه recommendation |
| POST | `/api/irrigation/recommend/` | تولید recommendation آبیاری |
| GET | `/api/irrigation/recommendations/` | گرفتن history recommendationهای آبیاری |
| GET | `/api/irrigation/recommendations/{recommendation_uuid}/` | گرفتن جزئیات یک recommendation |
| POST | `/api/irrigation/water-stress/` | گرفتن شاخص تنش آبی |
@@ -0,0 +1,232 @@
# Irrigation Plan APIs
این فایل APIهای مدیریت برنامه‌های آبیاری را توضیح می‌دهد.
Base path:
`/api/irrigation/`
این APIها فقط روی برنامه‌های متعلق به کاربر لاگین‌شده عمل می‌کنند.
---
## 1) دریافت لیست برنامه‌های آبیاری
### Request
- Method: `GET`
- URL: `/api/irrigation/plans/`
- Query params:
- `farm_uuid` الزامی
- `page` اختیاری
- `page_size` اختیاری، حداکثر `100`
### Example
```http
GET /api/irrigation/plans/?farm_uuid=11111111-1111-1111-1111-111111111111&page=1&page_size=10
```
### Success Response
```json
{
"code": 200,
"msg": "success",
"data": [
{
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"source": "free_text",
"source_label": "متن آزاد کاربر",
"title": "برنامه آبیاری گندم",
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": false,
"created_at": "2025-02-24T10:20:30Z"
}
],
"pagination": {
"page": 1,
"page_size": 10,
"total_pages": 1,
"total_items": 1,
"has_next": false,
"has_previous": false,
"next": null,
"previous": null
}
}
```
### Notes
- فقط planهایی برگردانده می‌شوند که `is_deleted=False` باشند.
- ترتیب لیست از جدید به قدیم است.
- در هر مزرعه، در هر نوع plan فقط یک plan می‌تواند `is_active=true` باشد.
---
## 2) دریافت جزئیات یک برنامه آبیاری
### Request
- Method: `GET`
- URL: `/api/irrigation/plans/{plan_uuid}/`
- Path param:
- `plan_uuid` الزامی
### Example
```http
GET /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
```
### Success Response
```json
{
"code": 200,
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"source": "free_text",
"source_label": "متن آزاد کاربر",
"title": "برنامه آبیاری گندم",
"crop_id": "گندم",
"plant_name": "گندم",
"growth_stage": "flowering",
"is_active": false,
"created_at": "2025-02-24T10:20:30Z",
"updated_at": "2025-02-24T10:20:30Z",
"plan_payload": {
"plan": {
"durationMinutes": 25
}
}
}
}
```
### Not Found
```json
{
"code": 404,
"msg": "Plan not found."
}
```
### Notes
- فقط اگر plan متعلق به کاربر باشد و حذف نشده باشد برگردانده می‌شود.
---
## 3) حذف برنامه آبیاری
### Request
- Method: `DELETE`
- URL: `/api/irrigation/plans/{plan_uuid}/`
### Example
```http
DELETE /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/
```
### Success Response
```json
{
"code": 200,
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"is_deleted": true
}
}
```
### Behavior
- حذف به‌صورت `soft delete` انجام می‌شود.
- در عمل:
- `is_deleted = true`
- `is_active = false`
- `deleted_at` مقداردهی می‌شود
### Not Found
```json
{
"code": 404,
"msg": "Plan not found."
}
```
---
## 4) تغییر وضعیت فعال بودن برنامه آبیاری
### Request
- Method: `PATCH`
- URL: `/api/irrigation/plans/{plan_uuid}/status/`
- Body:
- `is_active` الزامی، `boolean`
### Example
```http
PATCH /api/irrigation/plans/6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1/status/
Content-Type: application/json
{
"is_active": false
}
```
### Success Response
```json
{
"code": 200,
"msg": "success",
"data": {
"plan_uuid": "6d6a1f0d-1a9b-4f2f-8fe1-2d73d9d2d9f1",
"is_active": true
}
}
```
### Validation Error
```json
{
"is_active": [
"This field is required."
]
}
```
### Not Found
```json
{
"code": 404,
"msg": "Plan not found."
}
```
---
## Summary
- planهای جدید به‌صورت پیش‌فرض `inactive` ساخته می‌شوند.
- در هر مزرعه فقط یک plan از این نوع می‌تواند `active` باشد.
- `GET /api/irrigation/plans/` لیست برنامه‌ها
- `GET /api/irrigation/plans/{plan_uuid}/` جزئیات برنامه
- `DELETE /api/irrigation/plans/{plan_uuid}/` حذف نرم برنامه
- `PATCH /api/irrigation/plans/{plan_uuid}/status/` فعال/غیرفعال کردن برنامه
+1
View File
@@ -0,0 +1 @@
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class IrrigationConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "irrigation"
label = "irrigation_recommendation"
verbose_name = "Irrigation Recommendation & Plan Parser"
+28
View File
@@ -0,0 +1,28 @@
CONFIG_RESPONSE_TEMPLATE = {
"farmInfo": {
"soilType": None,
"waterQuality": None,
"climateZone": None,
},
"cropOptions": [
{"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"},
{"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"},
{"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"},
{"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"},
{"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"},
{"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"},
],
"status": "success",
"source": "default_template",
}
IRRIGATION_DASHBOARD_TEMPLATE = {
"title": "آبیاری",
"subtitle": "داده توصیه آبیاری هنوز ثبت نشده است.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
"status": "empty",
"source": "db",
"warnings": ["No persisted irrigation recommendation is available for this farm."],
}
@@ -0,0 +1,40 @@
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("farm_hub", "0002_seed_default_catalog"),
]
operations = [
migrations.CreateModel(
name="IrrigationRecommendationRequest",
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)),
("crop_id", models.CharField(blank=True, default="", max_length=255)),
("task_id", models.CharField(blank=True, db_index=True, default="", max_length=255)),
("status", models.CharField(blank=True, default="", max_length=64)),
("request_payload", models.JSONField(blank=True, default=dict)),
("response_payload", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"farm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="irrigations",
to="farm_hub.farmhub",
),
),
],
options={
"db_table": "irrigation_requests",
"ordering": ["-created_at", "-id"],
},
),
]
@@ -0,0 +1,30 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("irrigation_recommendation", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="irrigationrecommendationrequest",
name="growth_stage",
field=models.CharField(blank=True, default="", max_length=255),
),
migrations.AlterField(
model_name="irrigationrecommendationrequest",
name="status",
field=models.CharField(
choices=[
("in_progress", "در حال اجرا"),
("pending_confirmation", "منتظر تایید"),
("completed", "پایان یافته"),
("error", "خطا"),
],
db_index=True,
default="pending_confirmation",
max_length=64,
),
),
]
@@ -0,0 +1,44 @@
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("irrigation_recommendation", "0002_recommendation_status_and_growth_stage"),
]
operations = [
migrations.CreateModel(
name="IrrigationPlan",
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)),
("source", models.CharField(choices=[("recommendation", "توصیه هوش مصنوعی"), ("free_text", "متن آزاد کاربر")], db_index=True, max_length=32)),
("title", models.CharField(blank=True, default="", max_length=255)),
("crop_id", models.CharField(blank=True, default="", max_length=255)),
("growth_stage", models.CharField(blank=True, default="", max_length=255)),
("plan_payload", models.JSONField(blank=True, default=dict)),
("request_payload", models.JSONField(blank=True, default=dict)),
("response_payload", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(db_index=True, default=True)),
("is_deleted", models.BooleanField(db_index=True, default=False)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"farm",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="irrigation_plans", to="farm_hub.farmhub"),
),
(
"recommendation",
models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="plans", to="irrigation_recommendation.irrigationrecommendationrequest"),
),
],
options={
"db_table": "irrigation_plans",
"ordering": ["-created_at", "-id"],
},
),
]
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("irrigation_recommendation", "0003_irrigationplan"),
]
operations = [
migrations.AlterField(
model_name="irrigationplan",
name="is_active",
field=models.BooleanField(db_index=True, default=False),
),
]
+44
View File
@@ -0,0 +1,44 @@
"""
Static mock data for Irrigation Recommendation API.
No database, no dynamic values.
"""
CONFIG_RESPONSE_DATA = {
"farmInfo": {
"soilType": "Loamy",
"waterQuality": "Medium EC",
"climateZone": "Temperate",
},
"cropOptions": [
{"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"},
{"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"},
{"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"},
{"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"},
{"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"},
{"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"},
],
}
RECOMMEND_RESPONSE_DATA = {
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 45,
"bestTimeOfDay": "05:00 - 07:00",
"moistureLevel": 72,
"warning": "Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.",
},
}
WATER_NEED_PREDICTION = {
"totalNext7Days": 3290,
"unit": "m3",
"categories": ["روز 1", "روز 2", "روز 3", "روز 4", "روز 5", "روز 6", "روز 7"],
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
}
IRRIGATION_DASHBOARD_RECOMMENDATION = {
"title": "آبیاری: 05:00 - 07:00",
"subtitle": "4 نوبت در هفته، 45 دقیقه برای هر نوبت. رطوبت هدف 72%.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
}
+94
View File
@@ -0,0 +1,94 @@
import uuid
from django.db import models
from django.utils import timezone
from farm_hub.models import FarmHub
class IrrigationRecommendationRequest(models.Model):
STATUS_IN_PROGRESS = "in_progress"
STATUS_PENDING_CONFIRMATION = "pending_confirmation"
STATUS_COMPLETED = "completed"
STATUS_ERROR = "error"
STATUS_CHOICES = (
(STATUS_IN_PROGRESS, "در حال اجرا"),
(STATUS_PENDING_CONFIRMATION, "منتظر تایید"),
(STATUS_COMPLETED, "پایان یافته"),
(STATUS_ERROR, "خطا"),
)
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
farm = models.ForeignKey(
FarmHub,
on_delete=models.CASCADE,
related_name="irrigations",
)
crop_id = models.CharField(max_length=255, blank=True, default="")
growth_stage = models.CharField(max_length=255, blank=True, default="")
task_id = models.CharField(max_length=255, blank=True, default="", db_index=True)
status = models.CharField(
max_length=64,
choices=STATUS_CHOICES,
default=STATUS_PENDING_CONFIRMATION,
db_index=True,
)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "irrigation_requests"
ordering = ["-created_at", "-id"]
def __str__(self):
return self.task_id or str(self.uuid)
class IrrigationPlan(models.Model):
SOURCE_RECOMMENDATION = "recommendation"
SOURCE_FREE_TEXT = "free_text"
SOURCE_CHOICES = (
(SOURCE_RECOMMENDATION, "توصیه هوش مصنوعی"),
(SOURCE_FREE_TEXT, "متن آزاد کاربر"),
)
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
farm = models.ForeignKey(
FarmHub,
on_delete=models.CASCADE,
related_name="irrigation_plans",
)
source = models.CharField(max_length=32, choices=SOURCE_CHOICES, db_index=True)
recommendation = models.ForeignKey(
IrrigationRecommendationRequest,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="plans",
)
title = models.CharField(max_length=255, blank=True, default="")
crop_id = models.CharField(max_length=255, blank=True, default="")
growth_stage = models.CharField(max_length=255, blank=True, default="")
plan_payload = models.JSONField(default=dict, blank=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=False, db_index=True)
is_deleted = models.BooleanField(default=False, db_index=True)
deleted_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "irrigation_plans"
ordering = ["-created_at", "-id"]
def __str__(self):
return self.title or self.crop_id or str(self.uuid)
def soft_delete(self):
self.is_deleted = True
self.is_active = False
self.deleted_at = timezone.now()
self.save(update_fields=["is_deleted", "is_active", "deleted_at", "updated_at"])
@@ -0,0 +1 @@
{"info":{"name":"Irrigation Recommendation","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Irrigation Recommendation API. GET config (farm info + crop options). POST recommend (optional body). Returns static plan. No database."},"item":[{"name":"Get config (GET)","request":{"method":"GET","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{baseUrl}}/api/irrigation-recommendation/config/","description":"Returns static farmInfo and cropOptions."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"farmInfo\": {\n \"soilType\": \"Loamy\",\n \"waterQuality\": \"Medium EC\",\n \"climateZone\": \"Temperate\"\n },\n \"cropOptions\": [\n {\"id\": \"wheat\", \"labelKey\": \"wheat\", \"icon\": \"tabler-wheat\"},\n {\"id\": \"corn\", \"labelKey\": \"corn\", \"icon\": \"tabler-plant-2\"},\n {\"id\": \"cotton\", \"labelKey\": \"cotton\", \"icon\": \"tabler-flower\"},\n {\"id\": \"saffron\", \"labelKey\": \"saffron\", \"icon\": \"tabler-flower-2\"},\n {\"id\": \"canola\", \"labelKey\": \"canola\", \"icon\": \"tabler-leaf\"},\n {\"id\": \"vegetables\", \"labelKey\": \"vegetables\", \"icon\": \"tabler-carrot\"}\n ]\n }\n}"}]},{"name":"Get recommendation (POST)","request":{"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"body":{"mode":"raw","raw":"{\n \"crop_id\": \"wheat\",\n \"soilType\": \"Loamy\",\n \"waterQuality\": \"Medium EC\",\n \"climateZone\": \"Temperate\"\n}"},"url":"{{baseUrl}}/api/irrigation-recommendation/recommend/","description":"Optional body: crop_id, farm info. Returns static plan. Input not processed."},"response":[{"name":"Success","status":"OK","code":200,"body":"{\n \"status\": \"success\",\n \"data\": {\n \"plan\": {\n \"frequencyPerWeek\": 4,\n \"durationMinutes\": 45,\n \"bestTimeOfDay\": \"05:00 - 07:00\",\n \"moistureLevel\": 72,\n \"warning\": \"Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.\"\n }\n }\n}"}]}],"variable":[{"key":"baseUrl","value":"http://localhost:8000"}]}
+155
View File
@@ -0,0 +1,155 @@
from rest_framework import serializers
class IrrigationFarmDataSerializer(serializers.Serializer):
soilType = serializers.CharField(required=False, allow_blank=True)
waterQuality = serializers.CharField(required=False, allow_blank=True)
climateZone = serializers.CharField(required=False, allow_blank=True)
class IrrigationRecommendRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=False, help_text="UUID مزرعه برای دریافت توصیه آبیاری.")
sensor_uuid = serializers.UUIDField(required=False, help_text="نام قدیمی farm_uuid برای سازگاری با کلاینت های قدیمی.")
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.")
growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.")
irrigation_type = serializers.CharField(required=False, allow_blank=True, help_text="نام روش آبیاری مورد استفاده در UI.")
irrigation_method_name = serializers.CharField(required=False, allow_blank=True, help_text="نام روش آبیاری انتخابی.")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
attrs["farm_uuid"] = farm_uuid
irrigation_method_name = attrs.get("irrigation_method_name") or attrs.get("irrigation_type")
if irrigation_method_name:
attrs["irrigation_method_name"] = irrigation_method_name
attrs.setdefault("irrigation_type", irrigation_method_name)
return attrs
class WaterStressRequestSerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای محاسبه تنش آبی.")
sensor_uuid = serializers.UUIDField(required=False, help_text="UUID سنسور برای فیلتر اختیاری.")
class IrrigationMethodSerializer(serializers.Serializer):
id = serializers.IntegerField(required=False)
name = serializers.CharField(required=False, allow_blank=True)
category = serializers.CharField(required=False, allow_blank=True)
description = serializers.CharField(required=False, allow_blank=True)
water_efficiency_percent = serializers.FloatField(required=False)
water_pressure_required = serializers.CharField(required=False, allow_blank=True)
flow_rate = serializers.CharField(required=False, allow_blank=True)
coverage_area = serializers.CharField(required=False, allow_blank=True)
soil_type = serializers.CharField(required=False, allow_blank=True)
climate_suitability = serializers.CharField(required=False, allow_blank=True)
created_at = serializers.DateTimeField(required=False)
updated_at = serializers.DateTimeField(required=False)
class IrrigationRecommendationListQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست توصیه های آبیاری.")
page = serializers.IntegerField(required=False, min_value=1)
page_size = serializers.IntegerField(required=False, min_value=1, max_value=100)
class IrrigationRecommendationListItemSerializer(serializers.Serializer):
recommendation_uuid = serializers.UUIDField(source="uuid", read_only=True)
crop_id = serializers.CharField(read_only=True)
plant_name = serializers.CharField(source="crop_id", read_only=True)
growth_stage = serializers.CharField(read_only=True)
irrigation_method_name = serializers.CharField(read_only=True, allow_blank=True)
status = serializers.CharField(read_only=True)
status_label = serializers.CharField(source="get_status_display", read_only=True)
requested_at = serializers.DateTimeField(source="created_at", read_only=True)
class FreeTextPlanParserRequestSerializer(serializers.Serializer):
message = serializers.CharField(required=False, allow_blank=True, help_text="متن آزاد کاربر.")
answers = serializers.DictField(required=False, help_text="پاسخ های تکمیلی کاربر.")
partial_plan = serializers.DictField(required=False, help_text="داده استخراج شده از مرحله قبل.")
farm_uuid = serializers.UUIDField(
required=False,
allow_null=True,
initial="11111111-1111-1111-1111-111111111111",
help_text="UUID مزرعه برای context اختیاری.",
)
def validate(self, attrs):
has_message = bool((attrs.get("message") or "").strip())
has_answers = isinstance(attrs.get("answers"), dict) and bool(attrs.get("answers"))
has_partial_plan = isinstance(attrs.get("partial_plan"), dict) and bool(attrs.get("partial_plan"))
if not (has_message or has_answers or has_partial_plan):
raise serializers.ValidationError(
{"non_field_errors": ["حداقل یکی از message، answers یا partial_plan باید ارسال شود."]}
)
return attrs
class PlanParserQuestionSerializer(serializers.Serializer):
id = serializers.CharField(required=False, allow_blank=True)
field = serializers.CharField(required=False, allow_blank=True)
question = serializers.CharField(required=False, allow_blank=True)
rationale = serializers.CharField(required=False, allow_blank=True)
class FreeTextPlanParserResponseDataSerializer(serializers.Serializer):
status = serializers.CharField(required=False, allow_blank=True)
status_fa = serializers.CharField(required=False, allow_blank=True)
summary = serializers.CharField(required=False, allow_blank=True)
missing_fields = serializers.ListField(child=serializers.CharField(), required=False)
questions = PlanParserQuestionSerializer(many=True, required=False)
collected_data = serializers.DictField(required=False)
final_plan = serializers.DictField(required=False, allow_null=True)
class IrrigationRecommendResponseDataSerializer(serializers.Serializer):
recommendation_uuid = serializers.UUIDField(read_only=True, required=False)
crop_id = serializers.CharField(read_only=True, required=False, allow_blank=True)
plant_name = serializers.CharField(read_only=True, required=False, allow_blank=True)
growth_stage = serializers.CharField(read_only=True, required=False, allow_blank=True)
irrigation_method_name = serializers.CharField(read_only=True, required=False, allow_blank=True)
status = serializers.CharField(read_only=True, required=False)
status_label = serializers.CharField(read_only=True, required=False)
plan = serializers.DictField(read_only=True)
water_balance = serializers.DictField(read_only=True)
timeline = serializers.ListField(child=serializers.DictField(), read_only=True)
sections = serializers.ListField(child=serializers.DictField(), read_only=True)
class IrrigationPlanListQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت لیست برنامه های آبیاری.")
page = serializers.IntegerField(required=False, min_value=1)
page_size = serializers.IntegerField(required=False, min_value=1, max_value=100)
class IrrigationPlanListItemSerializer(serializers.Serializer):
plan_uuid = serializers.UUIDField(source="uuid", read_only=True)
source = serializers.CharField(read_only=True)
source_label = serializers.CharField(source="get_source_display", read_only=True)
title = serializers.CharField(read_only=True)
crop_id = serializers.CharField(read_only=True)
plant_name = serializers.CharField(source="crop_id", read_only=True)
growth_stage = serializers.CharField(read_only=True)
is_active = serializers.BooleanField(read_only=True)
created_at = serializers.DateTimeField(read_only=True)
class IrrigationPlanDetailSerializer(serializers.Serializer):
plan_uuid = serializers.UUIDField(source="uuid", read_only=True)
source = serializers.CharField(read_only=True)
source_label = serializers.CharField(source="get_source_display", read_only=True)
title = serializers.CharField(read_only=True)
crop_id = serializers.CharField(read_only=True)
plant_name = serializers.CharField(source="crop_id", read_only=True)
growth_stage = serializers.CharField(read_only=True)
is_active = serializers.BooleanField(read_only=True)
created_at = serializers.DateTimeField(read_only=True)
updated_at = serializers.DateTimeField(read_only=True)
plan_payload = serializers.DictField(read_only=True)
class IrrigationPlanStatusUpdateSerializer(serializers.Serializer):
is_active = serializers.BooleanField(required=True)
+325
View File
@@ -0,0 +1,325 @@
from copy import deepcopy
import logging
from config.failure_contract import StructuredServiceError
from .defaults import IRRIGATION_DASHBOARD_TEMPLATE
from .models import IrrigationPlan, IrrigationRecommendationRequest
logger = logging.getLogger(__name__)
class IrrigationDataUnavailableError(StructuredServiceError):
def __init__(self, *, error_code: str, message: str, details: dict | None = None):
super().__init__(
error_code=error_code,
message=message,
source="db",
details=details,
)
def _extract_result(response_payload):
if not isinstance(response_payload, dict):
raise IrrigationDataUnavailableError(
error_code="invalid_payload",
message="Irrigation recommendation payload must be a JSON object.",
)
data = response_payload.get("data")
if isinstance(data, dict):
if isinstance(data.get("result"), dict):
return data["result"]
if any(key in data for key in ("plan", "water_balance", "timeline", "sections")):
return data
result = response_payload.get("result")
if isinstance(result, dict):
return result
if any(key in response_payload for key in ("plan", "water_balance", "timeline", "sections")):
return response_payload
return None
def _get_latest_result(farm):
if farm is None:
raise IrrigationDataUnavailableError(
error_code="missing_farm",
message="Farm instance is required for irrigation result lookup.",
)
for request in IrrigationRecommendationRequest.objects.filter(farm=farm).order_by("-created_at", "-id"):
try:
result = _extract_result(request.response_payload)
except IrrigationDataUnavailableError as exc:
logger.error(
"Invalid irrigation response payload for farm_id=%s request_id=%s: %s",
getattr(farm, "id", None),
request.id,
exc,
)
raise IrrigationDataUnavailableError(
error_code=exc.contract.error_code,
message=f"Invalid irrigation recommendation payload for request_id={request.id}.",
details={"farm_id": getattr(farm, "id", None), "request_id": request.id},
) from exc
if result:
return result
raise IrrigationDataUnavailableError(
error_code="no_data",
message=f"No irrigation recommendation result found for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
def get_active_plan_payload(farm):
if farm is None:
raise IrrigationDataUnavailableError(
error_code="missing_farm",
message="Farm instance is required for active irrigation plan lookup.",
)
plan = (
IrrigationPlan.objects.filter(farm=farm, is_active=True, is_deleted=False)
.order_by("-created_at", "-id")
.first()
)
if plan is None or not isinstance(plan.plan_payload, dict):
raise IrrigationDataUnavailableError(
error_code="no_active_plan",
message=f"No active irrigation plan payload found for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
return deepcopy(plan.plan_payload)
def build_active_plan_context(farm):
plan_payload = get_active_plan_payload(farm)
context = {"plan_payload": plan_payload}
plan = _normalize_plan(plan_payload.get("plan"))
if plan:
context["plan"] = plan
water_balance = _normalize_water_balance(plan_payload.get("water_balance"))
if water_balance:
context["water_balance"] = water_balance
timeline = _normalize_timeline(plan_payload.get("timeline"))
if timeline:
context["timeline"] = timeline
sections = _normalize_sections(plan_payload.get("sections"))
if sections:
context["sections"] = sections
return context
def _normalize_plan(plan):
if not isinstance(plan, dict):
return {}
normalized = {}
for key in ("frequencyPerWeek", "durationMinutes", "bestTimeOfDay", "moistureLevel", "warning"):
value = plan.get(key)
if value is not None:
normalized[key] = value
return normalized
def _normalize_crop_profile(crop_profile):
if not isinstance(crop_profile, dict):
return {}
normalized = {}
for key in ("kc_initial", "kc_mid", "kc_end"):
value = crop_profile.get(key)
if value is not None:
normalized[key] = value
return normalized
def _normalize_daily_entries(daily_entries):
if not isinstance(daily_entries, list):
return []
normalized_daily = []
allowed_keys = (
"forecast_date",
"et0_mm",
"etc_mm",
"effective_rainfall_mm",
"gross_irrigation_mm",
"irrigation_timing",
)
for entry in daily_entries:
if not isinstance(entry, dict):
continue
normalized_entry = {key: entry.get(key) for key in allowed_keys if entry.get(key) is not None}
if normalized_entry:
normalized_daily.append(normalized_entry)
return normalized_daily
def _normalize_water_balance(water_balance):
if not isinstance(water_balance, dict):
return {}
normalized = {}
if water_balance.get("active_kc") is not None:
normalized["active_kc"] = water_balance.get("active_kc")
crop_profile = _normalize_crop_profile(water_balance.get("crop_profile"))
if crop_profile:
normalized["crop_profile"] = crop_profile
normalized["daily"] = _normalize_daily_entries(water_balance.get("daily"))
return normalized
def _normalize_timeline(timeline):
if not isinstance(timeline, list):
return []
normalized_timeline = []
for item in timeline:
if not isinstance(item, dict):
continue
normalized_item = {}
for key in ("step_number", "title", "description"):
value = item.get(key)
if value is not None:
normalized_item[key] = value
if normalized_item:
normalized_timeline.append(normalized_item)
return normalized_timeline
def _normalize_sections(raw_sections):
if not isinstance(raw_sections, list):
return []
allowed_keys = {
"type",
"title",
"icon",
"content",
"items",
"frequency",
"amount",
"timing",
"validityPeriod",
"expandableExplanation",
}
normalized_sections = []
for section in raw_sections:
if not isinstance(section, dict) or not section.get("type"):
continue
normalized_section = {}
for key in allowed_keys:
value = section.get(key)
if value is None:
continue
if key == "items":
if not isinstance(value, list):
continue
normalized_section[key] = [str(item) for item in value]
continue
normalized_section[key] = str(value) if key != "type" else value
normalized_sections.append(normalized_section)
return normalized_sections
def build_recommendation_response(adapter_payload):
result = _extract_result(adapter_payload)
if not isinstance(result, dict):
raise IrrigationDataUnavailableError(
error_code="no_result",
message="Irrigation recommendation payload did not include a result object.",
)
if not isinstance(result.get("plan"), dict):
raise IrrigationDataUnavailableError(
error_code="invalid_payload",
message="Irrigation recommendation payload is missing a valid `plan` object.",
)
response = {
"plan": _normalize_plan(result.get("plan")),
"water_balance": _normalize_water_balance(result.get("water_balance")),
"timeline": _normalize_timeline(result.get("timeline")),
"sections": _normalize_sections(result.get("sections")),
}
return response
def get_water_need_prediction_data(farm=None):
result = _get_latest_result(farm)
water_balance = result.get("water_balance", {})
daily = water_balance.get("daily", [])
if not daily:
raise IrrigationDataUnavailableError(
error_code="empty_daily_data",
message=f"Water need prediction data is missing daily entries for farm_id={getattr(farm, 'id', None)}.",
details={"farm_id": getattr(farm, "id", None)},
)
categories = [item.get("forecast_date") or f"روز {index + 1}" for index, item in enumerate(daily)]
series_data = [float(item.get("gross_irrigation_mm") or 0) for item in daily]
return {
"totalNext7Days": round(sum(series_data), 2),
"unit": "mm",
"categories": categories,
"series": [{"name": "نیاز آبی", "data": series_data}],
}
def get_irrigation_dashboard_recommendation(farm=None):
default_item = deepcopy(IRRIGATION_DASHBOARD_TEMPLATE)
try:
result = _get_latest_result(farm)
except IrrigationDataUnavailableError as exc:
logger.info(
"Irrigation dashboard recommendation unavailable for farm_id=%s: %s",
getattr(farm, "id", None),
exc,
)
return default_item
plan = result.get("plan")
if not isinstance(plan, dict):
return default_item
best_time = plan.get("bestTimeOfDay") or "05:00 - 07:00"
frequency = plan.get("frequencyPerWeek")
duration = plan.get("durationMinutes")
moisture = plan.get("moistureLevel")
warning = plan.get("warning")
subtitle_parts = []
if frequency is not None and duration is not None:
subtitle_parts.append(f"{frequency} نوبت در هفته، {duration} دقیقه برای هر نوبت")
if moisture is not None:
subtitle_parts.append(f"رطوبت هدف {moisture}%")
if warning:
subtitle_parts.append(str(warning))
default_item["title"] = f"آبیاری: {best_time}"
if subtitle_parts:
default_item["subtitle"] = ". ".join(subtitle_parts)
default_item["status"] = "success"
default_item["source"] = "db"
default_item["warnings"] = []
return default_item
+734
View File
@@ -0,0 +1,734 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from external_api_adapter.adapter import AdapterResponse
from farm_hub.models import FarmHub, FarmType
from farmer_calendar.models import FarmerCalendarEvent
from .models import IrrigationPlan, IrrigationRecommendationRequest
from .services import IrrigationDataUnavailableError, build_recommendation_response
from .views import (
IrrigationMethodListView,
IrrigationPlanDetailView,
IrrigationPlanListView,
IrrigationPlanStatusView,
PlanFromTextView,
RecommendView,
RecommendationDetailView,
RecommendationListView,
WaterStressView,
)
class IrrigationServiceFailureTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="irrigation-service-user",
password="secret123",
email="irrigation-service@example.com",
phone_number="09120000009",
)
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Service Farm")
def test_get_water_need_prediction_raises_structured_error_for_missing_daily_entries(self):
IrrigationRecommendationRequest.objects.create(
farm=self.farm,
response_payload={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}},
)
from .services import get_water_need_prediction_data
with self.assertRaises(IrrigationDataUnavailableError) as exc_info:
get_water_need_prediction_data(self.farm)
self.assertEqual(exc_info.exception.contract.error_code, "empty_daily_data")
def test_build_recommendation_response_rejects_non_object_payload(self):
with self.assertRaises(IrrigationDataUnavailableError) as exc_info:
build_recommendation_response(["not-a-dict"])
self.assertEqual(exc_info.exception.contract.error_code, "invalid_payload")
class WaterStressViewTests(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, farm_type=self.farm_type, name="Farm 1")
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Farm 2")
@patch("irrigation.views.external_api_request")
def test_post_proxies_request_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"result": {
"waterStressIndex": 12,
"level": "پایین",
"sourceMetric": {"soilMoisture": 24},
}
}
},
)
request = self.factory.post(
"/api/irrigation/water-stress/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = WaterStressView.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"]["farm_uuid"], str(self.farm.farm_uuid))
self.assertEqual(response.data["data"]["waterStressIndex"], 12)
self.assertEqual(response.data["data"]["level"], "پایین")
self.assertEqual(response.data["data"]["sourceMetric"], {"soilMoisture": 24})
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation_water_stress")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/water-stress/",
method="POST",
payload={"farm_uuid": str(self.farm.farm_uuid)},
)
def test_post_rejects_foreign_farm_uuid(self):
request = self.factory.post(
"/api/irrigation/water-stress/",
{"farm_uuid": str(self.other_farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = WaterStressView.as_view()(request)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["code"], 404)
self.assertEqual(response.data["data"]["farm_uuid"][0], "Farm not found.")
@patch("irrigation.views.external_api_request")
def test_post_returns_upstream_failure_without_masking_as_empty(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=503,
data={"message": "AI unavailable", "status": "error"},
)
request = self.factory.post(
"/api/irrigation/water-stress/",
{"farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = WaterStressView.as_view()(request)
self.assertEqual(response.status_code, 503)
self.assertEqual(response.data["data"]["message"], "AI unavailable")
self.assertNotEqual(response.data.get("data"), [])
self.assertNotEqual(response.data.get("data"), {})
class IrrigationPlanFromTextViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="plan-parser-user",
password="secret123",
email="plan-parser@example.com",
phone_number="09120000005",
)
self.farm_type = FarmType.objects.create(name="گلخانه ای")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Plan Parser Farm")
@patch("irrigation.views.external_api_request")
def test_plan_from_text_proxies_to_ai_service(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"code": 200,
"msg": "موفق",
"data": {
"status": "completed",
"status_fa": "تکمیل شد",
"summary": "done",
"missing_fields": [],
"questions": [],
"collected_data": {"crop_name": "گوجه فرنگی"},
"final_plan": {"crop_name": "گوجه فرنگی"},
},
},
)
request = self.factory.post(
"/api/irrigation/plan-from-text/",
{"message": "متن برنامه", "farm_uuid": str(self.farm.farm_uuid)},
format="json",
)
force_authenticate(request, user=self.user)
response = PlanFromTextView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["status"], "completed")
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertEqual(response.data["meta"]["ownership"], "backend")
self.assertEqual(IrrigationPlan.objects.count(), 1)
plan = IrrigationPlan.objects.get()
self.assertEqual(plan.source, IrrigationPlan.SOURCE_FREE_TEXT)
self.assertEqual(plan.crop_id, "گوجه فرنگی")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/plan-from-text/",
method="POST",
payload={"message": "متن برنامه", "farm_uuid": str(self.farm.farm_uuid)},
)
def test_plan_from_text_requires_message_or_answers_or_partial_plan(self):
request = self.factory.post("/api/irrigation/plan-from-text/", {}, format="json")
force_authenticate(request, user=self.user)
response = PlanFromTextView.as_view()(request)
self.assertEqual(response.status_code, 400)
self.assertIn("non_field_errors", response.data)
class IrrigationMethodListViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
@patch("irrigation.views.external_api_request")
def test_get_proxies_irrigation_methods_from_ai(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": [
{
"id": 1,
"name": "Drip",
"category": "micro",
"description": "Efficient irrigation",
"water_efficiency_percent": 90.0,
}
]
},
)
request = self.factory.get("/api/irrigation/")
response = IrrigationMethodListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"][0]["name"], "Drip")
self.assertEqual(response.data["meta"]["flow_type"], "direct_proxy")
self.assertTrue(response.data["meta"]["live"])
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/",
method="GET",
)
@patch("irrigation.views.external_api_request")
def test_post_proxies_irrigation_method_creation_to_ai(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=201,
data={
"data": {
"id": 1,
"name": "Drip",
"category": "micro",
}
},
)
request = self.factory.post("/api/irrigation/", {"name": "Drip"}, format="json")
response = IrrigationMethodListView.as_view()(request)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["data"]["name"], "Drip")
self.assertEqual(response.data["meta"]["source_service"], "ai_irrigation")
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/",
method="POST",
payload={"name": "Drip"},
)
class RecommendViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="recommend-farmer",
password="secret123",
email="recommend@example.com",
phone_number="09120000002",
)
self.farm_type = FarmType.objects.create(name="باغی")
self.farm = FarmHub.objects.create(
owner=self.user,
farm_type=self.farm_type,
name="Recommend Farm",
irrigation_method_id=3,
irrigation_method_name="آبیاری قطره ای",
)
@patch("irrigation.views.external_api_request")
def test_post_returns_full_recommendation_shape(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={
"data": {
"result": {
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 38,
"bestTimeOfDay": "05:30 تا 08:00 صبح",
"moistureLevel": 72,
"warning": "در ساعات گرم روز آبیاری انجام نشود",
},
"water_balance": {
"active_kc": 0.93,
"crop_profile": {
"kc_initial": 0.55,
"kc_mid": 1.05,
"kc_end": 0.78,
},
"daily": [
{
"forecast_date": "2025-02-12",
"et0_mm": 5.4,
"etc_mm": 4.9,
"effective_rainfall_mm": 0,
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 07:00",
}
],
},
"timeline": [
{
"step_number": 1,
"title": "بررسی فشار",
"description": "فشار ابتدا و انتهای لاین کنترل شود",
}
],
"sections": [
{
"title": "هشدار تبخیر بالا",
"icon": "tabler-alert-triangle",
"type": "warning",
"content": "در ساعات گرم روز آبیاری انجام نشود",
},
{
"title": "نکته بهره وری",
"icon": "tabler-bulb",
"type": "tip",
"content": "شست وشوی فیلترها به یکنواختی آبیاری کمک می کند",
},
],
}
}
},
)
request = self.factory.post(
"/api/irrigation/recommend/",
{
"farm_uuid": str(self.farm.farm_uuid),
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
},
format="json",
)
force_authenticate(request, user=self.user)
response = RecommendView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertIn("recommendation_uuid", response.data["data"])
self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION)
self.assertEqual(response.data["data"]["status_label"], "منتظر تایید")
self.assertEqual(IrrigationPlan.objects.count(), 1)
plan = IrrigationPlan.objects.get()
self.assertEqual(plan.source, IrrigationPlan.SOURCE_RECOMMENDATION)
self.assertFalse(plan.is_active)
self.assertFalse(plan.is_deleted)
self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 38)
self.assertEqual(response.data["data"]["water_balance"]["active_kc"], 0.93)
self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1)
self.assertEqual(response.data["data"]["sections"][0]["type"], "warning")
@patch("irrigation.views.external_api_request")
def test_recommend_view_persists_real_response_and_never_returns_fake_success_on_invalid_payload(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"plan": {"bestTimeOfDay": "05:00"}}}},
)
request = self.factory.post(
"/api/irrigation/recommend/",
{
"farm_uuid": str(self.farm.farm_uuid),
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
},
format="json",
)
force_authenticate(request, user=self.user)
response = RecommendView.as_view()(request)
self.assertEqual(response.status_code, 502)
self.assertEqual(IrrigationRecommendationRequest.objects.count(), 1)
self.assertEqual(IrrigationRecommendationRequest.objects.get().status, IrrigationRecommendationRequest.STATUS_ERROR)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/recommend/",
method="POST",
payload={
"farm_uuid": str(self.farm.farm_uuid),
"plant_name": "گوجه فرنگی",
"growth_stage": "گلدهی",
"irrigation_method_id": 3,
"irrigation_type": "آبیاری قطره ای",
"irrigation_method_name": "آبیاری قطره ای",
},
)
@patch("irrigation.views.external_api_request")
def test_post_includes_active_irrigation_plan_in_ai_payload(self, mock_external_api_request):
IrrigationPlan.objects.create(
farm=self.farm,
source=IrrigationPlan.SOURCE_FREE_TEXT,
title="برنامه فعال",
crop_id="گوجه فرنگی",
growth_stage="گلدهی",
plan_payload={
"plan": {"frequencyPerWeek": 2, "durationMinutes": 25, "bestTimeOfDay": "صبح"},
"water_balance": {"active_kc": 0.82, "daily": []},
"timeline": [{"step_number": 1, "title": "مرحله", "description": "توضیح"}],
"sections": [{"type": "warning", "title": "هشدار", "content": "متن"}],
},
is_active=True,
)
mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {"result": {"plan": {}}}})
request = self.factory.post(
"/api/irrigation/recommend/",
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گوجه فرنگی", "growth_stage": "گلدهی"},
format="json",
)
force_authenticate(request, user=self.user)
response = RecommendView.as_view()(request)
self.assertEqual(response.status_code, 200)
sent_payload = mock_external_api_request.call_args.kwargs["payload"]
self.assertIn("active_irrigation_plan", sent_payload)
self.assertEqual(sent_payload["active_irrigation_plan"]["plan"]["durationMinutes"], 25)
self.assertEqual(sent_payload["active_irrigation_plan"]["water_balance"]["active_kc"], 0.82)
class IrrigationRecommendationHistoryTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="history-farmer",
password="secret123",
email="history@example.com",
phone_number="09120000003",
)
self.other_user = get_user_model().objects.create_user(
username="other-history-farmer",
password="secret123",
email="other-history@example.com",
phone_number="09120000004",
)
self.farm_type = FarmType.objects.create(name="گلخانه ای")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="History Farm")
self.other_farm = FarmHub.objects.create(owner=self.other_user, farm_type=self.farm_type, name="Other History Farm")
def test_recommendation_list_returns_paginated_items(self):
first = IrrigationRecommendationRequest.objects.create(
farm=self.farm,
crop_id="گندم",
growth_stage="vegetative",
status=IrrigationRecommendationRequest.STATUS_COMPLETED,
request_payload={"irrigation_method_name": "بارانی"},
response_payload={"data": {"plan": {"durationMinutes": 20}}},
)
second = IrrigationRecommendationRequest.objects.create(
farm=self.farm,
crop_id="ذرت",
growth_stage="flowering",
status=IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
request_payload={"irrigation_method_name": "قطره ای"},
response_payload={"data": {"plan": {"durationMinutes": 35}}},
)
request = self.factory.get(
f"/api/irrigation/recommendations/?farm_uuid={self.farm.farm_uuid}&page=1&page_size=1"
)
force_authenticate(request, user=self.user)
response = RecommendationListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(len(response.data["data"]), 1)
self.assertEqual(response.data["pagination"]["total_items"], 2)
self.assertEqual(response.data["data"][0]["recommendation_uuid"], str(second.uuid))
self.assertEqual(response.data["data"][0]["plant_name"], "ذرت")
self.assertEqual(response.data["data"][0]["growth_stage"], "flowering")
self.assertEqual(response.data["data"][0]["irrigation_method_name"], "قطره ای")
self.assertEqual(response.data["data"][0]["status"], IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION)
self.assertEqual(response.data["data"][0]["status_label"], "منتظر تایید")
self.assertNotEqual(response.data["data"][0]["recommendation_uuid"], str(first.uuid))
def test_recommendation_detail_returns_saved_shape(self):
recommendation = IrrigationRecommendationRequest.objects.create(
farm=self.farm,
crop_id="گوجه فرنگی",
growth_stage="fruiting",
status=IrrigationRecommendationRequest.STATUS_COMPLETED,
request_payload={"irrigation_method_name": "قطره ای"},
response_payload={
"data": {
"result": {
"plan": {"frequencyPerWeek": 4, "durationMinutes": 30},
"water_balance": {"active_kc": 0.93, "daily": []},
"timeline": [{"step_number": 1, "title": "مرحله اول", "description": "اجرا شود"}],
"sections": [{"type": "tip", "title": "نکته", "content": "صبح زود آبیاری شود"}],
}
}
},
)
request = self.factory.get(f"/api/irrigation/recommendations/{recommendation.uuid}/")
force_authenticate(request, user=self.user)
response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(response.data["data"]["recommendation_uuid"], str(recommendation.uuid))
self.assertEqual(response.data["data"]["crop_id"], "گوجه فرنگی")
self.assertEqual(response.data["data"]["plant_name"], "گوجه فرنگی")
self.assertEqual(response.data["data"]["growth_stage"], "fruiting")
self.assertEqual(response.data["data"]["irrigation_method_name"], "قطره ای")
self.assertEqual(response.data["data"]["status"], IrrigationRecommendationRequest.STATUS_COMPLETED)
self.assertEqual(response.data["data"]["status_label"], "پایان یافته")
self.assertEqual(response.data["data"]["plan"]["durationMinutes"], 30)
self.assertEqual(response.data["data"]["timeline"][0]["step_number"], 1)
self.assertEqual(response.data["data"]["sections"][0]["type"], "tip")
def test_recommendation_detail_rejects_foreign_recommendation(self):
recommendation = IrrigationRecommendationRequest.objects.create(
farm=self.other_farm,
crop_id="خیار",
status=IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
)
request = self.factory.get(f"/api/irrigation/recommendations/{recommendation.uuid}/")
force_authenticate(request, user=self.user)
response = RecommendationDetailView.as_view()(request, recommendation_uuid=recommendation.uuid)
self.assertEqual(response.status_code, 404)
self.assertEqual(response.data["msg"], "Recommendation not found.")
@patch("irrigation.views.external_api_request")
def test_post_accepts_sensor_uuid_as_farm_uuid_alias(self, mock_external_api_request):
mock_external_api_request.return_value = AdapterResponse(
status_code=200,
data={"data": {"result": {"sections": []}}},
)
request = self.factory.post(
"/api/irrigation/recommend/",
{
"sensor_uuid": str(self.farm.farm_uuid),
},
format="json",
)
force_authenticate(request, user=self.user)
response = RecommendView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["plan"]["frequencyPerWeek"], 4)
mock_external_api_request.assert_called_once_with(
"ai",
"/api/irrigation/recommend/",
method="POST",
payload={
"farm_uuid": str(self.farm.farm_uuid),
"irrigation_method_id": 3,
"irrigation_type": "آبیاری قطره ای",
"irrigation_method_name": "آبیاری قطره ای",
},
)
class IrrigationPlanApiTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = get_user_model().objects.create_user(
username="irrigation-plan-user",
password="secret123",
email="irrigation-plan@example.com",
phone_number="09124445566",
)
self.farm_type = FarmType.objects.create(name="زراعی")
self.farm = FarmHub.objects.create(owner=self.user, farm_type=self.farm_type, name="Irrigation Plan Farm")
self.plan = IrrigationPlan.objects.create(
farm=self.farm,
source=IrrigationPlan.SOURCE_FREE_TEXT,
title="برنامه آبیاری نمونه",
crop_id="گندم",
growth_stage="flowering",
plan_payload={"plan": {"durationMinutes": 25}},
)
def test_plan_list_returns_non_deleted_plans(self):
IrrigationPlan.objects.create(
farm=self.farm,
source=IrrigationPlan.SOURCE_RECOMMENDATION,
title="حذف شده",
is_deleted=True,
is_active=False,
)
request = self.factory.get(f"/api/irrigation/plans/?farm_uuid={self.farm.farm_uuid}")
force_authenticate(request, user=self.user)
response = IrrigationPlanListView.as_view()(request)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["code"], 200)
self.assertEqual(len(response.data["data"]), 1)
self.assertEqual(response.data["data"][0]["plan_uuid"], str(self.plan.uuid))
def test_plan_detail_returns_plan_payload(self):
request = self.factory.get(f"/api/irrigation/plans/{self.plan.uuid}/")
force_authenticate(request, user=self.user)
response = IrrigationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["data"]["plan_uuid"], str(self.plan.uuid))
self.assertEqual(response.data["data"]["plan_payload"]["plan"]["durationMinutes"], 25)
def test_plan_delete_is_soft_delete(self):
request = self.factory.delete(f"/api/irrigation/plans/{self.plan.uuid}/")
force_authenticate(request, user=self.user)
response = IrrigationPlanDetailView.as_view()(request, plan_uuid=self.plan.uuid)
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
self.assertTrue(self.plan.is_deleted)
self.assertFalse(self.plan.is_active)
def test_plan_status_patch_updates_is_active(self):
request = self.factory.patch(
f"/api/irrigation/plans/{self.plan.uuid}/status/",
{"is_active": True},
format="json",
)
force_authenticate(request, user=self.user)
response = IrrigationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid)
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
self.assertTrue(self.plan.is_active)
def test_activating_one_plan_deactivates_other_active_plan(self):
other_plan = IrrigationPlan.objects.create(
farm=self.farm,
source=IrrigationPlan.SOURCE_FREE_TEXT,
title="برنامه دوم",
is_active=True,
)
request = self.factory.patch(
f"/api/irrigation/plans/{self.plan.uuid}/status/",
{"is_active": True},
format="json",
)
force_authenticate(request, user=self.user)
response = IrrigationPlanStatusView.as_view()(request, plan_uuid=self.plan.uuid)
self.assertEqual(response.status_code, 200)
self.plan.refresh_from_db()
other_plan.refresh_from_db()
self.assertTrue(self.plan.is_active)
self.assertFalse(other_plan.is_active)
def test_plan_status_patch_syncs_calendar_events(self):
self.plan.plan_payload = {
"plan": {"durationMinutes": 25, "bestTimeOfDay": "05:30 - 06:00"},
"water_balance": {
"daily": [
{
"forecast_date": "2025-02-12",
"gross_irrigation_mm": 17,
"irrigation_timing": "05:30 - 06:00",
}
]
},
}
self.plan.is_active = False
self.plan.save(update_fields=["plan_payload", "is_active", "updated_at"])
activate_request = self.factory.patch(
f"/api/irrigation/plans/{self.plan.uuid}/status/",
{"is_active": True},
format="json",
)
force_authenticate(activate_request, user=self.user)
activate_response = IrrigationPlanStatusView.as_view()(activate_request, plan_uuid=self.plan.uuid)
self.assertEqual(activate_response.status_code, 200)
events = FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid))
self.assertEqual(events.count(), 1)
self.assertEqual(events.first().extended_props["plan_type"], "irrigation")
deactivate_request = self.factory.patch(
f"/api/irrigation/plans/{self.plan.uuid}/status/",
{"is_active": False},
format="json",
)
force_authenticate(deactivate_request, user=self.user)
deactivate_response = IrrigationPlanStatusView.as_view()(deactivate_request, plan_uuid=self.plan.uuid)
self.assertEqual(deactivate_response.status_code, 200)
self.assertFalse(
FarmerCalendarEvent.objects.filter(farm=self.farm, extended_props__plan_uuid=str(self.plan.uuid)).exists()
)
+27
View File
@@ -0,0 +1,27 @@
from django.urls import path
from .views import (
ConfigView,
IrrigationMethodListView,
IrrigationPlanDetailView,
IrrigationPlanListView,
IrrigationPlanStatusView,
PlanFromTextView,
RecommendationDetailView,
RecommendationListView,
RecommendView,
WaterStressView,
)
urlpatterns = [
path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"),
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
path("plans/", IrrigationPlanListView.as_view(), name="irrigation-plan-list"),
path("plans/<uuid:plan_uuid>/", IrrigationPlanDetailView.as_view(), name="irrigation-plan-detail"),
path("plans/<uuid:plan_uuid>/status/", IrrigationPlanStatusView.as_view(), name="irrigation-plan-status"),
path("recommendations/<uuid:recommendation_uuid>/", RecommendationDetailView.as_view(), name="irrigation-recommendation-detail"),
path("recommendations/", RecommendationListView.as_view(), name="irrigation-recommendation-list"),
path("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"),
path("plan-from-text/", PlanFromTextView.as_view(), name="irrigation-plan-from-text"),
path("water-stress/", WaterStressView.as_view(), name="irrigation-water-stress"),
]
+667
View File
@@ -0,0 +1,667 @@
"""
Irrigation Recommendation API views.
"""
import logging
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from rest_framework import serializers, status
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from rest_framework.views import APIView
from drf_spectacular.utils import extend_schema
from config.integration_contract import build_integration_meta
from config.swagger import code_response, status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from farmer_calendar import PLAN_TYPE_IRRIGATION, delete_plan_events, sync_plan_events
from water.serializers import WaterStressIndexSerializer
from water.views import WaterStressIndexView
from .defaults import CONFIG_RESPONSE_TEMPLATE
from .models import IrrigationPlan, IrrigationRecommendationRequest
from .serializers import (
FreeTextPlanParserRequestSerializer,
FreeTextPlanParserResponseDataSerializer,
IrrigationMethodSerializer,
IrrigationPlanDetailSerializer,
IrrigationPlanListItemSerializer,
IrrigationPlanListQuerySerializer,
IrrigationPlanStatusUpdateSerializer,
IrrigationRecommendationListItemSerializer,
IrrigationRecommendationListQuerySerializer,
IrrigationRecommendRequestSerializer,
IrrigationRecommendResponseDataSerializer,
WaterStressRequestSerializer,
)
from .services import build_recommendation_response
from .services import build_active_plan_context
from .services import IrrigationDataUnavailableError
logger = logging.getLogger(__name__)
class IrrigationRecommendationPagination(PageNumberPagination):
page_size = 10
page_size_query_param = "page_size"
max_page_size = 100
def get_paginated_response(self, data):
page_size = self.get_page_size(self.request) or self.page.paginator.per_page
return Response(
{
"code": 200,
"msg": "success",
"data": data,
"pagination": {
"page": self.page.number,
"page_size": page_size,
"total_pages": self.page.paginator.num_pages,
"total_items": self.page.paginator.count,
"has_next": self.page.has_next(),
"has_previous": self.page.has_previous(),
"next": self.get_next_link(),
"previous": self.get_previous_link(),
},
},
status=status.HTTP_200_OK,
)
class FarmAccessMixin:
@staticmethod
def _get_farm(request, farm_uuid):
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
try:
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
except FarmHub.DoesNotExist as exc:
raise serializers.ValidationError({"farm_uuid": ["Farm not found."]}) from exc
class ConfigView(FarmAccessMixin, APIView):
@extend_schema(
tags=["Irrigation Recommendation"],
responses={200: status_response("IrrigationConfigResponse", data=serializers.JSONField())},
)
def get(self, request):
farm = self._get_farm(request, request.query_params.get("farm_uuid"))
data = dict(CONFIG_RESPONSE_TEMPLATE)
data["farm_uuid"] = str(farm.farm_uuid)
return Response({"status": "success", "data": data}, status=status.HTTP_200_OK)
class IrrigationMethodListView(APIView):
@staticmethod
def _extract_methods(adapter_data):
if not isinstance(adapter_data, dict):
return adapter_data if isinstance(adapter_data, list) else []
data = adapter_data.get("data")
if isinstance(data, dict) and isinstance(data.get("result"), list):
return data["result"]
if isinstance(data, list):
return data
result = adapter_data.get("result")
if isinstance(result, list):
return result
return []
@extend_schema(
tags=["Irrigation Recommendation"],
responses={200: status_response("IrrigationMethodListResponse", data=IrrigationMethodSerializer(many=True))},
)
def get(self, request):
adapter_response = external_api_request(
"ai",
"/api/irrigation/",
method="GET",
)
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,
)
return Response(
{
"code": 200,
"msg": "success",
"data": self._extract_methods(adapter_response.data),
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
@extend_schema(
tags=["Irrigation Recommendation"],
request=serializers.JSONField,
responses={201: status_response("IrrigationMethodCreateResponse", data=IrrigationMethodSerializer())},
)
def post(self, request):
adapter_response = external_api_request(
"ai",
"/api/irrigation/",
method="POST",
payload=request.data,
)
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)}
if adapter_response.status_code >= 400:
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
payload = self._extract_methods(adapter_response.data)
if not payload:
payload = response_data.get("data", response_data)
return Response(
{
"code": adapter_response.status_code,
"msg": "success",
"data": payload,
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation",
ownership="ai",
live=True,
cached=False,
),
},
status=adapter_response.status_code,
)
class RecommendView(FarmAccessMixin, APIView):
@staticmethod
def _build_plan_title(crop_id, growth_stage, plan):
best_time = ""
if isinstance(plan, dict):
best_time = str(plan.get("bestTimeOfDay") or "").strip()
parts = [part for part in [crop_id, growth_stage, best_time] if part]
return " - ".join(parts) if parts else "برنامه آبیاری"
def _create_plan_from_recommendation(self, recommendation, recommendation_data):
plan = IrrigationPlan.objects.create(
farm=recommendation.farm,
source=IrrigationPlan.SOURCE_RECOMMENDATION,
recommendation=recommendation,
title=self._build_plan_title(recommendation.crop_id, recommendation.growth_stage, recommendation_data.get("plan")),
crop_id=recommendation.crop_id,
growth_stage=recommendation.growth_stage,
plan_payload=recommendation_data,
request_payload=recommendation.request_payload,
response_payload=recommendation.response_payload,
)
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
@staticmethod
def _enrich_ai_payload(payload, farm):
enriched_payload = payload.copy()
try:
active_plan_context = build_active_plan_context(farm)
except IrrigationDataUnavailableError:
active_plan_context = None
if active_plan_context:
enriched_payload["active_irrigation_plan"] = active_plan_context
return enriched_payload
@extend_schema(
tags=["Irrigation Recommendation"],
request=IrrigationRecommendRequestSerializer,
responses={200: status_response("IrrigationRecommendResponse", data=IrrigationRecommendResponseDataSerializer())},
)
def post(self, request):
serializer = IrrigationRecommendRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy()
farm = self._get_farm(request, payload.get("farm_uuid"))
payload["farm_uuid"] = str(farm.farm_uuid)
payload.pop("sensor_uuid", None)
payload.pop("irrigation_type", None)
payload.pop("irrigation_method_name", None)
if farm.irrigation_method_name:
payload["irrigation_method_name"] = farm.irrigation_method_name
payload["irrigation_type"] = farm.irrigation_method_name
if farm.irrigation_method_id is not None:
payload["irrigation_method_id"] = farm.irrigation_method_id
ai_payload = self._enrich_ai_payload(payload, farm)
adapter_response = external_api_request(
"ai",
"/api/irrigation/recommend/",
method="POST",
payload=ai_payload,
)
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
recommendation = IrrigationRecommendationRequest.objects.create(
farm=farm,
crop_id=payload.get("plant_name", ""),
growth_stage=payload.get("growth_stage", ""),
task_id="",
status=(
IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION
if adapter_response.status_code < 400
else IrrigationRecommendationRequest.STATUS_ERROR
),
request_payload=ai_payload,
response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data},
)
if adapter_response.status_code >= 400:
return Response(
{
"code": adapter_response.status_code,
"msg": "error",
"data": response_data if isinstance(response_data, dict) else {"message": str(adapter_response.data)},
},
status=adapter_response.status_code,
)
try:
recommendation_data = build_recommendation_response(response_data)
except IrrigationDataUnavailableError as exc:
recommendation.status = IrrigationRecommendationRequest.STATUS_ERROR
recommendation.save(update_fields=["status"])
return Response(
{"code": 502, "msg": "error", "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
logger.warning(
"Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s",
str(farm.farm_uuid),
adapter_response.status_code,
sorted(response_data.keys()) if isinstance(response_data, dict) else None,
len(recommendation_data["sections"]),
)
self._create_plan_from_recommendation(recommendation, recommendation_data)
recommendation_data["recommendation_uuid"] = str(recommendation.uuid)
recommendation_data["crop_id"] = recommendation.crop_id
recommendation_data["plant_name"] = recommendation.crop_id
recommendation_data["growth_stage"] = recommendation.growth_stage
recommendation_data["irrigation_method_name"] = payload.get("irrigation_method_name", "")
recommendation_data["status"] = recommendation.status
recommendation_data["status_label"] = recommendation.get_status_display()
return Response(
{
"code": 200,
"msg": "success",
"data": recommendation_data,
"meta": build_integration_meta(
flow_type="backend_owned_data_with_ai_enrichment",
source_type="provider",
source_service="ai_irrigation",
ownership="backend",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class RecommendationListView(FarmAccessMixin, APIView):
pagination_class = IrrigationRecommendationPagination
@extend_schema(
tags=["Irrigation Recommendation"],
parameters=[IrrigationRecommendationListQuerySerializer],
responses={200: code_response("IrrigationRecommendationListResponse")},
)
def get(self, request):
serializer = IrrigationRecommendationListQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
recommendations = farm.irrigations.all().order_by("-created_at", "-id")
paginator = self.pagination_class()
page = paginator.paginate_queryset(recommendations, request, view=self)
items = []
for recommendation in page:
request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {}
recommendation.irrigation_method_name = str(request_payload.get("irrigation_method_name") or "")
items.append(recommendation)
data = IrrigationRecommendationListItemSerializer(items, many=True).data
return paginator.get_paginated_response(data)
class RecommendationDetailView(FarmAccessMixin, APIView):
@extend_schema(
tags=["Irrigation Recommendation"],
parameters=[
OpenApiParameter(
name="recommendation_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.PATH,
required=True,
)
],
responses={
200: code_response("IrrigationRecommendationDetailResponse", data=IrrigationRecommendResponseDataSerializer()),
404: code_response("IrrigationRecommendationDetailNotFoundResponse"),
},
)
def get(self, request, recommendation_uuid):
recommendation = IrrigationRecommendationRequest.objects.filter(
uuid=recommendation_uuid,
farm__owner=request.user,
).select_related("farm").first()
if recommendation is None:
return Response({"code": 404, "msg": "Recommendation not found."}, status=status.HTTP_404_NOT_FOUND)
try:
data = build_recommendation_response(recommendation.response_payload)
except IrrigationDataUnavailableError as exc:
return Response(
{"code": 502, "msg": "error", "data": {"detail": str(exc)}},
status=status.HTTP_502_BAD_GATEWAY,
)
request_payload = recommendation.request_payload if isinstance(recommendation.request_payload, dict) else {}
data["recommendation_uuid"] = str(recommendation.uuid)
data["crop_id"] = recommendation.crop_id
data["plant_name"] = recommendation.crop_id
data["growth_stage"] = recommendation.growth_stage
data["irrigation_method_name"] = str(request_payload.get("irrigation_method_name") or "")
data["status"] = recommendation.status
data["status_label"] = recommendation.get_status_display()
return Response(
{
"code": 200,
"msg": "success",
"data": data,
"meta": build_integration_meta(
flow_type="backend_owned_data_with_ai_enrichment",
source_type="db",
source_service="backend_irrigation",
ownership="backend",
live=False,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class WaterStressView(APIView):
@staticmethod
def _get_farm(request, farm_uuid):
if not farm_uuid:
return None, Response(
{"code": 400, "msg": "error", "data": {"farm_uuid": ["This field is required."]}},
status=status.HTTP_400_BAD_REQUEST,
)
try:
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user), None
except FarmHub.DoesNotExist:
return None, Response(
{"code": 404, "msg": "error", "data": {"farm_uuid": ["Farm not found."]}},
status=status.HTTP_404_NOT_FOUND,
)
@extend_schema(
tags=["Irrigation Recommendation"],
request=WaterStressRequestSerializer,
responses={200: status_response("WaterStressResponse", data=WaterStressIndexSerializer())},
)
def post(self, request):
serializer = WaterStressRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy()
farm, error_response = self._get_farm(request, payload.get("farm_uuid"))
if error_response is not None:
return error_response
query = {"farm_uuid": str(farm.farm_uuid)}
sensor_uuid = payload.get("sensor_uuid")
if sensor_uuid:
query["sensor_uuid"] = str(sensor_uuid)
adapter_response = external_api_request(
"ai",
"/api/irrigation/water-stress/",
method="POST",
payload=query,
)
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,
)
stress_payload = WaterStressIndexView.extract_stress_payload(adapter_response.data, farm.farm_uuid)
return Response(
{
"code": 200,
"msg": "success",
"data": stress_payload,
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation_water_stress",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class PlanFromTextView(FarmAccessMixin, APIView):
@staticmethod
def _extract_final_plan(response_data):
if not isinstance(response_data, dict):
return None
data = response_data.get("data")
if isinstance(data, dict):
final_plan = data.get("final_plan")
if isinstance(final_plan, dict) and final_plan:
return final_plan
final_plan = response_data.get("final_plan")
if isinstance(final_plan, dict) and final_plan:
return final_plan
return None
@staticmethod
def _build_free_text_plan_title(final_plan):
if not isinstance(final_plan, dict):
return "برنامه آبیاری"
for key in ("title", "plan_title", "crop_name", "crop_id", "plant_name"):
value = str(final_plan.get(key, "")).strip()
if value:
return value
return "برنامه آبیاری"
@extend_schema(
tags=["Irrigation Recommendation"],
request=FreeTextPlanParserRequestSerializer,
responses={200: code_response("IrrigationPlanFromTextResponse", data=FreeTextPlanParserResponseDataSerializer())},
)
def post(self, request):
serializer = FreeTextPlanParserRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data.copy()
farm_uuid = payload.get("farm_uuid")
if farm_uuid:
farm = self._get_farm(request, farm_uuid)
payload["farm_uuid"] = str(farm.farm_uuid)
adapter_response = external_api_request(
"ai",
"/api/irrigation/plan-from-text/",
method="POST",
payload=payload,
)
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {"message": str(adapter_response.data)}
if adapter_response.status_code >= 400:
return Response(
{"code": adapter_response.status_code, "msg": "error", "data": response_data},
status=adapter_response.status_code,
)
final_plan = self._extract_final_plan(response_data)
if final_plan and farm_uuid:
plan = IrrigationPlan.objects.create(
farm=farm,
source=IrrigationPlan.SOURCE_FREE_TEXT,
title=self._build_free_text_plan_title(final_plan),
crop_id=str(
final_plan.get("crop_id")
or final_plan.get("crop_name")
or final_plan.get("plant_name")
or ""
).strip(),
growth_stage=str(final_plan.get("growth_stage") or "").strip(),
plan_payload=final_plan,
request_payload=payload,
response_payload=response_data,
)
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
return Response(
{
"code": 200,
"msg": response_data.get("msg", "موفق"),
"data": response_data.get("data", response_data),
"meta": build_integration_meta(
flow_type="direct_proxy",
source_type="provider",
source_service="ai_irrigation_plan_parser",
ownership="backend" if final_plan and farm_uuid else "ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class IrrigationPlanListView(FarmAccessMixin, APIView):
pagination_class = IrrigationRecommendationPagination
@extend_schema(
tags=["Irrigation Recommendation"],
parameters=[IrrigationPlanListQuerySerializer],
responses={200: code_response("IrrigationPlanListResponse")},
)
def get(self, request):
serializer = IrrigationPlanListQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
plans = farm.irrigation_plans.filter(is_deleted=False).order_by("-created_at", "-id")
paginator = self.pagination_class()
page = paginator.paginate_queryset(plans, request, view=self)
data = IrrigationPlanListItemSerializer(page, many=True).data
return paginator.get_paginated_response(data)
class IrrigationPlanDetailView(APIView):
@extend_schema(
tags=["Irrigation Recommendation"],
parameters=[
OpenApiParameter(
name="plan_uuid",
type=OpenApiTypes.UUID,
location=OpenApiParameter.PATH,
required=True,
)
],
responses={200: code_response("IrrigationPlanDetailResponse", data=IrrigationPlanDetailSerializer())},
)
def get(self, request, plan_uuid):
plan = IrrigationPlan.objects.filter(
uuid=plan_uuid,
farm__owner=request.user,
is_deleted=False,
).select_related("farm").first()
if plan is None:
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
data = IrrigationPlanDetailSerializer(plan).data
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
@extend_schema(
tags=["Irrigation Recommendation"],
responses={200: status_response("IrrigationPlanDeleteResponse", data=serializers.JSONField())},
)
def delete(self, request, plan_uuid):
plan = IrrigationPlan.objects.filter(
uuid=plan_uuid,
farm__owner=request.user,
is_deleted=False,
).first()
if plan is None:
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
plan.soft_delete()
delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_IRRIGATION, plan_uuid=plan.uuid)
return Response({"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_deleted": True}}, status=status.HTTP_200_OK)
class IrrigationPlanStatusView(APIView):
@extend_schema(
tags=["Irrigation Recommendation"],
request=IrrigationPlanStatusUpdateSerializer,
responses={200: code_response("IrrigationPlanStatusResponse", data=serializers.JSONField())},
)
def patch(self, request, plan_uuid):
serializer = IrrigationPlanStatusUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
plan = IrrigationPlan.objects.filter(
uuid=plan_uuid,
farm__owner=request.user,
is_deleted=False,
).first()
if plan is None:
return Response({"code": 404, "msg": "Plan not found."}, status=status.HTTP_404_NOT_FOUND)
new_is_active = serializer.validated_data["is_active"]
if new_is_active:
IrrigationPlan.objects.filter(
farm=plan.farm,
is_deleted=False,
is_active=True,
).exclude(pk=plan.pk).update(is_active=False)
plan.is_active = new_is_active
plan.save(update_fields=["is_active", "updated_at"])
if plan.is_active:
sync_plan_events(plan, PLAN_TYPE_IRRIGATION)
else:
delete_plan_events(farm=plan.farm, plan_type=PLAN_TYPE_IRRIGATION, plan_uuid=plan.uuid)
return Response(
{"code": 200, "msg": "success", "data": {"plan_uuid": str(plan.uuid), "is_active": plan.is_active}},
status=status.HTTP_200_OK,
)