UPDATE
This commit is contained in:
@@ -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/` فعال/غیرفعال کردن برنامه
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"}]}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user