UPDATE
This commit is contained in:
@@ -0,0 +1,330 @@
|
|||||||
|
# Fertilization Recommendation History APIs
|
||||||
|
|
||||||
|
این فایل برای تیم فرانت نوشته شده تا بتواند از APIهای history توصیه های کودهی استفاده کند.
|
||||||
|
|
||||||
|
## وضعیت recommendation
|
||||||
|
|
||||||
|
هر recommendation یک status دارد.
|
||||||
|
|
||||||
|
### statusهای ممکن
|
||||||
|
|
||||||
|
- `pending_confirmation` → `منتظر تایید`
|
||||||
|
- `in_progress` → `در حال مصرف`
|
||||||
|
- `completed` → `پایان یافته`
|
||||||
|
|
||||||
|
### وضعیت فعلی سیستم
|
||||||
|
|
||||||
|
فعلاً همه recommendationهای جدید و recommendationهای قبلی که migrate شده اند با وضعیت زیر ذخیره می شوند:
|
||||||
|
|
||||||
|
- `pending_confirmation`
|
||||||
|
- برچسب نمایشی: `منتظر تایید`
|
||||||
|
|
||||||
|
فرانت باید `status` را برای منطق برنامه و `status_label` را برای نمایش مستقیم استفاده کند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) لیست توصیه های کودهی یک مزرعه
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
`GET /api/fertilization/recommendations/?farm_uuid=<farm_uuid>`
|
||||||
|
|
||||||
|
### کاربرد
|
||||||
|
|
||||||
|
- نمایش history توصیه های کودهی یک مزرعه
|
||||||
|
- ساخت جدول یا لیست برای مشاهده توصیه های قبلی
|
||||||
|
- نمایش badge وضعیت recommendation
|
||||||
|
- ورود به صفحه جزئیات هر recommendation
|
||||||
|
|
||||||
|
### Query Params
|
||||||
|
|
||||||
|
- `farm_uuid`: شناسه مزرعه
|
||||||
|
- `crop_id`: شناسه یا نام محصول. این فیلد همان plant name است و مستقیم برای AI هم ارسال می شود
|
||||||
|
- `page`: شماره صفحه، شروع از `1`
|
||||||
|
- `page_size`: تعداد آیتم در هر صفحه، بین `1` تا `100`
|
||||||
|
|
||||||
|
### هدرها
|
||||||
|
|
||||||
|
- `Authorization: Bearer <token>`
|
||||||
|
- `Accept: application/json`
|
||||||
|
|
||||||
|
### نمونه درخواست
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET \
|
||||||
|
'http://localhost:8000/api/fertilization/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": "4d595ee0-9dbb-4c50-a871-2b4359d0d748",
|
||||||
|
"crop_id": "گندم",
|
||||||
|
"plant_name": "گندم",
|
||||||
|
"growth_stage": "vegetative",
|
||||||
|
"fertilizer_type": "NPK",
|
||||||
|
"status": "pending_confirmation",
|
||||||
|
"status_label": "منتظر تایید",
|
||||||
|
"requested_at": "2025-01-10T08:30:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recommendation_uuid": "bbdf0d50-0f78-4099-a4d3-b1c4aa54eeb9",
|
||||||
|
"crop_id": "ذرت",
|
||||||
|
"plant_name": "ذرت",
|
||||||
|
"growth_stage": "flowering",
|
||||||
|
"fertilizer_type": "Micronutrient",
|
||||||
|
"status": "pending_confirmation",
|
||||||
|
"status_label": "منتظر تایید",
|
||||||
|
"requested_at": "2025-01-08T09:10:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pagination": {
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10,
|
||||||
|
"total_pages": 3,
|
||||||
|
"total_items": 25,
|
||||||
|
"has_next": true,
|
||||||
|
"has_previous": false,
|
||||||
|
"next": "http://localhost:8000/api/fertilization/recommendations/?farm_uuid=11111111-1111-1111-1111-111111111111&page=2&page_size=10",
|
||||||
|
"previous": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### فیلدهای `data[]`
|
||||||
|
|
||||||
|
- `recommendation_uuid`: شناسه یکتای recommendation برای گرفتن جزئیات
|
||||||
|
- `crop_id`: شناسه یا نام محصول ثبت شده در recommendation
|
||||||
|
- `plant_name`: معادل نمایشی `crop_id` برای سازگاری با فرانت
|
||||||
|
- `growth_stage`: مرحله رشد در زمان ثبت recommendation
|
||||||
|
- `fertilizer_type`: نوع کود پیشنهادی مثل `NPK`
|
||||||
|
- `status`: کد وضعیت recommendation
|
||||||
|
- `status_label`: متن نمایشی وضعیت recommendation
|
||||||
|
- `requested_at`: زمان ثبت recommendation
|
||||||
|
|
||||||
|
### فیلدهای `pagination`
|
||||||
|
|
||||||
|
- `page`: صفحه فعلی
|
||||||
|
- `page_size`: تعداد آیتم در هر صفحه
|
||||||
|
- `total_pages`: تعداد کل صفحات
|
||||||
|
- `total_items`: تعداد کل recommendationها
|
||||||
|
- `has_next`: آیا صفحه بعدی وجود دارد یا نه
|
||||||
|
- `has_previous`: آیا صفحه قبلی وجود دارد یا نه
|
||||||
|
- `next`: لینک صفحه بعدی
|
||||||
|
- `previous`: لینک صفحه قبلی
|
||||||
|
|
||||||
|
### پیشنهاد نمایش status در UI
|
||||||
|
|
||||||
|
- `pending_confirmation` → badge زرد یا خاکستری روشن
|
||||||
|
- `in_progress` → badge آبی یا سبز
|
||||||
|
- `completed` → badge خاکستری یا سفید
|
||||||
|
|
||||||
|
### خطاهای رایج
|
||||||
|
|
||||||
|
#### مزرعه پیدا نشد
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"farm_uuid": [
|
||||||
|
"Farm not found."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### پارامترهای pagination نامعتبر
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": [
|
||||||
|
"Ensure this value is greater than or equal to 1."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) جزئیات یک recommendation
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
`GET /api/fertilization/recommendations/<recommendation_uuid>/`
|
||||||
|
|
||||||
|
### کاربرد
|
||||||
|
|
||||||
|
- نمایش کامل جزئیات recommendation
|
||||||
|
- باز کردن صفحه detail یا modal recommendation
|
||||||
|
- replay کردن خروجی recommendation بدون نیاز به درخواست مجدد از AI
|
||||||
|
|
||||||
|
### Path Param
|
||||||
|
|
||||||
|
- `recommendation_uuid`: شناسه recommendation از API لیست
|
||||||
|
|
||||||
|
### نکته مهم برای محصول
|
||||||
|
|
||||||
|
- فیلد اصلی محصول در این ماژول `crop_id` است
|
||||||
|
- `crop_id` همان plant name است
|
||||||
|
- بک اند همان `crop_id` را مستقیم برای AI ارسال می کند
|
||||||
|
- `plant_name` در response فقط برای سازگاری فرانت نگه داشته شده و مقدارش برابر `crop_id` است
|
||||||
|
|
||||||
|
### هدرها
|
||||||
|
|
||||||
|
- `Authorization: Bearer <token>`
|
||||||
|
- `Accept: application/json`
|
||||||
|
|
||||||
|
### نمونه درخواست
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET \
|
||||||
|
'http://localhost:8000/api/fertilization/recommendations/4d595ee0-9dbb-4c50-a871-2b4359d0d748/' \
|
||||||
|
-H 'accept: application/json' \
|
||||||
|
-H 'Authorization: Bearer <token>'
|
||||||
|
```
|
||||||
|
|
||||||
|
### نمونه پاسخ موفق
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"crop_id": "گندم",
|
||||||
|
"plant_name": "گندم",
|
||||||
|
"growth_stage": "vegetative",
|
||||||
|
"status": "pending_confirmation",
|
||||||
|
"status_label": "منتظر تایید",
|
||||||
|
"primary_recommendation": {
|
||||||
|
"fertilizer_code": "npk-202020",
|
||||||
|
"fertilizer_name": "NPK 20-20-20",
|
||||||
|
"display_title": "کود کامل متعادل",
|
||||||
|
"fertilizer_type": "NPK",
|
||||||
|
"npk_ratio": {
|
||||||
|
"n": 20,
|
||||||
|
"p": 20,
|
||||||
|
"k": 20,
|
||||||
|
"label": "20-20-20"
|
||||||
|
},
|
||||||
|
"application_method": {
|
||||||
|
"id": "fertigation",
|
||||||
|
"label": "کودآبیاری"
|
||||||
|
},
|
||||||
|
"application_interval": {
|
||||||
|
"value": 14,
|
||||||
|
"unit": "day",
|
||||||
|
"label": "هر 14 روز"
|
||||||
|
},
|
||||||
|
"dosage": {
|
||||||
|
"base_amount_per_hectare": 65,
|
||||||
|
"base_amount_per_square_meter": 0.0065,
|
||||||
|
"unit": "kg",
|
||||||
|
"label": "65 کیلوگرم در هکتار",
|
||||||
|
"calculation_basis": "engine-v2"
|
||||||
|
},
|
||||||
|
"reasoning": "متعادل برای فاز رشد",
|
||||||
|
"summary": "مصرف منظم در این مرحله توصیه می شود"
|
||||||
|
},
|
||||||
|
"nutrient_analysis": {
|
||||||
|
"macro": [
|
||||||
|
{
|
||||||
|
"key": "n",
|
||||||
|
"name": "Nitrogen",
|
||||||
|
"value": 20,
|
||||||
|
"unit": "percent",
|
||||||
|
"description": "تقویت رشد رویشی"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"micro": []
|
||||||
|
},
|
||||||
|
"application_guide": {
|
||||||
|
"safety_warning": "در ساعات خنک مصرف شود",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"step_number": 1,
|
||||||
|
"title": "حل کردن",
|
||||||
|
"description": "کود را در آب حل کنید"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"alternative_recommendations": [
|
||||||
|
{
|
||||||
|
"fertilizer_code": "npk-121236",
|
||||||
|
"fertilizer_name": "NPK 12-12-36",
|
||||||
|
"fertilizer_type": "NPK",
|
||||||
|
"usage_method": "fertigation",
|
||||||
|
"description": "برای نیاز پتاس بالا"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "recommendation",
|
||||||
|
"title": "پیشنهاد اصلی",
|
||||||
|
"icon": "leaf",
|
||||||
|
"content": "NPK 20-20-20"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### نکته مهم
|
||||||
|
|
||||||
|
این response دقیقا همان ساختار endpoint زیر را برمی گرداند:
|
||||||
|
|
||||||
|
`POST /api/fertilization/recommend/`
|
||||||
|
|
||||||
|
یعنی فرانت می تواند برای صفحه detail همان componentهایی را استفاده کند که برای recommendation اصلی استفاده می کند.
|
||||||
|
|
||||||
|
### خطای رایج
|
||||||
|
|
||||||
|
#### recommendation پیدا نشد
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 404,
|
||||||
|
"msg": "Recommendation not found."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پیشنهاد پیاده سازی در فرانت
|
||||||
|
|
||||||
|
### برای صفحه history
|
||||||
|
|
||||||
|
- ابتدا API لیست را با `farm_uuid` صدا بزنید
|
||||||
|
- `data` را در جدول یا کارت لیست نمایش دهید
|
||||||
|
- `status_label` را مستقیم در badge یا chip نشان دهید
|
||||||
|
- اگر لازم بود رفتار UI بر اساس وضعیت تغییر کند، از `status` استفاده کنید
|
||||||
|
- با `pagination.page` و `pagination.total_pages` صفحه بندی را بسازید
|
||||||
|
- روی هر آیتم با `recommendation_uuid` به صفحه detail بروید
|
||||||
|
|
||||||
|
### برای صفحه detail
|
||||||
|
|
||||||
|
- `recommendation_uuid` را از route بگیرید
|
||||||
|
- API جزئیات را صدا بزنید
|
||||||
|
- `data.primary_recommendation` را در Hero/Card اصلی نمایش دهید
|
||||||
|
- `data.nutrient_analysis` را در بخش تحلیل عناصر نمایش دهید
|
||||||
|
- `data.application_guide` را در بخش راهنمای مصرف نمایش دهید
|
||||||
|
- `data.alternative_recommendations` را برای جایگزین ها نمایش دهید
|
||||||
|
- در صورت نیاز برای سازگاری، از `data.sections` هم استفاده کنید
|
||||||
|
|
||||||
|
### فرمول محاسبه مقدار مصرف
|
||||||
|
|
||||||
|
```text
|
||||||
|
مقدار کل = base_amount_per_square_meter × مساحت مزرعه
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خلاصه مسیرها
|
||||||
|
|
||||||
|
- لیست recommendationها:
|
||||||
|
- `GET /api/fertilization/recommendations/?farm_uuid=<farm_uuid>&page=1&page_size=10`
|
||||||
|
- جزئیات recommendation:
|
||||||
|
- `GET /api/fertilization/recommendations/<recommendation_uuid>/`
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("farm_hub", "0008_product_plant_selector_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="farmhub",
|
||||||
|
name="irrigation_method_id",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="farmhub",
|
||||||
|
name="irrigation_method_name",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -103,6 +103,8 @@ class FarmHub(models.Model):
|
|||||||
)
|
)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
irrigation_method_id = models.IntegerField(null=True, blank=True)
|
||||||
|
irrigation_method_name = models.CharField(max_length=255, blank=True, default="")
|
||||||
current_crop_area = models.ForeignKey(
|
current_crop_area = models.ForeignKey(
|
||||||
"crop_zoning.CropArea",
|
"crop_zoning.CropArea",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ ADMIN_FARM_UUID = uuid.UUID("11111111-1111-1111-1111-111111111111")
|
|||||||
ADMIN_FARM_DATA = {
|
ADMIN_FARM_DATA = {
|
||||||
"name": "Admin Smart Farm",
|
"name": "Admin Smart Farm",
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
|
"irrigation_method_id": 1,
|
||||||
|
"irrigation_method_name": "آبیاری قطره ای",
|
||||||
"sensors": [
|
"sensors": [
|
||||||
{
|
{
|
||||||
"sensor_catalog_code": "sensor_7_soil_moisture_sensor_v1_2",
|
"sensor_catalog_code": "sensor_7_soil_moisture_sensor_v1_2",
|
||||||
@@ -81,6 +83,8 @@ def seed_admin_farm():
|
|||||||
"farm_type": farm_type,
|
"farm_type": farm_type,
|
||||||
"name": ADMIN_FARM_DATA["name"],
|
"name": ADMIN_FARM_DATA["name"],
|
||||||
"is_active": ADMIN_FARM_DATA["is_active"],
|
"is_active": ADMIN_FARM_DATA["is_active"],
|
||||||
|
"irrigation_method_id": ADMIN_FARM_DATA["irrigation_method_id"],
|
||||||
|
"irrigation_method_name": ADMIN_FARM_DATA["irrigation_method_name"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
farm.products.set(products)
|
farm.products.set(products)
|
||||||
|
|||||||
+13
-3
@@ -72,6 +72,8 @@ class FarmHubSerializer(serializers.ModelSerializer):
|
|||||||
"area_uuid",
|
"area_uuid",
|
||||||
"name",
|
"name",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
"irrigation_method_id",
|
||||||
|
"irrigation_method_name",
|
||||||
"farm_type",
|
"farm_type",
|
||||||
"subscription_plan",
|
"subscription_plan",
|
||||||
"products",
|
"products",
|
||||||
@@ -128,7 +130,8 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
|||||||
sensors = FarmSensorWriteSerializer(many=True, required=False)
|
sensors = FarmSensorWriteSerializer(many=True, required=False)
|
||||||
sensor_key = serializers.CharField(write_only=True, required=False, allow_blank=True, default="sensor-7-1")
|
sensor_key = serializers.CharField(write_only=True, required=False, allow_blank=True, default="sensor-7-1")
|
||||||
sensor_payload = serializers.JSONField(write_only=True, required=False)
|
sensor_payload = serializers.JSONField(write_only=True, required=False)
|
||||||
irrigation_method_id = serializers.IntegerField(write_only=True, required=False, allow_null=True)
|
irrigation_method_id = serializers.IntegerField(required=False, allow_null=True)
|
||||||
|
irrigation_method_name = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = FarmHub
|
model = FarmHub
|
||||||
@@ -144,6 +147,7 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
|||||||
"sensor_key",
|
"sensor_key",
|
||||||
"sensor_payload",
|
"sensor_payload",
|
||||||
"irrigation_method_id",
|
"irrigation_method_id",
|
||||||
|
"irrigation_method_name",
|
||||||
]
|
]
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
@@ -217,13 +221,20 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
|||||||
attrs["farm_type"] = farm_type
|
attrs["farm_type"] = farm_type
|
||||||
attrs["subscription_plan"] = subscription_plan
|
attrs["subscription_plan"] = subscription_plan
|
||||||
attrs["products"] = products
|
attrs["products"] = products
|
||||||
|
|
||||||
|
irrigation_method_id = attrs.get("irrigation_method_id", serializers.empty)
|
||||||
|
irrigation_method_name = attrs.get("irrigation_method_name", serializers.empty)
|
||||||
|
if irrigation_method_id is None:
|
||||||
|
attrs["irrigation_method_name"] = ""
|
||||||
|
elif irrigation_method_name is serializers.empty and self.instance is not None:
|
||||||
|
attrs["irrigation_method_name"] = self.instance.irrigation_method_name
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data.pop("area_geojson", None)
|
validated_data.pop("area_geojson", None)
|
||||||
validated_data.pop("sensor_key", None)
|
validated_data.pop("sensor_key", None)
|
||||||
validated_data.pop("sensor_payload", None)
|
validated_data.pop("sensor_payload", None)
|
||||||
validated_data.pop("irrigation_method_id", None)
|
|
||||||
sensors_data = validated_data.pop("sensors", [])
|
sensors_data = validated_data.pop("sensors", [])
|
||||||
products = validated_data.pop("products", [])
|
products = validated_data.pop("products", [])
|
||||||
validated_data["farm_type"] = validated_data.pop("farm_type")
|
validated_data["farm_type"] = validated_data.pop("farm_type")
|
||||||
@@ -243,7 +254,6 @@ class FarmHubCreateSerializer(serializers.ModelSerializer):
|
|||||||
validated_data.pop("area_geojson", None)
|
validated_data.pop("area_geojson", None)
|
||||||
validated_data.pop("sensor_key", None)
|
validated_data.pop("sensor_key", None)
|
||||||
validated_data.pop("sensor_payload", None)
|
validated_data.pop("sensor_payload", None)
|
||||||
validated_data.pop("irrigation_method_id", None)
|
|
||||||
sensors_data = validated_data.pop("sensors", None)
|
sensors_data = validated_data.pop("sensors", None)
|
||||||
products = validated_data.pop("products", None)
|
products = validated_data.pop("products", None)
|
||||||
farm_type = validated_data.pop("farm_type", None)
|
farm_type = validated_data.pop("farm_type", None)
|
||||||
|
|||||||
@@ -73,8 +73,11 @@ def sync_farm_data(
|
|||||||
if plant_ids:
|
if plant_ids:
|
||||||
request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids]
|
request_payload["plant_ids"] = [int(plant_id) for plant_id in plant_ids]
|
||||||
|
|
||||||
if irrigation_method_id is not None:
|
resolved_irrigation_method_id = irrigation_method_id
|
||||||
request_payload["irrigation_method_id"] = int(irrigation_method_id)
|
if resolved_irrigation_method_id is None:
|
||||||
|
resolved_irrigation_method_id = farm.irrigation_method_id
|
||||||
|
if resolved_irrigation_method_id is not None:
|
||||||
|
request_payload["irrigation_method_id"] = int(resolved_irrigation_method_id)
|
||||||
|
|
||||||
if not any(key in request_payload for key in ("sensor_payload", "plant_ids", "irrigation_method_id")):
|
if not any(key in request_payload for key in ("sensor_payload", "plant_ids", "irrigation_method_id")):
|
||||||
raise FarmDataSyncError(
|
raise FarmDataSyncError(
|
||||||
@@ -112,7 +115,7 @@ def create_farm_with_zoning(serializer, owner):
|
|||||||
area_feature = serializer.validated_data.pop("area_geojson", None) or get_default_area_feature()
|
area_feature = serializer.validated_data.pop("area_geojson", None) or get_default_area_feature()
|
||||||
sensor_key = serializer.validated_data.pop("sensor_key", "sensor-7-1")
|
sensor_key = serializer.validated_data.pop("sensor_key", "sensor-7-1")
|
||||||
sensor_payload = serializer.validated_data.pop("sensor_payload", None)
|
sensor_payload = serializer.validated_data.pop("sensor_payload", None)
|
||||||
irrigation_method_id = serializer.validated_data.pop("irrigation_method_id", None)
|
irrigation_method_id = serializer.validated_data.get("irrigation_method_id", None)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
farm = serializer.save(owner=owner)
|
farm = serializer.save(owner=owner)
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ class FarmListCreateViewTests(TestCase):
|
|||||||
"farm_type_uuid": str(self.farm_type.uuid),
|
"farm_type_uuid": str(self.farm_type.uuid),
|
||||||
"subscription_plan_uuid": str(self.plan.uuid),
|
"subscription_plan_uuid": str(self.plan.uuid),
|
||||||
"product_uuids": [str(self.wheat.uuid)],
|
"product_uuids": [str(self.wheat.uuid)],
|
||||||
|
"irrigation_method_id": 3,
|
||||||
|
"irrigation_method_name": "Drip",
|
||||||
"sensors": [
|
"sensors": [
|
||||||
{
|
{
|
||||||
"sensor_catalog_uuid": str(self.weather_station.uuid),
|
"sensor_catalog_uuid": str(self.weather_station.uuid),
|
||||||
@@ -88,6 +90,8 @@ class FarmListCreateViewTests(TestCase):
|
|||||||
self.assertEqual(response.data["code"], 201)
|
self.assertEqual(response.data["code"], 201)
|
||||||
self.assertEqual(response.data["data"]["name"], "farm-1")
|
self.assertEqual(response.data["data"]["name"], "farm-1")
|
||||||
self.assertEqual(response.data["data"]["subscription_plan"]["code"], self.plan.code)
|
self.assertEqual(response.data["data"]["subscription_plan"]["code"], self.plan.code)
|
||||||
|
self.assertEqual(response.data["data"]["irrigation_method_id"], 3)
|
||||||
|
self.assertEqual(response.data["data"]["irrigation_method_name"], "Drip")
|
||||||
self.assertIn("zoning", response.data["data"])
|
self.assertIn("zoning", response.data["data"])
|
||||||
self.assertIsNotNone(response.data["data"]["area_uuid"])
|
self.assertIsNotNone(response.data["data"]["area_uuid"])
|
||||||
self.assertEqual(len(response.data["data"]["sensors"]), 1)
|
self.assertEqual(len(response.data["data"]["sensors"]), 1)
|
||||||
@@ -107,6 +111,7 @@ class FarmListCreateViewTests(TestCase):
|
|||||||
"farm_uuid": response.data["data"]["farm_uuid"],
|
"farm_uuid": response.data["data"]["farm_uuid"],
|
||||||
"farm_boundary": AREA_GEOJSON["geometry"],
|
"farm_boundary": AREA_GEOJSON["geometry"],
|
||||||
"plant_ids": [self.wheat.id],
|
"plant_ids": [self.wheat.id],
|
||||||
|
"irrigation_method_id": 3,
|
||||||
},
|
},
|
||||||
headers={
|
headers={
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
@@ -223,6 +228,7 @@ class FarmListCreateViewTests(TestCase):
|
|||||||
},
|
},
|
||||||
"sensor_payload": {"soil_moisture": 45.2},
|
"sensor_payload": {"soil_moisture": 45.2},
|
||||||
"irrigation_method_id": 3,
|
"irrigation_method_id": 3,
|
||||||
|
"irrigation_method_name": "Drip",
|
||||||
},
|
},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
@@ -233,6 +239,8 @@ class FarmListCreateViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
farm.refresh_from_db()
|
farm.refresh_from_db()
|
||||||
self.assertIsNotNone(farm.current_crop_area)
|
self.assertIsNotNone(farm.current_crop_area)
|
||||||
|
self.assertEqual(farm.irrigation_method_id, 3)
|
||||||
|
self.assertEqual(farm.irrigation_method_name, "Drip")
|
||||||
mock_external_api_request.assert_called_once_with(
|
mock_external_api_request.assert_called_once_with(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/farm-data/",
|
"/api/farm-data/",
|
||||||
@@ -279,6 +287,8 @@ class FarmSeedTests(TestCase):
|
|||||||
self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111")
|
self.assertEqual(farm.farm_uuid.hex, "11111111111111111111111111111111")
|
||||||
self.assertEqual(CropArea.objects.count(), 1)
|
self.assertEqual(CropArea.objects.count(), 1)
|
||||||
self.assertEqual(farm.sensors.count(), 1)
|
self.assertEqual(farm.sensors.count(), 1)
|
||||||
|
self.assertEqual(farm.irrigation_method_id, 1)
|
||||||
|
self.assertEqual(farm.irrigation_method_name, "آبیاری قطره ای")
|
||||||
self.assertIsNotNone(farm.sensors.first().physical_device_uuid)
|
self.assertIsNotNone(farm.sensors.first().physical_device_uuid)
|
||||||
self.assertTrue(SensorCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists())
|
self.assertTrue(SensorCatalog.objects.filter(code="sensor_7_soil_moisture_sensor_v1_2").exists())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
PENDING_STATUS = "pending_confirmation"
|
||||||
|
OLD_STATUSES = {"", "success", "error", None}
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_existing_statuses(apps, schema_editor):
|
||||||
|
Recommendation = apps.get_model("fertilization_recommendation", "FertilizationRecommendationRequest")
|
||||||
|
Recommendation.objects.filter(status__in=[status for status in OLD_STATUSES if status is not None]).update(
|
||||||
|
status=PENDING_STATUS
|
||||||
|
)
|
||||||
|
Recommendation.objects.filter(status__isnull=True).update(status=PENDING_STATUS)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("fertilization_recommendation", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="fertilizationrecommendationrequest",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("in_progress", "در حال مصرف"),
|
||||||
|
("pending_confirmation", "منتظر تایید"),
|
||||||
|
("completed", "پایان یافته"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="pending_confirmation",
|
||||||
|
max_length=64,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(migrate_existing_statuses, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -6,6 +6,15 @@ from farm_hub.models import FarmHub
|
|||||||
|
|
||||||
|
|
||||||
class FertilizationRecommendationRequest(models.Model):
|
class FertilizationRecommendationRequest(models.Model):
|
||||||
|
STATUS_IN_PROGRESS = "in_progress"
|
||||||
|
STATUS_PENDING_CONFIRMATION = "pending_confirmation"
|
||||||
|
STATUS_COMPLETED = "completed"
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
(STATUS_IN_PROGRESS, "در حال مصرف"),
|
||||||
|
(STATUS_PENDING_CONFIRMATION, "منتظر تایید"),
|
||||||
|
(STATUS_COMPLETED, "پایان یافته"),
|
||||||
|
)
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||||
farm = models.ForeignKey(
|
farm = models.ForeignKey(
|
||||||
FarmHub,
|
FarmHub,
|
||||||
@@ -15,7 +24,12 @@ class FertilizationRecommendationRequest(models.Model):
|
|||||||
crop_id = 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="")
|
growth_stage = models.CharField(max_length=255, blank=True, default="")
|
||||||
task_id = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
task_id = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||||
status = models.CharField(max_length=64, blank=True, default="")
|
status = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default=STATUS_PENDING_CONFIRMATION,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
request_payload = models.JSONField(default=dict, blank=True)
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
response_payload = models.JSONField(default=dict, blank=True)
|
response_payload = models.JSONField(default=dict, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ class FertilizationFarmDataSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class FertilizationRecommendRequestSerializer(serializers.Serializer):
|
class FertilizationRecommendRequestSerializer(serializers.Serializer):
|
||||||
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه کودی.")
|
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه کودی.")
|
||||||
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.")
|
crop_id = serializers.CharField(required=False, allow_blank=True, help_text="شناسه یا نام محصول. این فیلد همان plant_name است.")
|
||||||
|
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه. این فیلد همان crop_id است.")
|
||||||
growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.")
|
growth_stage = serializers.CharField(required=False, allow_blank=True, help_text="مرحله رشد گیاه.")
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationRecommendationListQuerySerializer(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 FertilizationSectionSerializer(serializers.Serializer):
|
class FertilizationSectionSerializer(serializers.Serializer):
|
||||||
type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"])
|
type = serializers.ChoiceField(choices=["text", "list", "recommendation", "warning"])
|
||||||
title = serializers.CharField(required=False, allow_blank=True)
|
title = serializers.CharField(required=False, allow_blank=True)
|
||||||
@@ -98,7 +105,24 @@ class AlternativeRecommendationSerializer(serializers.Serializer):
|
|||||||
description = serializers.CharField(required=False, allow_blank=True)
|
description = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationRecommendationListItemSerializer(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)
|
||||||
|
fertilizer_type = 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 FertilizationRecommendResponseDataSerializer(serializers.Serializer):
|
class FertilizationRecommendResponseDataSerializer(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)
|
||||||
|
status = serializers.CharField(read_only=True, required=False)
|
||||||
|
status_label = serializers.CharField(read_only=True, required=False)
|
||||||
primary_recommendation = PrimaryRecommendationSerializer(read_only=True)
|
primary_recommendation = PrimaryRecommendationSerializer(read_only=True)
|
||||||
nutrient_analysis = NutrientAnalysisSerializer(read_only=True)
|
nutrient_analysis = NutrientAnalysisSerializer(read_only=True)
|
||||||
application_guide = ApplicationGuideSerializer(read_only=True)
|
application_guide = ApplicationGuideSerializer(read_only=True)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType
|
from farm_hub.models import FarmHub, FarmType
|
||||||
from .views import RecommendView
|
from .models import FertilizationRecommendationRequest
|
||||||
|
from .views import RecommendationDetailView, RecommendationListView, RecommendView
|
||||||
|
|
||||||
|
|
||||||
class FertilizationRecommendViewTests(TestCase):
|
class FertilizationRecommendViewTests(TestCase):
|
||||||
@@ -78,7 +79,7 @@ class FertilizationRecommendViewTests(TestCase):
|
|||||||
|
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"/api/fertilization/recommend/",
|
"/api/fertilization/recommend/",
|
||||||
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "گندم", "growth_stage": "vegetative"},
|
{"farm_uuid": str(self.farm.farm_uuid), "crop_id": "گندم", "growth_stage": "vegetative"},
|
||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
force_authenticate(request, user=self.user)
|
force_authenticate(request, user=self.user)
|
||||||
@@ -95,13 +96,179 @@ class FertilizationRecommendViewTests(TestCase):
|
|||||||
self.assertEqual(response.data["data"]["primary_recommendation"]["application_interval"]["value"], 14.0)
|
self.assertEqual(response.data["data"]["primary_recommendation"]["application_interval"]["value"], 14.0)
|
||||||
self.assertEqual(response.data["data"]["alternative_recommendations"][0]["usage_method"], "fertigation")
|
self.assertEqual(response.data["data"]["alternative_recommendations"][0]["usage_method"], "fertigation")
|
||||||
self.assertEqual(response.data["data"]["sections"][0]["type"], "recommendation")
|
self.assertEqual(response.data["data"]["sections"][0]["type"], "recommendation")
|
||||||
|
self.assertEqual(FertilizationRecommendationRequest.objects.count(), 1)
|
||||||
|
saved_request = FertilizationRecommendationRequest.objects.get()
|
||||||
|
self.assertEqual(saved_request.crop_id, "گندم")
|
||||||
|
self.assertEqual(saved_request.growth_stage, "vegetative")
|
||||||
|
self.assertEqual(
|
||||||
|
saved_request.status,
|
||||||
|
FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
saved_request.response_payload["data"]["primary_recommendation"]["fertilizer_code"],
|
||||||
|
"npk-202020",
|
||||||
|
)
|
||||||
mock_external_api_request.assert_called_once_with(
|
mock_external_api_request.assert_called_once_with(
|
||||||
"ai",
|
"ai",
|
||||||
"/api/fertilization/recommend/",
|
"/api/fertilization/recommend/",
|
||||||
method="POST",
|
method="POST",
|
||||||
payload={
|
payload={
|
||||||
"farm_uuid": str(self.farm.farm_uuid),
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"crop_id": "گندم",
|
||||||
"plant_name": "گندم",
|
"plant_name": "گندم",
|
||||||
"growth_stage": "vegetative",
|
"growth_stage": "vegetative",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("fertilization_recommendation.views.external_api_request")
|
||||||
|
def test_recommend_accepts_plant_name_and_passes_it_directly_to_ai(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(status_code=200, data={"data": {}})
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/fertilization/recommend/",
|
||||||
|
{"farm_uuid": str(self.farm.farm_uuid), "plant_name": "جو", "growth_stage": "flowering"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = RecommendView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
saved_request = FertilizationRecommendationRequest.objects.latest("created_at")
|
||||||
|
self.assertEqual(saved_request.crop_id, "جو")
|
||||||
|
mock_external_api_request.assert_called_once_with(
|
||||||
|
"ai",
|
||||||
|
"/api/fertilization/recommend/",
|
||||||
|
method="POST",
|
||||||
|
payload={
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"crop_id": "جو",
|
||||||
|
"plant_name": "جو",
|
||||||
|
"growth_stage": "flowering",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recommendation_list_returns_paginated_summary_items(self):
|
||||||
|
first = FertilizationRecommendationRequest.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
crop_id="گندم",
|
||||||
|
growth_stage="vegetative",
|
||||||
|
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||||
|
response_payload={
|
||||||
|
"data": {
|
||||||
|
"primary_recommendation": {
|
||||||
|
"fertilizer_type": "NPK",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
second = FertilizationRecommendationRequest.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
crop_id="ذرت",
|
||||||
|
growth_stage="flowering",
|
||||||
|
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||||
|
response_payload={
|
||||||
|
"data": {
|
||||||
|
"primary_recommendation": {
|
||||||
|
"fertilizer_type": "Micronutrient",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(
|
||||||
|
f"/api/fertilization/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"]["page"], 1)
|
||||||
|
self.assertEqual(response.data["pagination"]["page_size"], 1)
|
||||||
|
self.assertEqual(response.data["pagination"]["total_pages"], 2)
|
||||||
|
self.assertEqual(response.data["pagination"]["total_items"], 2)
|
||||||
|
self.assertTrue(response.data["pagination"]["has_next"])
|
||||||
|
self.assertFalse(response.data["pagination"]["has_previous"])
|
||||||
|
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]["fertilizer_type"], "Micronutrient")
|
||||||
|
self.assertEqual(response.data["data"][0]["status"], "pending_confirmation")
|
||||||
|
self.assertEqual(response.data["data"][0]["status_label"], "منتظر تایید")
|
||||||
|
self.assertIn("requested_at", response.data["data"][0])
|
||||||
|
self.assertNotEqual(response.data["data"][0]["recommendation_uuid"], str(first.uuid))
|
||||||
|
|
||||||
|
def test_recommendation_detail_returns_same_shape_as_recommend_endpoint(self):
|
||||||
|
recommendation = FertilizationRecommendationRequest.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
crop_id="گندم",
|
||||||
|
growth_stage="vegetative",
|
||||||
|
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||||
|
response_payload={
|
||||||
|
"data": {
|
||||||
|
"primary_recommendation": {
|
||||||
|
"fertilizer_code": "npk-202020",
|
||||||
|
"fertilizer_type": "NPK",
|
||||||
|
"summary": "خلاصه توصیه",
|
||||||
|
},
|
||||||
|
"nutrient_analysis": {
|
||||||
|
"macro": [{"key": "n", "name": "Nitrogen", "value": 20, "unit": "percent"}],
|
||||||
|
"micro": [],
|
||||||
|
},
|
||||||
|
"application_guide": {
|
||||||
|
"safety_warning": "در هوای خنک استفاده شود",
|
||||||
|
"steps": [{"step_number": 1, "title": "آماده سازی", "description": "در آب حل شود"}],
|
||||||
|
},
|
||||||
|
"alternative_recommendations": [
|
||||||
|
{"fertilizer_code": "alt-1", "fertilizer_name": "Alt", "fertilizer_type": "NPK"}
|
||||||
|
],
|
||||||
|
"sections": [{"type": "warning", "title": "هشدار", "content": "اختلاط نشود"}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/fertilization/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"]["primary_recommendation"]["fertilizer_code"], "npk-202020")
|
||||||
|
self.assertEqual(response.data["data"]["primary_recommendation"]["fertilizer_type"], "NPK")
|
||||||
|
self.assertEqual(response.data["data"]["nutrient_analysis"]["macro"][0]["value"], 20.0)
|
||||||
|
self.assertEqual(response.data["data"]["application_guide"]["steps"][0]["step_number"], 1)
|
||||||
|
self.assertEqual(response.data["data"]["sections"][0]["type"], "warning")
|
||||||
|
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"]["status"], "pending_confirmation")
|
||||||
|
self.assertEqual(response.data["data"]["status_label"], "منتظر تایید")
|
||||||
|
|
||||||
|
def test_recommendation_detail_falls_back_to_top_level_fertilizer_code(self):
|
||||||
|
recommendation = FertilizationRecommendationRequest.objects.create(
|
||||||
|
farm=self.farm,
|
||||||
|
crop_id="گندم",
|
||||||
|
growth_stage="vegetative",
|
||||||
|
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||||
|
response_payload={
|
||||||
|
"data": {
|
||||||
|
"fertilizer_code": "legacy-code-101",
|
||||||
|
"fertilizer_type": "NPK",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.get(f"/api/fertilization/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["data"]["primary_recommendation"]["fertilizer_code"],
|
||||||
|
"legacy-code-101",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import ConfigView, RecommendView
|
from .views import ConfigView, RecommendationDetailView, RecommendationListView, RecommendView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
|
path("config/", ConfigView.as_view(), name="fertilization-recommendation-config"),
|
||||||
|
path("recommendations/<uuid:recommendation_uuid>/", RecommendationDetailView.as_view(), name="fertilization-recommendation-detail"),
|
||||||
|
path("recommendations/", RecommendationListView.as_view(), name="fertilization-recommendation-list"),
|
||||||
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"),
|
path("recommend/", RecommendView.as_view(), name="fertilization-recommendation-recommend"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ Fertilization Recommendation API views.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter
|
||||||
from rest_framework import serializers, status
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
@@ -15,6 +18,8 @@ from farm_hub.models import FarmHub
|
|||||||
from .mock_data import CONFIG_RESPONSE_DATA
|
from .mock_data import CONFIG_RESPONSE_DATA
|
||||||
from .models import FertilizationRecommendationRequest
|
from .models import FertilizationRecommendationRequest
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
FertilizationRecommendationListItemSerializer,
|
||||||
|
FertilizationRecommendationListQuerySerializer,
|
||||||
FertilizationRecommendRequestSerializer,
|
FertilizationRecommendRequestSerializer,
|
||||||
FertilizationRecommendResponseDataSerializer,
|
FertilizationRecommendResponseDataSerializer,
|
||||||
)
|
)
|
||||||
@@ -23,6 +28,33 @@ from .serializers import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FertilizationRecommendationPagination(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:
|
class FarmAccessMixin:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_farm(request, farm_uuid):
|
def _get_farm(request, farm_uuid):
|
||||||
@@ -161,21 +193,50 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _first_non_empty(*values):
|
||||||
|
for value in values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
def _normalize_primary_recommendation(self, payload):
|
def _normalize_primary_recommendation(self, payload):
|
||||||
raw_data = payload.get("primary_recommendation")
|
raw_data = payload.get("primary_recommendation")
|
||||||
if not isinstance(raw_data, dict):
|
if not isinstance(raw_data, dict):
|
||||||
return {}
|
raw_data = {}
|
||||||
|
|
||||||
normalized = {}
|
normalized = {}
|
||||||
for key in (
|
scalar_fields = {
|
||||||
"fertilizer_code",
|
"fertilizer_code": (
|
||||||
"fertilizer_name",
|
raw_data.get("fertilizer_code"),
|
||||||
"display_title",
|
payload.get("fertilizer_code"),
|
||||||
"fertilizer_type",
|
),
|
||||||
"reasoning",
|
"fertilizer_name": (
|
||||||
"summary",
|
raw_data.get("fertilizer_name"),
|
||||||
):
|
payload.get("fertilizer_name"),
|
||||||
value = self._to_string(raw_data.get(key)).strip()
|
),
|
||||||
|
"display_title": (
|
||||||
|
raw_data.get("display_title"),
|
||||||
|
payload.get("display_title"),
|
||||||
|
),
|
||||||
|
"fertilizer_type": (
|
||||||
|
raw_data.get("fertilizer_type"),
|
||||||
|
payload.get("fertilizer_type"),
|
||||||
|
),
|
||||||
|
"reasoning": (
|
||||||
|
raw_data.get("reasoning"),
|
||||||
|
payload.get("reasoning"),
|
||||||
|
),
|
||||||
|
"summary": (
|
||||||
|
raw_data.get("summary"),
|
||||||
|
payload.get("summary"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for key, values in scalar_fields.items():
|
||||||
|
value = self._first_non_empty(*values)
|
||||||
if value:
|
if value:
|
||||||
normalized[key] = value
|
normalized[key] = value
|
||||||
|
|
||||||
@@ -305,8 +366,11 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
payload = serializer.validated_data.copy()
|
payload = serializer.validated_data.copy()
|
||||||
farm = self._get_farm(request, payload.get("farm_uuid"))
|
farm = self._get_farm(request, payload.get("farm_uuid"))
|
||||||
|
crop_id = self._first_non_empty(payload.get("crop_id"), payload.get("plant_name"))
|
||||||
|
plant_name = self._first_non_empty(payload.get("plant_name"), payload.get("crop_id"))
|
||||||
payload["farm_uuid"] = str(farm.farm_uuid)
|
payload["farm_uuid"] = str(farm.farm_uuid)
|
||||||
payload["plant_name"] = payload.get("plant_name", "")
|
payload["crop_id"] = crop_id
|
||||||
|
payload["plant_name"] = plant_name
|
||||||
payload["growth_stage"] = payload.get("growth_stage", "")
|
payload["growth_stage"] = payload.get("growth_stage", "")
|
||||||
|
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
@@ -329,10 +393,10 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
FertilizationRecommendationRequest.objects.create(
|
FertilizationRecommendationRequest.objects.create(
|
||||||
farm=farm,
|
farm=farm,
|
||||||
crop_id=payload.get("plant_name", ""),
|
crop_id=crop_id,
|
||||||
growth_stage=payload.get("growth_stage", ""),
|
growth_stage=payload.get("growth_stage", ""),
|
||||||
task_id="",
|
task_id="",
|
||||||
status="success" if adapter_response.status_code < 400 else "error",
|
status=FertilizationRecommendationRequest.STATUS_PENDING_CONFIRMATION,
|
||||||
request_payload=payload,
|
request_payload=payload,
|
||||||
response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data},
|
response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data},
|
||||||
)
|
)
|
||||||
@@ -354,3 +418,70 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
},
|
},
|
||||||
status=status.HTTP_200_OK,
|
status=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationListView(FarmAccessMixin, APIView):
|
||||||
|
permission_classes = RecommendView.permission_classes
|
||||||
|
pagination_class = FertilizationRecommendationPagination
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Fertilization Recommendation"],
|
||||||
|
parameters=[FertilizationRecommendationListQuerySerializer],
|
||||||
|
responses={200: code_response("FertilizationRecommendationListResponse")},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
serializer = FertilizationRecommendationListQuerySerializer(data=request.query_params)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
farm = self._get_farm(request, serializer.validated_data["farm_uuid"])
|
||||||
|
recommendations = farm.fertilization_recommendations.all().order_by("-created_at", "-id")
|
||||||
|
|
||||||
|
paginator = self.pagination_class()
|
||||||
|
page = paginator.paginate_queryset(recommendations, request, view=self)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
view_helper = RecommendView()
|
||||||
|
for recommendation in page:
|
||||||
|
normalized_payload = view_helper._normalize_response_payload(recommendation.response_payload)
|
||||||
|
recommendation.fertilizer_type = (
|
||||||
|
normalized_payload.get("primary_recommendation", {}).get("fertilizer_type", "")
|
||||||
|
)
|
||||||
|
items.append(recommendation)
|
||||||
|
|
||||||
|
data = FertilizationRecommendationListItemSerializer(items, many=True).data
|
||||||
|
return paginator.get_paginated_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
class RecommendationDetailView(FarmAccessMixin, APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["Fertilization Recommendation"],
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="recommendation_uuid",
|
||||||
|
type=OpenApiTypes.UUID,
|
||||||
|
location=OpenApiParameter.PATH,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
responses={
|
||||||
|
200: code_response("FertilizationRecommendationDetailResponse", data=FertilizationRecommendResponseDataSerializer()),
|
||||||
|
404: code_response("FertilizationRecommendationDetailNotFoundResponse"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def get(self, request, recommendation_uuid):
|
||||||
|
recommendation = FertilizationRecommendationRequest.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)
|
||||||
|
|
||||||
|
view_helper = RecommendView()
|
||||||
|
data = view_helper._normalize_response_payload(recommendation.response_payload)
|
||||||
|
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["status"] = recommendation.status
|
||||||
|
data["status_label"] = recommendation.get_status_display()
|
||||||
|
return Response({"code": 200, "msg": "success", "data": data}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
# مستند API آبیاری و محصولات انتخابشده
|
||||||
|
|
||||||
|
این فایل برای تحویل به فرانت نوشته شده و endpointهای مرتبط با آبیاری را بهصورت کامل توضیح میدهد.
|
||||||
|
|
||||||
|
محدوده این مستند:
|
||||||
|
- همه endpointهای `irrigation_recommendation/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,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -6,6 +6,17 @@ from farm_hub.models import FarmHub
|
|||||||
|
|
||||||
|
|
||||||
class IrrigationRecommendationRequest(models.Model):
|
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)
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
|
||||||
farm = models.ForeignKey(
|
farm = models.ForeignKey(
|
||||||
FarmHub,
|
FarmHub,
|
||||||
@@ -13,8 +24,14 @@ class IrrigationRecommendationRequest(models.Model):
|
|||||||
related_name="irrigation_recommendations",
|
related_name="irrigation_recommendations",
|
||||||
)
|
)
|
||||||
crop_id = 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="")
|
||||||
task_id = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
task_id = models.CharField(max_length=255, blank=True, default="", db_index=True)
|
||||||
status = models.CharField(max_length=64, blank=True, default="")
|
status = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default=STATUS_PENDING_CONFIRMATION,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
request_payload = models.JSONField(default=dict, blank=True)
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
response_payload = models.JSONField(default=dict, blank=True)
|
response_payload = models.JSONField(default=dict, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
@@ -8,11 +8,26 @@ class IrrigationFarmDataSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
|
|
||||||
class IrrigationRecommendRequestSerializer(serializers.Serializer):
|
class IrrigationRecommendRequestSerializer(serializers.Serializer):
|
||||||
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای دریافت توصیه آبیاری.")
|
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="نام محصول یا گیاه.")
|
plant_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول یا گیاه.")
|
||||||
growth_stage = 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="نام روش آبیاری انتخابی.")
|
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):
|
class WaterStressRequestSerializer(serializers.Serializer):
|
||||||
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای محاسبه تنش آبی.")
|
farm_uuid = serializers.UUIDField(required=True, help_text="UUID مزرعه برای محاسبه تنش آبی.")
|
||||||
@@ -34,5 +49,32 @@ class IrrigationMethodSerializer(serializers.Serializer):
|
|||||||
updated_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 IrrigationRecommendResponseDataSerializer(serializers.Serializer):
|
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)
|
sections = serializers.ListField(child=serializers.DictField(), read_only=True)
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ def _extract_result(response_payload):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
data = response_payload.get("data")
|
data = response_payload.get("data")
|
||||||
if isinstance(data, dict) and isinstance(data.get("result"), dict):
|
if isinstance(data, dict):
|
||||||
|
if isinstance(data.get("result"), dict):
|
||||||
return data["result"]
|
return data["result"]
|
||||||
|
if any(key in data for key in ("plan", "water_balance", "timeline", "sections")):
|
||||||
|
return data
|
||||||
|
|
||||||
result = response_payload.get("result")
|
result = response_payload.get("result")
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if any(key in response_payload for key in ("plan", "water_balance", "timeline", "sections")):
|
||||||
|
return response_payload
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@@ -23,7 +29,7 @@ def _get_latest_result(farm):
|
|||||||
if farm is None:
|
if farm is None:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
for request in IrrigationRecommendationRequest.objects.filter(farm=farm):
|
for request in IrrigationRecommendationRequest.objects.filter(farm=farm).order_by("-created_at", "-id"):
|
||||||
result = _extract_result(request.response_payload)
|
result = _extract_result(request.response_payload)
|
||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
@@ -31,6 +37,138 @@ def _get_latest_result(farm):
|
|||||||
return {}
|
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):
|
def get_water_need_prediction_data(farm=None):
|
||||||
default_data = deepcopy(WATER_NEED_PREDICTION)
|
default_data = deepcopy(WATER_NEED_PREDICTION)
|
||||||
result = _get_latest_result(farm)
|
result = _get_latest_result(farm)
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ from rest_framework.test import APIRequestFactory, force_authenticate
|
|||||||
from external_api_adapter.adapter import AdapterResponse
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType
|
from farm_hub.models import FarmHub, FarmType
|
||||||
|
|
||||||
from .views import IrrigationMethodListView, WaterStressView
|
from .models import IrrigationRecommendationRequest
|
||||||
|
from .views import (
|
||||||
|
IrrigationMethodListView,
|
||||||
|
RecommendView,
|
||||||
|
RecommendationDetailView,
|
||||||
|
RecommendationListView,
|
||||||
|
WaterStressView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WaterStressViewTests(TestCase):
|
class WaterStressViewTests(TestCase):
|
||||||
@@ -139,3 +146,256 @@ class IrrigationMethodListViewTests(TestCase):
|
|||||||
method="POST",
|
method="POST",
|
||||||
payload={"name": "Drip"},
|
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_recommendation.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_recommendation.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": "آبیاری قطره ای",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import ConfigView, IrrigationMethodListView, RecommendView, WaterStressView
|
from .views import (
|
||||||
|
ConfigView,
|
||||||
|
IrrigationMethodListView,
|
||||||
|
RecommendationDetailView,
|
||||||
|
RecommendationListView,
|
||||||
|
RecommendView,
|
||||||
|
WaterStressView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"),
|
path("", IrrigationMethodListView.as_view(), name="irrigation-method-list"),
|
||||||
path("config/", ConfigView.as_view(), name="irrigation-recommendation-config"),
|
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("recommend/", RecommendView.as_view(), name="irrigation-recommendation-recommend"),
|
||||||
path("water-stress/", WaterStressView.as_view(), name="irrigation-water-stress"),
|
path("water-stress/", WaterStressView.as_view(), name="irrigation-water-stress"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ Irrigation Recommendation API views.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter
|
||||||
from rest_framework import serializers, status
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
|
|
||||||
from config.swagger import status_response
|
from config.swagger import code_response, status_response
|
||||||
from external_api_adapter import request as external_api_request
|
from external_api_adapter import request as external_api_request
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
from water.serializers import WaterStressIndexSerializer
|
from water.serializers import WaterStressIndexSerializer
|
||||||
@@ -18,15 +21,45 @@ from .mock_data import CONFIG_RESPONSE_DATA
|
|||||||
from .models import IrrigationRecommendationRequest
|
from .models import IrrigationRecommendationRequest
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
IrrigationMethodSerializer,
|
IrrigationMethodSerializer,
|
||||||
|
IrrigationRecommendationListItemSerializer,
|
||||||
|
IrrigationRecommendationListQuerySerializer,
|
||||||
IrrigationRecommendRequestSerializer,
|
IrrigationRecommendRequestSerializer,
|
||||||
IrrigationRecommendResponseDataSerializer,
|
IrrigationRecommendResponseDataSerializer,
|
||||||
WaterStressRequestSerializer,
|
WaterStressRequestSerializer,
|
||||||
)
|
)
|
||||||
|
from .services import build_recommendation_response
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class FarmAccessMixin:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_farm(request, farm_uuid):
|
def _get_farm(request, farm_uuid):
|
||||||
@@ -122,61 +155,6 @@ class IrrigationMethodListView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class RecommendView(FarmAccessMixin, APIView):
|
class RecommendView(FarmAccessMixin, APIView):
|
||||||
@staticmethod
|
|
||||||
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 _extract_public_sections(self, adapter_data):
|
|
||||||
if not isinstance(adapter_data, dict):
|
|
||||||
return []
|
|
||||||
|
|
||||||
data = adapter_data.get("data")
|
|
||||||
if isinstance(data, dict) and isinstance(data.get("sections"), list):
|
|
||||||
return self._normalize_sections(data.get("sections"))
|
|
||||||
|
|
||||||
result = data.get("result") if isinstance(data, dict) else None
|
|
||||||
if isinstance(result, dict) and isinstance(result.get("sections"), list):
|
|
||||||
return self._normalize_sections(result.get("sections"))
|
|
||||||
|
|
||||||
if isinstance(adapter_data.get("sections"), list):
|
|
||||||
return self._normalize_sections(adapter_data.get("sections"))
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["Irrigation Recommendation"],
|
tags=["Irrigation Recommendation"],
|
||||||
request=IrrigationRecommendRequestSerializer,
|
request=IrrigationRecommendRequestSerializer,
|
||||||
@@ -188,6 +166,15 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
payload = serializer.validated_data.copy()
|
payload = serializer.validated_data.copy()
|
||||||
farm = self._get_farm(request, payload.get("farm_uuid"))
|
farm = self._get_farm(request, payload.get("farm_uuid"))
|
||||||
payload["farm_uuid"] = str(farm.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(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
@@ -197,21 +184,26 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
response_data = adapter_response.data if isinstance(adapter_response.data, dict) else {}
|
||||||
public_sections = self._extract_public_sections(response_data)
|
recommendation_data = build_recommendation_response(response_data)
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s",
|
"Irrigation recommendation response parsed: farm_uuid=%s status_code=%s response_keys=%s sections_count=%s",
|
||||||
str(farm.farm_uuid),
|
str(farm.farm_uuid),
|
||||||
adapter_response.status_code,
|
adapter_response.status_code,
|
||||||
sorted(response_data.keys()) if isinstance(response_data, dict) else None,
|
sorted(response_data.keys()) if isinstance(response_data, dict) else None,
|
||||||
len(public_sections),
|
len(recommendation_data["sections"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
IrrigationRecommendationRequest.objects.create(
|
recommendation = IrrigationRecommendationRequest.objects.create(
|
||||||
farm=farm,
|
farm=farm,
|
||||||
crop_id=payload.get("plant_name", ""),
|
crop_id=payload.get("plant_name", ""),
|
||||||
|
growth_stage=payload.get("growth_stage", ""),
|
||||||
task_id="",
|
task_id="",
|
||||||
status="success" if adapter_response.status_code < 400 else "error",
|
status=(
|
||||||
|
IrrigationRecommendationRequest.STATUS_PENDING_CONFIRMATION
|
||||||
|
if adapter_response.status_code < 400
|
||||||
|
else IrrigationRecommendationRequest.STATUS_ERROR
|
||||||
|
),
|
||||||
request_payload=payload,
|
request_payload=payload,
|
||||||
response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data},
|
response_payload=adapter_response.data if isinstance(adapter_response.data, dict) else {"raw": adapter_response.data},
|
||||||
)
|
)
|
||||||
@@ -225,18 +217,88 @@ class RecommendView(FarmAccessMixin, APIView):
|
|||||||
status=adapter_response.status_code,
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"msg": "success",
|
"msg": "success",
|
||||||
"data": {
|
"data": recommendation_data,
|
||||||
"sections": public_sections,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
status=status.HTTP_200_OK,
|
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.irrigation_recommendations.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):
|
class WaterStressView(APIView):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_farm(request, farm_uuid):
|
def _get_farm(request, farm_uuid):
|
||||||
|
|||||||
Reference in New Issue
Block a user