This commit is contained in:
2026-05-02 06:16:36 +03:30
parent bd02c8342a
commit f34d5dd198
38 changed files with 71 additions and 69 deletions
+492
View File
@@ -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/` | گرفتن شاخص تنش آبی |
+1
View File
@@ -0,0 +1 @@
+8
View File
@@ -0,0 +1,8 @@
from django.apps import AppConfig
class IrrigationConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "irrigation"
label = "irrigation_recommendation"
verbose_name = "Irrigation Recommendation & Plan Parser"
+40
View File
@@ -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", "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,
),
),
]
View File
+44
View File
@@ -0,0 +1,44 @@
"""
Static mock data for Irrigation Recommendation API.
No database, no dynamic values.
"""
CONFIG_RESPONSE_DATA = {
"farmInfo": {
"soilType": "Loamy",
"waterQuality": "Medium EC",
"climateZone": "Temperate",
},
"cropOptions": [
{"id": "wheat", "labelKey": "wheat", "icon": "tabler-wheat"},
{"id": "corn", "labelKey": "corn", "icon": "tabler-plant-2"},
{"id": "cotton", "labelKey": "cotton", "icon": "tabler-flower"},
{"id": "saffron", "labelKey": "saffron", "icon": "tabler-flower-2"},
{"id": "canola", "labelKey": "canola", "icon": "tabler-leaf"},
{"id": "vegetables", "labelKey": "vegetables", "icon": "tabler-carrot"},
],
}
RECOMMEND_RESPONSE_DATA = {
"plan": {
"frequencyPerWeek": 4,
"durationMinutes": 45,
"bestTimeOfDay": "05:00 - 07:00",
"moistureLevel": 72,
"warning": "Avoid irrigation during midday hours in the coming week due to forecasted high temperatures.",
},
}
WATER_NEED_PREDICTION = {
"totalNext7Days": 3290,
"unit": "m3",
"categories": ["روز 1", "روز 2", "روز 3", "روز 4", "روز 5", "روز 6", "روز 7"],
"series": [{"name": "نیاز آبی", "data": [420, 450, 480, 460, 490, 510, 480]}],
}
IRRIGATION_DASHBOARD_RECOMMENDATION = {
"title": "آبیاری: 05:00 - 07:00",
"subtitle": "4 نوبت در هفته، 45 دقیقه برای هر نوبت. رطوبت هدف 72%.",
"avatarIcon": "tabler-droplet",
"avatarColor": "primary",
}
+45
View File
@@ -0,0 +1,45 @@
import uuid
from django.db import models
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)
@@ -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"}]}
+119
View File
@@ -0,0 +1,119 @@
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)
+215
View File
@@ -0,0 +1,215 @@
from copy import deepcopy
from .mock_data import IRRIGATION_DASHBOARD_RECOMMENDATION, RECOMMEND_RESPONSE_DATA, WATER_NEED_PREDICTION
from .models import IrrigationRecommendationRequest
def _extract_result(response_payload):
if not isinstance(response_payload, dict):
return {}
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 {}
def _get_latest_result(farm):
if farm is None:
return {}
for request in IrrigationRecommendationRequest.objects.filter(farm=farm).order_by("-created_at", "-id"):
result = _extract_result(request.response_payload)
if result:
return result
return {}
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)
fallback_plan = RECOMMEND_RESPONSE_DATA.get("plan", {})
return {
"plan": _normalize_plan(result.get("plan") or fallback_plan),
"water_balance": _normalize_water_balance(result.get("water_balance")),
"timeline": _normalize_timeline(result.get("timeline")),
"sections": _normalize_sections(result.get("sections")),
}
def get_water_need_prediction_data(farm=None):
default_data = deepcopy(WATER_NEED_PREDICTION)
result = _get_latest_result(farm)
water_balance = result.get("water_balance", {})
daily = water_balance.get("daily", [])
if not daily:
return default_data
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_RECOMMENDATION)
result = _get_latest_result(farm)
plan = result.get("plan") or RECOMMEND_RESPONSE_DATA.get("plan", {})
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)
return default_item
+461
View File
@@ -0,0 +1,461 @@
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 .models import IrrigationRecommendationRequest
from .views import (
IrrigationMethodListView,
PlanFromTextView,
RecommendView,
RecommendationDetailView,
RecommendationListView,
WaterStressView,
)
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})
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.")
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")
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")
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")
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(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")
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": "آبیاری قطره ای",
},
)
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": "آبیاری قطره ای",
},
)
+21
View File
@@ -0,0 +1,21 @@
from django.urls import path
from .views import (
ConfigView,
IrrigationMethodListView,
PlanFromTextView,
RecommendationDetailView,
RecommendationListView,
RecommendView,
WaterStressView,
)
urlpatterns = [
path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"),
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
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"),
]
+393
View File
@@ -0,0 +1,393 @@
"""
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.swagger import code_response, status_response
from external_api_adapter import request as external_api_request
from farm_hub.models import FarmHub
from water.serializers import WaterStressIndexSerializer
from water.views import WaterStressIndexView
from .mock_data import CONFIG_RESPONSE_DATA
from .models import IrrigationRecommendationRequest
from .serializers import (
FreeTextPlanParserRequestSerializer,
FreeTextPlanParserResponseDataSerializer,
IrrigationMethodSerializer,
IrrigationRecommendationListItemSerializer,
IrrigationRecommendationListQuerySerializer,
IrrigationRecommendRequestSerializer,
IrrigationRecommendResponseDataSerializer,
WaterStressRequestSerializer,
)
from .services import build_recommendation_response
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_DATA)
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)},
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},
status=adapter_response.status_code,
)
class RecommendView(FarmAccessMixin, APIView):
@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
adapter_response = external_api_request(
"ai",
"/api/irrigation/recommend/",
method="POST",
payload=payload,
)
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
recommendation_data = build_recommendation_response(response_data)
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"]),
)
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=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,
)
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,
},
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)
data = build_recommendation_response(recommendation.response_payload)
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}, 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},
status=status.HTTP_200_OK,
)
class PlanFromTextView(FarmAccessMixin, APIView):
@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,
)
return Response(
{"code": 200, "msg": response_data.get("msg", "موفق"), "data": response_data.get("data", response_data)},
status=status.HTTP_200_OK,
)